mirror of
https://github.com/authelia/authelia.git
synced 2026-10-28 09:34:52 +08:00
feat(web): consistent rate limit information (#13510)
This drastically improves the user feedback surrounding rate limited endpoints displaying information about how long the user should wait. This also drastically improves the related tooling including adding a react hook and go funcs to ensure the desired outcome is always achieved.
This commit is contained in:
1 parent
54cd7cb4c3
commit
37a0538aec
50 files changed
+1449
-717
No files matched your search
+27
-27
@@ -714,9 +714,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
/api/reset-password/identity/finish:
|
||||
@@ -753,9 +753,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
/api/reset-password:
|
||||
@@ -791,9 +791,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
delete:
|
||||
@@ -826,9 +826,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
{{- end }}
|
||||
@@ -940,9 +940,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
put:
|
||||
@@ -983,9 +983,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
/api/user/session/elevation/{id}:
|
||||
@@ -1211,9 +1211,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
delete:
|
||||
@@ -1558,9 +1558,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
security:
|
||||
- authelia_auth: []
|
||||
/api/secondfactor/duo_devices:
|
||||
@@ -2141,9 +2141,9 @@ paths:
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: string
|
||||
format: http-date
|
||||
description: The date time that the request can be retried.
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: The number of seconds to wait before the request can be retried.
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestNewConfiguration(t *testing.T) {
|
||||
paths: []string{"../../internal/configuration/test_resources/config.webauthn.yml"},
|
||||
keys: []string{
|
||||
"regulation.max_retries",
|
||||
"server.endpoints.rate_limits.health.enable",
|
||||
"server.endpoints.rate_limits.openid_connect_introspection.enable",
|
||||
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable",
|
||||
"server.endpoints.rate_limits.openid_connect_revocation.enable",
|
||||
@@ -105,6 +106,7 @@ func TestNewConfiguration(t *testing.T) {
|
||||
"server.endpoints.authz.forward-auth.authn_strategies[].name",
|
||||
"server.endpoints.authz.forward-auth.implementation",
|
||||
"server.endpoints.authz.legacy.implementation",
|
||||
"server.endpoints.rate_limits.health.enable",
|
||||
"server.endpoints.rate_limits.openid_connect_introspection.enable",
|
||||
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable",
|
||||
"server.endpoints.rate_limits.openid_connect_revocation.enable",
|
||||
|
||||
@@ -4,23 +4,27 @@
|
||||
|
||||
package configuration
|
||||
|
||||
var defaults = map[string]any{
|
||||
"regulation.max_retries": 3,
|
||||
"server.endpoints.rate_limits.openid_connect_introspection.enable": true,
|
||||
"server.endpoints.rate_limits.openid_connect_pushed_authorization_request.enable": true,
|
||||
"server.endpoints.rate_limits.openid_connect_revocation.enable": true,
|
||||
"server.endpoints.rate_limits.openid_connect_token.enable": true,
|
||||
"server.endpoints.rate_limits.openid_connect_userinfo.enable": true,
|
||||
"server.endpoints.rate_limits.reset_password_start.enable": true,
|
||||
"server.endpoints.rate_limits.reset_password_finish.enable": true,
|
||||
"server.endpoints.rate_limits.second_factor_totp.enable": true,
|
||||
"server.endpoints.rate_limits.second_factor_duo.enable": true,
|
||||
"server.endpoints.rate_limits.second_factor_password.enable": true,
|
||||
"server.endpoints.rate_limits.session_elevation_start.enable": true,
|
||||
"server.endpoints.rate_limits.session_elevation_finish.enable": true,
|
||||
"webauthn.selection_criteria.discoverability": "preferred",
|
||||
"webauthn.selection_criteria.user_verification": "preferred",
|
||||
"webauthn.metadata.cache_policy": "strict",
|
||||
import (
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
)
|
||||
|
||||
var defaults = newDefaults()
|
||||
|
||||
func newDefaults() (values map[string]any) {
|
||||
values = map[string]any{
|
||||
"regulation.max_retries": 3,
|
||||
"webauthn.selection_criteria.discoverability": "preferred",
|
||||
"webauthn.selection_criteria.user_verification": "preferred",
|
||||
"webauthn.metadata.cache_policy": "strict",
|
||||
}
|
||||
|
||||
limits := schema.ServerEndpointRateLimits{}
|
||||
|
||||
for _, endpoint := range limits.Endpoints() {
|
||||
values["server.endpoints.rate_limits."+endpoint.Name+".enable"] = true
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Defaults returns a copy of the defaults.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/configuration/schema"
|
||||
)
|
||||
|
||||
func TestShouldEnableAllServerEndpointRateLimitsByDefault(t *testing.T) {
|
||||
rt := reflect.TypeFor[schema.ServerEndpointRateLimits]()
|
||||
|
||||
for i := range rt.NumField() {
|
||||
name := rt.Field(i).Tag.Get("koanf")
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.Equal(t, true, defaults["server.endpoints.rate_limits."+name+".enable"])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,46 @@ type ServerEndpointRateLimits struct {
|
||||
OpenIDConnectRevocation ServerEndpointRateLimit `koanf:"openid_connect_revocation" yaml:"openid_connect_revocation,omitempty" toml:"openid_connect_revocation,omitempty" json:"openid_connect_revocation,omitempty" jsonschema:"title=OpenID Connect Revocation" jsonschema_description:"Configures the rate limiter which applies to the OpenID Connect 1.0 Revocation Endpoint."`
|
||||
}
|
||||
|
||||
// Endpoints returns a ServerEndpointRateLimitEndpoint for every configurable rate limiter in field order.
|
||||
func (r *ServerEndpointRateLimits) Endpoints() []ServerEndpointRateLimitEndpoint {
|
||||
return []ServerEndpointRateLimitEndpoint{
|
||||
{Name: "reset_password_start", Config: &r.ResetPasswordStart},
|
||||
{Name: "reset_password_finish", Config: &r.ResetPasswordFinish},
|
||||
{Name: "second_factor_totp", Config: &r.SecondFactorTOTP},
|
||||
{Name: "second_factor_duo", Config: &r.SecondFactorDuo},
|
||||
{Name: "second_factor_password", Config: &r.SecondFactorPassword},
|
||||
{Name: "session_elevation_start", Config: &r.SessionElevationStart, Weight: weightElevatedSessionCodeLifespan},
|
||||
{Name: "health", Config: &r.Health},
|
||||
{Name: "session_elevation_finish", Config: &r.SessionElevationFinish, Weight: weightElevatedSessionElevationLifespan},
|
||||
{Name: "openid_connect_token", Config: &r.OpenIDConnectToken},
|
||||
{Name: "openid_connect_pushed_authorization_request", Config: &r.OpenIDConnectPushedAuthorizationRequest},
|
||||
{Name: "openid_connect_userinfo", Config: &r.OpenIDConnectUserInfo},
|
||||
{Name: "openid_connect_introspection", Config: &r.OpenIDConnectIntrospection},
|
||||
{Name: "openid_connect_revocation", Config: &r.OpenIDConnectRevocation},
|
||||
}
|
||||
}
|
||||
|
||||
// ServerEndpointRateLimitEndpoint describes a single endpoint rate limit of ServerEndpointRateLimits.
|
||||
type ServerEndpointRateLimitEndpoint struct {
|
||||
// Name is the configuration key of the endpoint rate limit.
|
||||
Name string
|
||||
|
||||
// Config is the endpoint rate limit configuration.
|
||||
Config *ServerEndpointRateLimit
|
||||
|
||||
// Weight returns the duration each default bucket period is multiplied by. A nil Weight means the default bucket
|
||||
// periods are absolute durations.
|
||||
Weight func(config *Configuration) time.Duration
|
||||
}
|
||||
|
||||
func weightElevatedSessionCodeLifespan(config *Configuration) time.Duration {
|
||||
return config.IdentityValidation.ElevatedSession.CodeLifespan
|
||||
}
|
||||
|
||||
func weightElevatedSessionElevationLifespan(config *Configuration) time.Duration {
|
||||
return config.IdentityValidation.ElevatedSession.ElevationLifespan
|
||||
}
|
||||
|
||||
// ServerEndpointRateLimit represents the rate limiter configuration for a single endpoint.
|
||||
type ServerEndpointRateLimit struct {
|
||||
Enable bool `koanf:"enable" yaml:"enable" toml:"enable" json:"enable" jsonschema:"default=true,title=Enable" jsonschema_description:"Enables the rate limiter for this endpoint."`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServerEndpointRateLimitsEndpoints(t *testing.T) {
|
||||
limits := &ServerEndpointRateLimits{}
|
||||
|
||||
endpoints := limits.Endpoints()
|
||||
|
||||
rv := reflect.ValueOf(limits).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
require.Len(t, endpoints, rt.NumField())
|
||||
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
t.Run(field.Name, func(t *testing.T) {
|
||||
assert.Equal(t, field.Tag.Get("koanf"), endpoints[i].Name)
|
||||
assert.Same(t, rv.Field(i).Addr().Interface().(*ServerEndpointRateLimit), endpoints[i].Config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerEndpointRateLimitsEndpointsWeight(t *testing.T) {
|
||||
config := &Configuration{
|
||||
IdentityValidation: IdentityValidation{
|
||||
ElevatedSession: IdentityValidationElevatedSession{
|
||||
CodeLifespan: time.Minute,
|
||||
ElevationLifespan: time.Hour,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"session_elevation_start", time.Minute},
|
||||
{"session_elevation_finish", time.Hour},
|
||||
}
|
||||
|
||||
limits := &ServerEndpointRateLimits{}
|
||||
|
||||
weighted := map[string]ServerEndpointRateLimitEndpoint{}
|
||||
|
||||
for _, endpoint := range limits.Endpoints() {
|
||||
if endpoint.Weight != nil {
|
||||
weighted[endpoint.Name] = endpoint
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, weighted, len(testCases))
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
endpoint, ok := weighted[tc.name]
|
||||
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tc.expected, endpoint.Weight(config))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -322,48 +322,31 @@ func validateServerEndpointsHealth(config *schema.Configuration, validator *sche
|
||||
}
|
||||
|
||||
func validateServerEndpointsRateLimits(config *schema.Configuration, validator *schema.StructValidator) {
|
||||
validateServerEndpointsRateLimitDefault("openid_connect_pushed_authorization_request", &config.Server.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest, schema.DefaultServerConfiguration.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest, validator)
|
||||
validateServerEndpointsRateLimitDefault("openid_connect_token", &config.Server.Endpoints.RateLimits.OpenIDConnectToken, schema.DefaultServerConfiguration.Endpoints.RateLimits.OpenIDConnectToken, validator)
|
||||
defaults := schema.DefaultServerConfiguration.Endpoints.RateLimits.Endpoints()
|
||||
|
||||
validateServerEndpointsRateLimitDefault("health", &config.Server.Endpoints.RateLimits.Health, schema.DefaultServerConfiguration.Endpoints.RateLimits.Health, validator)
|
||||
validateServerEndpointsRateLimitDefault("reset_password_start", &config.Server.Endpoints.RateLimits.ResetPasswordStart, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordStart, validator)
|
||||
validateServerEndpointsRateLimitDefault("reset_password_finish", &config.Server.Endpoints.RateLimits.ResetPasswordFinish, schema.DefaultServerConfiguration.Endpoints.RateLimits.ResetPasswordFinish, validator)
|
||||
|
||||
validateServerEndpointsRateLimitDefault("second_factor_totp", &config.Server.Endpoints.RateLimits.SecondFactorTOTP, schema.DefaultServerConfiguration.Endpoints.RateLimits.SecondFactorTOTP, validator)
|
||||
validateServerEndpointsRateLimitDefault("second_factor_duo", &config.Server.Endpoints.RateLimits.SecondFactorDuo, schema.DefaultServerConfiguration.Endpoints.RateLimits.SecondFactorDuo, validator)
|
||||
validateServerEndpointsRateLimitDefault("second_factor_password", &config.Server.Endpoints.RateLimits.SecondFactorPassword, schema.DefaultServerConfiguration.Endpoints.RateLimits.SecondFactorPassword, validator)
|
||||
|
||||
validateServerEndpointsRateLimitDefaultWeighted("session_elevation_start", &config.Server.Endpoints.RateLimits.SessionElevationStart, schema.DefaultServerConfiguration.Endpoints.RateLimits.SessionElevationStart, config.IdentityValidation.ElevatedSession.CodeLifespan, validator)
|
||||
validateServerEndpointsRateLimitDefaultWeighted("session_elevation_finish", &config.Server.Endpoints.RateLimits.SessionElevationFinish, schema.DefaultServerConfiguration.Endpoints.RateLimits.SessionElevationFinish, config.IdentityValidation.ElevatedSession.ElevationLifespan, validator)
|
||||
}
|
||||
|
||||
func validateServerEndpointsRateLimitDefault(name string, config *schema.ServerEndpointRateLimit, defaults schema.ServerEndpointRateLimit, validator *schema.StructValidator) {
|
||||
if len(config.Buckets) == 0 {
|
||||
config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults.Buckets))
|
||||
|
||||
copy(config.Buckets, defaults.Buckets)
|
||||
|
||||
return
|
||||
for i, endpoint := range config.Server.Endpoints.RateLimits.Endpoints() {
|
||||
validateServerEndpointsRateLimitDefault(config, endpoint, defaults[i].Config.Buckets, validator)
|
||||
}
|
||||
|
||||
validateServerEndpointsRateLimitBuckets(name, config, validator)
|
||||
}
|
||||
|
||||
func validateServerEndpointsRateLimitDefaultWeighted(name string, config *schema.ServerEndpointRateLimit, defaults schema.ServerEndpointRateLimit, weight time.Duration, validator *schema.StructValidator) {
|
||||
if len(config.Buckets) == 0 {
|
||||
config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults.Buckets))
|
||||
func validateServerEndpointsRateLimitDefault(config *schema.Configuration, endpoint schema.ServerEndpointRateLimitEndpoint, defaults []schema.ServerEndpointRateLimitBucket, validator *schema.StructValidator) {
|
||||
if len(endpoint.Config.Buckets) == 0 {
|
||||
endpoint.Config.Buckets = make([]schema.ServerEndpointRateLimitBucket, len(defaults))
|
||||
|
||||
for i := range defaults.Buckets {
|
||||
config.Buckets[i] = schema.ServerEndpointRateLimitBucket{
|
||||
Period: weight * defaults.Buckets[i].Period,
|
||||
Requests: defaults.Buckets[i].Requests,
|
||||
copy(endpoint.Config.Buckets, defaults)
|
||||
|
||||
if endpoint.Weight != nil {
|
||||
weight := endpoint.Weight(config)
|
||||
|
||||
for i := range endpoint.Config.Buckets {
|
||||
endpoint.Config.Buckets[i].Period *= weight
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
validateServerEndpointsRateLimitBuckets(name, config, validator)
|
||||
validateServerEndpointsRateLimitBuckets(endpoint.Name, endpoint.Config, validator)
|
||||
}
|
||||
|
||||
func validateServerEndpointsRateLimitBuckets(name string, config *schema.ServerEndpointRateLimit, validator *schema.StructValidator) {
|
||||
|
||||
@@ -58,6 +58,11 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: schema.ServerEndpointRateLimits{
|
||||
Health: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: 1 * time.Minute, Requests: 60},
|
||||
},
|
||||
},
|
||||
ResetPasswordStart: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: 10 * time.Minute, Requests: 5},
|
||||
@@ -84,6 +89,12 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
|
||||
{Period: 2 * time.Minute, Requests: 15},
|
||||
},
|
||||
},
|
||||
SecondFactorPassword: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: 5 * time.Minute, Requests: 30},
|
||||
{Period: 60 * time.Minute, Requests: 40},
|
||||
},
|
||||
},
|
||||
SessionElevationStart: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 1, Requests: 3},
|
||||
@@ -114,6 +125,30 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
|
||||
{Period: time.Hour, Requests: 100},
|
||||
},
|
||||
},
|
||||
OpenIDConnectUserInfo: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: 1 * time.Minute, Requests: 30},
|
||||
{Period: 2 * time.Minute, Requests: 40},
|
||||
{Period: 10 * time.Minute, Requests: 50},
|
||||
{Period: time.Hour, Requests: 100},
|
||||
},
|
||||
},
|
||||
OpenIDConnectIntrospection: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: 1 * time.Minute, Requests: 30},
|
||||
{Period: 2 * time.Minute, Requests: 40},
|
||||
{Period: 10 * time.Minute, Requests: 50},
|
||||
{Period: time.Hour, Requests: 100},
|
||||
},
|
||||
},
|
||||
OpenIDConnectRevocation: schema.ServerEndpointRateLimit{
|
||||
Buckets: []schema.ServerEndpointRateLimitBucket{
|
||||
{Period: 1 * time.Minute, Requests: 30},
|
||||
{Period: 2 * time.Minute, Requests: 40},
|
||||
{Period: 10 * time.Minute, Requests: 50},
|
||||
{Period: time.Hour, Requests: 100},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -128,14 +163,7 @@ func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
|
||||
assert.Len(t, validator.Errors(), 0)
|
||||
assert.Len(t, validator.Warnings(), 0)
|
||||
|
||||
assert.Equal(t, tc.expected.OpenIDConnectPushedAuthorizationRequest, tc.config.Server.Endpoints.RateLimits.OpenIDConnectPushedAuthorizationRequest)
|
||||
assert.Equal(t, tc.expected.OpenIDConnectToken, tc.config.Server.Endpoints.RateLimits.OpenIDConnectToken)
|
||||
assert.Equal(t, tc.expected.ResetPasswordStart, tc.config.Server.Endpoints.RateLimits.ResetPasswordStart)
|
||||
assert.Equal(t, tc.expected.ResetPasswordFinish, tc.config.Server.Endpoints.RateLimits.ResetPasswordFinish)
|
||||
assert.Equal(t, tc.expected.SecondFactorTOTP, tc.config.Server.Endpoints.RateLimits.SecondFactorTOTP)
|
||||
assert.Equal(t, tc.expected.SecondFactorDuo, tc.config.Server.Endpoints.RateLimits.SecondFactorDuo)
|
||||
assert.Equal(t, tc.expected.SessionElevationStart, tc.config.Server.Endpoints.RateLimits.SessionElevationStart)
|
||||
assert.Equal(t, tc.expected.SessionElevationFinish, tc.config.Server.Endpoints.RateLimits.SessionElevationFinish)
|
||||
assert.Equal(t, tc.expected, tc.config.Server.Endpoints.RateLimits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ package middlewares
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -205,7 +204,7 @@ func WithRateLimitCollector(collector *GarbageCollector) RateLimiterOption {
|
||||
func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) {
|
||||
ctx.SetStatusCode(fasthttp.StatusTooManyRequests)
|
||||
|
||||
ctx.Response.Header.SetBytesK(headerRetryAfter, time.Now().UTC().Add(retryAfter).Format(http.TimeFormat))
|
||||
ctx.Response.Header.SetBytesK(headerRetryAfter, formatRetryAfter(retryAfter))
|
||||
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueNoStore)
|
||||
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
|
||||
|
||||
@@ -216,7 +215,7 @@ func HandlerRateLimitAPI(ctx *AutheliaCtx, retryAfter time.Duration) {
|
||||
func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) {
|
||||
ctx.SetStatusCode(fasthttp.StatusTooManyRequests)
|
||||
|
||||
ctx.Response.Header.SetBytesK(headerRetryAfter, strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
|
||||
ctx.Response.Header.SetBytesK(headerRetryAfter, formatRetryAfter(retryAfter))
|
||||
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueNoStore)
|
||||
ctx.Response.Header.SetBytesKV(headerPragma, headerValueNoCache)
|
||||
ctx.Response.Header.SetBytesKV(headerContentType, contentTypeApplicationJSON)
|
||||
@@ -224,6 +223,10 @@ func HandlerRateLimitOpenIDConnect(ctx *AutheliaCtx, retryAfter time.Duration) {
|
||||
ctx.Response.SetBodyRaw(bodyOpenIDConnectRateLimitExceeded)
|
||||
}
|
||||
|
||||
func formatRetryAfter(retryAfter time.Duration) string {
|
||||
return strconv.Itoa(int(math.Ceil(retryAfter.Seconds())))
|
||||
}
|
||||
|
||||
func newRateLimiterHandler(next RequestHandler, buckets []RateLimitBucket, handler RateLimitRequestHandler, exemptStatusCodes []int) RequestHandler {
|
||||
isRateLimitExempt := newIsRateLimitExempt(exemptStatusCodes)
|
||||
|
||||
|
||||
@@ -838,14 +838,46 @@ func TestNewIsRateLimitExempt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandlerRateLimitAPI(t *testing.T) {
|
||||
ctx := newTestAutheliaCtx("192.168.1.1")
|
||||
testCases := []struct {
|
||||
name string
|
||||
retryAfter time.Duration
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"ShouldRenderWholeSeconds",
|
||||
time.Second * 30,
|
||||
"30",
|
||||
},
|
||||
{
|
||||
"ShouldRoundPartialSecondsUp",
|
||||
time.Millisecond * 1500,
|
||||
"2",
|
||||
},
|
||||
{
|
||||
"ShouldRenderSubSecondAsOne",
|
||||
time.Millisecond,
|
||||
"1",
|
||||
},
|
||||
{
|
||||
"ShouldRenderZero",
|
||||
0,
|
||||
"0",
|
||||
},
|
||||
}
|
||||
|
||||
HandlerRateLimitAPI(ctx, 30*time.Second)
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := newTestAutheliaCtx("192.168.1.1")
|
||||
|
||||
body := string(ctx.Response.Body())
|
||||
assert.Contains(t, body, "Too Many Requests")
|
||||
assert.Equal(t, fasthttp.StatusTooManyRequests, ctx.Response.StatusCode())
|
||||
assert.NotEmpty(t, ctx.Response.Header.Peek(fasthttp.HeaderRetryAfter))
|
||||
HandlerRateLimitAPI(ctx, tc.retryAfter)
|
||||
|
||||
assert.Equal(t, fasthttp.StatusTooManyRequests, ctx.Response.StatusCode())
|
||||
assert.Equal(t, tc.expected, string(ctx.Response.Header.Peek(fasthttp.HeaderRetryAfter)))
|
||||
assert.Equal(t, HeaderCacheControlNotStore, string(ctx.Response.Header.Peek(fasthttp.HeaderCacheControl)))
|
||||
assert.Equal(t, HeaderPragmaNoCache, string(ctx.Response.Header.Peek(fasthttp.HeaderPragma)))
|
||||
assert.JSONEq(t, `{"status":"KO","message":"Too Many Requests"}`, string(ctx.Response.Body()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRateLimitOpenIDConnect(t *testing.T) {
|
||||
|
||||
@@ -103,12 +103,16 @@
|
||||
"Time-based One-Time Password": "Time-based One-Time Password",
|
||||
"Toggle password visibility": "Toggle password visibility",
|
||||
"Touch the token of your security key": "Touch the token of your security key",
|
||||
"Try again in {{count}} seconds_one": "Try again in {{count}} second",
|
||||
"Try again in {{count}} seconds_other": "Try again in {{count}} seconds",
|
||||
"Use OpenID to verify your identity": "Use OpenID to verify your identity",
|
||||
"Username": "Username",
|
||||
"Username is required": "Username is required",
|
||||
"Warning": "Warning",
|
||||
"You cancelled the assertion request": "You cancelled the assertion request",
|
||||
"You have made too many requests": "You have made too many requests",
|
||||
"You have made too many requests, try again in {{count}} seconds_one": "You have made too many requests, try again in {{count}} second",
|
||||
"You have made too many requests, try again in {{count}} seconds_other": "You have made too many requests, try again in {{count}} seconds",
|
||||
"You must view and accept the Privacy Policy before using {{authelia}}.": "You must view and accept the \u003cpolicy\u003ePrivacy Policy\u003c/policy\u003e before using {{authelia}}",
|
||||
"You're being signed out and redirected": "You're being signed out and redirected",
|
||||
"Your browser does not support the WebAuthn protocol": "Your browser does not support the WebAuthn protocol",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
translate: vi.fn((key: string, options?: { count?: number }) => `${key}:${options?.count}`),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: mocks.translate }),
|
||||
}));
|
||||
|
||||
it("renders only an empty status region when there are no seconds remaining", () => {
|
||||
render(<RateLimitCountdown secondsRemaining={0} />);
|
||||
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the seconds remaining", () => {
|
||||
render(<RateLimitCountdown secondsRemaining={42} />);
|
||||
|
||||
expect(document.getElementById("rate-limit-countdown")).toHaveTextContent("Try again in {{count}} seconds:42");
|
||||
expect(mocks.translate).toHaveBeenCalledWith("Try again in {{count}} seconds", { count: 42 });
|
||||
});
|
||||
|
||||
it("announces the seconds remaining once when the countdown starts", () => {
|
||||
const { rerender } = render(<RateLimitCountdown secondsRemaining={0} />);
|
||||
|
||||
rerender(<RateLimitCountdown secondsRemaining={30} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Try again in {{count}} seconds:30");
|
||||
|
||||
rerender(<RateLimitCountdown secondsRemaining={29} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Try again in {{count}} seconds:30");
|
||||
expect(document.getElementById("rate-limit-countdown")).toHaveTextContent("Try again in {{count}} seconds:29");
|
||||
});
|
||||
|
||||
it("announces again when the countdown restarts", () => {
|
||||
const { rerender } = render(<RateLimitCountdown secondsRemaining={10} />);
|
||||
|
||||
rerender(<RateLimitCountdown secondsRemaining={5} />);
|
||||
rerender(<RateLimitCountdown secondsRemaining={20} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Try again in {{count}} seconds:20");
|
||||
});
|
||||
|
||||
it("clears the announcement when the countdown ends", () => {
|
||||
const { rerender } = render(<RateLimitCountdown secondsRemaining={1} />);
|
||||
|
||||
rerender(<RateLimitCountdown secondsRemaining={0} />);
|
||||
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface Props {
|
||||
secondsRemaining: number;
|
||||
}
|
||||
|
||||
const RateLimitCountdown = function (props: Props) {
|
||||
const { t: translate } = useTranslation("portal");
|
||||
const [previous, setPrevious] = useState(props.secondsRemaining);
|
||||
const [announced, setAnnounced] = useState(props.secondsRemaining);
|
||||
|
||||
if (props.secondsRemaining !== previous) {
|
||||
setPrevious(props.secondsRemaining);
|
||||
|
||||
if (props.secondsRemaining > previous || props.secondsRemaining <= 0) {
|
||||
setAnnounced(props.secondsRemaining);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<span role="status" className="sr-only">
|
||||
{announced > 0 ? translate("Try again in {{count}} seconds", { count: announced }) : null}
|
||||
</span>
|
||||
{props.secondsRemaining > 0 ? (
|
||||
<p id="rate-limit-countdown" className="w-full text-center text-sm text-muted-foreground">
|
||||
{translate("Try again in {{count}} seconds", { count: props.secondsRemaining })}
|
||||
</p>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateLimitCountdown;
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("is not limited initially", () => {
|
||||
const { result } = renderHook(() => useRateLimit());
|
||||
|
||||
expect(result.current.limited).toBe(false);
|
||||
expect(result.current.secondsRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("counts down the seconds remaining", () => {
|
||||
const { result } = renderHook(() => useRateLimit());
|
||||
|
||||
act(() => result.current.trigger(3));
|
||||
|
||||
expect(result.current.secondsRemaining).toBe(3);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.secondsRemaining).toBe(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.secondsRemaining).toBe(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.secondsRemaining).toBe(0);
|
||||
expect(result.current.limited).toBe(false);
|
||||
});
|
||||
|
||||
it("rounds a partial second up", () => {
|
||||
const { result } = renderHook(() => useRateLimit());
|
||||
|
||||
act(() => result.current.trigger(1.5));
|
||||
|
||||
expect(result.current.secondsRemaining).toBe(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.secondsRemaining).toBe(1);
|
||||
expect(result.current.limited).toBe(true);
|
||||
});
|
||||
|
||||
it("is limited until the retry period elapses", () => {
|
||||
const onExpire = vi.fn();
|
||||
const { result } = renderHook(() => useRateLimit(onExpire));
|
||||
|
||||
act(() => result.current.trigger(30));
|
||||
|
||||
expect(result.current.limited).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(29999);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(true);
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(false);
|
||||
expect(onExpire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onExpire once when the countdown and the retry period end together", () => {
|
||||
const onExpire = vi.fn();
|
||||
const { result } = renderHook(() => useRateLimit(onExpire));
|
||||
|
||||
act(() => result.current.trigger(2));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(false);
|
||||
expect(onExpire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays limited for a retry period longer than the maximum timeout delay", () => {
|
||||
const onExpire = vi.fn();
|
||||
const { result } = renderHook(() => useRateLimit(onExpire));
|
||||
const retryAfter = 30 * 24 * 60 * 60;
|
||||
|
||||
act(() => result.current.trigger(retryAfter));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(true);
|
||||
expect(result.current.secondsRemaining).toBe(retryAfter - 1);
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
|
||||
vi.setSystemTime(Date.now() + (retryAfter - 3) * 1000);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(true);
|
||||
expect(result.current.secondsRemaining).toBe(1);
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(false);
|
||||
expect(onExpire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restarts the retry period when triggered again", () => {
|
||||
const onExpire = vi.fn();
|
||||
const { result } = renderHook(() => useRateLimit(onExpire));
|
||||
|
||||
act(() => result.current.trigger(10));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
act(() => result.current.trigger(10));
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(true);
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(result.current.limited).toBe(false);
|
||||
expect(onExpire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the limit without calling onExpire", () => {
|
||||
const onExpire = vi.fn();
|
||||
const { result } = renderHook(() => useRateLimit(onExpire));
|
||||
|
||||
act(() => result.current.trigger(30));
|
||||
act(() => result.current.clear());
|
||||
|
||||
expect(result.current.limited).toBe(false);
|
||||
expect(result.current.secondsRemaining).toBe(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the latest onExpire", () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const { rerender, result } = renderHook(({ onExpire }) => useRateLimit(onExpire), {
|
||||
initialProps: { onExpire: first },
|
||||
});
|
||||
|
||||
act(() => result.current.trigger(10));
|
||||
|
||||
rerender({ onExpire: second });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the timer on unmount", () => {
|
||||
const onExpire = vi.fn();
|
||||
const { result, unmount } = renderHook(() => useRateLimit(onExpire));
|
||||
|
||||
act(() => result.current.trigger(30));
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useCallback, useEffect, useEffectEvent, useState } from "react";
|
||||
|
||||
const MaximumTimeoutDelay = 2_147_483_647;
|
||||
|
||||
function secondsUntil(deadline: number) {
|
||||
return Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
export const useRateLimit = (onExpire?: () => void) => {
|
||||
const [deadline, setDeadline] = useState<null | number>(null);
|
||||
const [secondsRemaining, setSecondsRemaining] = useState(0);
|
||||
|
||||
const handleExpire = useEffectEvent(() => {
|
||||
setDeadline(null);
|
||||
setSecondsRemaining(0);
|
||||
onExpire?.();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (deadline === null) return;
|
||||
|
||||
let interval: null | ReturnType<typeof setInterval> = null;
|
||||
let timeout: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
const stop = () => {
|
||||
if (interval !== null) clearInterval(interval);
|
||||
if (timeout !== null) clearTimeout(timeout);
|
||||
|
||||
interval = null;
|
||||
timeout = null;
|
||||
};
|
||||
|
||||
const expire = () => {
|
||||
stop();
|
||||
handleExpire();
|
||||
};
|
||||
|
||||
interval = setInterval(() => {
|
||||
const seconds = secondsUntil(deadline);
|
||||
|
||||
if (seconds === 0) {
|
||||
expire();
|
||||
} else {
|
||||
setSecondsRemaining(seconds);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const delay = Math.max(0, deadline - Date.now());
|
||||
|
||||
if (delay <= MaximumTimeoutDelay) timeout = setTimeout(expire, delay);
|
||||
|
||||
return stop;
|
||||
}, [deadline]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setDeadline(null);
|
||||
setSecondsRemaining(0);
|
||||
}, []);
|
||||
|
||||
const trigger = useCallback((retryAfter: number) => {
|
||||
setDeadline(Date.now() + retryAfter * 1000);
|
||||
setSecondsRemaining(Math.ceil(retryAfter));
|
||||
}, []);
|
||||
|
||||
return { clear, limited: deadline !== null, secondsRemaining, trigger };
|
||||
};
|
||||
|
||||
export default useRateLimit;
|
||||
@@ -9,6 +9,9 @@ import { createRoot } from "react-dom/client";
|
||||
import "@root/index.css";
|
||||
import App from "@root/App";
|
||||
import "@i18n/index";
|
||||
import { registerRateLimitInterceptor } from "@services/RateLimit";
|
||||
|
||||
registerRateLimitInterceptor();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -7,58 +7,11 @@ import { AxiosResponse } from "axios";
|
||||
import {
|
||||
hasServiceError,
|
||||
toData,
|
||||
toDataRateLimited,
|
||||
validateStatusAuthentication,
|
||||
validateStatusOneTimeCode,
|
||||
validateStatusTooManyRequests,
|
||||
validateStatusWebAuthnCreation,
|
||||
} from "@services/Api";
|
||||
|
||||
it("throws for missing retry-after header", () => {
|
||||
const resp = { data: { status: "KO" }, headers: {}, status: 429 } as any;
|
||||
expect(() => toDataRateLimited(resp)).toThrow("Header Retry-After is missing");
|
||||
});
|
||||
|
||||
it("handles numeric retry-after", () => {
|
||||
const resp = { data: { status: "KO" }, headers: { "retry-after": "30" }, status: 429 } as any;
|
||||
const result = toDataRateLimited(resp);
|
||||
expect(result?.limited).toBe(true);
|
||||
expect(result?.retryAfter).toBe(30);
|
||||
});
|
||||
|
||||
it("handles date retry-after", () => {
|
||||
const future = new Date(Date.now() + 60000).toUTCString();
|
||||
const resp = { data: { status: "KO" }, headers: { "retry-after": future }, status: 429 } as any;
|
||||
const result = toDataRateLimited(resp);
|
||||
expect(result?.limited).toBe(true);
|
||||
expect(result?.retryAfter).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("throws for invalid date retry-after", () => {
|
||||
const resp = { data: { status: "KO" }, headers: { "retry-after": "invalid-date" }, status: 429 } as any;
|
||||
expect(() => toDataRateLimited(resp)).toThrow("Header Retry-After has an invalid date value");
|
||||
});
|
||||
|
||||
it("returns data for ok status", () => {
|
||||
const resp = { data: { data: "test", status: "OK" }, status: 200 } as AxiosResponse;
|
||||
expect(toDataRateLimited(resp)).toEqual({ data: "test", limited: false, retryAfter: 0 });
|
||||
});
|
||||
|
||||
it("returns limited for ko status with 429", () => {
|
||||
const resp = { data: { status: "KO" }, headers: { "retry-after": "30" }, status: 429 } as any;
|
||||
expect(toDataRateLimited(resp)).toEqual({ limited: true, retryAfter: 30 });
|
||||
});
|
||||
|
||||
it("returns limited for 429 status", () => {
|
||||
const resp = { data: { status: "OTHER" }, headers: { "retry-after": "30" }, status: 429 } as any;
|
||||
expect(toDataRateLimited(resp)).toEqual({ limited: true, retryAfter: 30 });
|
||||
});
|
||||
|
||||
it("returns undefined for no data", () => {
|
||||
const resp = { data: null, status: 200 } as AxiosResponse;
|
||||
expect(toDataRateLimited(resp)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports no error for ok response", () => {
|
||||
const resp = { data: { data: "test", status: "OK" }, status: 200 } as AxiosResponse;
|
||||
expect(hasServiceError(resp)).toEqual({ errored: false, message: null });
|
||||
@@ -69,14 +22,6 @@ it("reports error for ko response", () => {
|
||||
expect(hasServiceError(resp)).toEqual({ errored: true, message: "bad request" });
|
||||
});
|
||||
|
||||
it("validates status for too many requests", () => {
|
||||
expect(validateStatusTooManyRequests(200)).toBe(true);
|
||||
expect(validateStatusTooManyRequests(299)).toBe(true);
|
||||
expect(validateStatusTooManyRequests(429)).toBe(true);
|
||||
expect(validateStatusTooManyRequests(400)).toBe(false);
|
||||
expect(validateStatusTooManyRequests(500)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates status for authentication", () => {
|
||||
expect(validateStatusAuthentication(200)).toBe(true);
|
||||
expect(validateStatusAuthentication(401)).toBe(true);
|
||||
@@ -123,20 +68,3 @@ describe("toData", () => {
|
||||
expect(toData(resp)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDataRateLimited", () => {
|
||||
it("reports a failure that is not rate limited", () => {
|
||||
const resp = { data: { status: "KO" }, headers: {}, status: 400 } as any;
|
||||
expect(toDataRateLimited(resp)).toEqual({ limited: false, retryAfter: 0 });
|
||||
});
|
||||
|
||||
it("returns undefined without data", () => {
|
||||
const resp = { data: undefined, headers: {}, status: 200 } as any;
|
||||
expect(toDataRateLimited(resp)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown status on a non 429 response", () => {
|
||||
const resp = { data: { status: "MAYBE" }, headers: {}, status: 200 } as any;
|
||||
expect(toDataRateLimited(resp)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -59,11 +59,6 @@ export interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorRateLimitResponse extends ErrorResponse {
|
||||
limited: boolean;
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
export interface Response<T> extends OKResponse {
|
||||
data: T;
|
||||
}
|
||||
@@ -97,54 +92,6 @@ export function toData<T>(resp: AxiosResponse<ServiceResponse<T>>): T | undefine
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type RateLimitedData<T> = {
|
||||
data?: T;
|
||||
limited: boolean;
|
||||
retryAfter: number;
|
||||
};
|
||||
|
||||
function getRetryAfter(resp: AxiosResponse): number {
|
||||
if (resp.status !== 429) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const valueRetryAfter = resp.headers["retry-after"];
|
||||
if (valueRetryAfter) {
|
||||
if (/^\d+$/.test(valueRetryAfter)) {
|
||||
const retryAfter = Number.parseFloat(valueRetryAfter);
|
||||
|
||||
if (Number.isNaN(retryAfter)) {
|
||||
throw new TypeError("Header Retry-After has an invalid number value");
|
||||
}
|
||||
|
||||
return retryAfter;
|
||||
} else {
|
||||
const date = new Date(valueRetryAfter);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new TypeError("Header Retry-After has an invalid date value");
|
||||
}
|
||||
|
||||
return Math.max(0, (date.getTime() - Date.now()) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Header Retry-After is missing");
|
||||
}
|
||||
|
||||
export function toDataRateLimited<T>(resp: AxiosResponse<ServiceResponse<T>>): RateLimitedData<T> | undefined {
|
||||
if (resp.data && "status" in resp.data) {
|
||||
if (resp.data["status"] === "OK") {
|
||||
return { data: resp.data.data, limited: false, retryAfter: 0 };
|
||||
} else if (resp.data["status"] === "KO") {
|
||||
return { limited: resp.status === 429, retryAfter: getRetryAfter(resp) };
|
||||
} else if (resp.status === 429) {
|
||||
return { limited: true, retryAfter: getRetryAfter(resp) };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasError(err: ErrorResponse | undefined) {
|
||||
if (err?.status === "KO") {
|
||||
return { errored: true, message: err.message };
|
||||
@@ -159,10 +106,6 @@ export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
|
||||
return hasError(errResp);
|
||||
}
|
||||
|
||||
export function validateStatusTooManyRequests(status: number) {
|
||||
return (status >= 200 && status < 300) || status === 429;
|
||||
}
|
||||
|
||||
export function validateStatusAuthentication(status: number) {
|
||||
return (status >= 200 && status < 300) || status === 401 || status === 403;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import { hasServiceError, toData, toDataRateLimited } from "@services/Api";
|
||||
import { hasServiceError, toData } from "@services/Api";
|
||||
import * as Client from "@services/Client";
|
||||
|
||||
vi.mock("axios");
|
||||
@@ -30,26 +30,6 @@ it("handles successful post with optional response", async () => {
|
||||
expect(result).toBe("test");
|
||||
});
|
||||
|
||||
it("handles successful rate limited post", async () => {
|
||||
const mockRes = { data: { data: "test", status: "OK" }, status: 200 };
|
||||
(axios.post as any).mockResolvedValue(mockRes);
|
||||
(hasServiceError as any).mockReturnValue({ errored: false });
|
||||
(toDataRateLimited as any).mockReturnValue({ data: "test", limited: false, retryAfter: 0 });
|
||||
|
||||
const result = await Client.PostWithOptionalResponseRateLimited("/path", {});
|
||||
expect(result).toEqual({ data: "test", limited: false, retryAfter: 0 });
|
||||
});
|
||||
|
||||
it("handles rate limited post", async () => {
|
||||
const mockRes = { data: { status: "KO" }, headers: { "retry-after": "30" }, status: 429 };
|
||||
(axios.post as any).mockResolvedValue(mockRes);
|
||||
(hasServiceError as any).mockReturnValue({ errored: true });
|
||||
(toDataRateLimited as any).mockReturnValue({ limited: true, retryAfter: 30 });
|
||||
|
||||
const result = await Client.PostWithOptionalResponseRateLimited("/path", {});
|
||||
expect(result).toEqual({ limited: true, retryAfter: 30 });
|
||||
});
|
||||
|
||||
it("throws on post error", async () => {
|
||||
const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
|
||||
(axios.post as any).mockResolvedValue(mockRes);
|
||||
@@ -60,16 +40,6 @@ it("throws on post error", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on rate limited post error", async () => {
|
||||
const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
|
||||
(axios.post as any).mockResolvedValue(mockRes);
|
||||
(hasServiceError as any).mockReturnValue({ errored: true, message: "error" });
|
||||
|
||||
await expect(Client.PostWithOptionalResponseRateLimited("/path", {})).rejects.toThrow(
|
||||
"Failed POST to /path. Code: 400. Message: error",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on post with no data", async () => {
|
||||
const mockRes = { data: { status: "OK" }, status: 200 };
|
||||
(axios.post as any).mockResolvedValue(mockRes);
|
||||
@@ -211,7 +181,6 @@ it("forwards the abort signal to axios on every helper", async () => {
|
||||
(axios.delete as any).mockResolvedValue(mockRes);
|
||||
(hasServiceError as any).mockReturnValue({ errored: false });
|
||||
(toData as any).mockReturnValue("test");
|
||||
(toDataRateLimited as any).mockReturnValue({ data: "test", limited: false, retryAfter: 0 });
|
||||
|
||||
await Client.Get("/path", signal);
|
||||
expect(axios.get).toHaveBeenLastCalledWith("/path", { signal });
|
||||
@@ -225,9 +194,6 @@ it("forwards the abort signal to axios on every helper", async () => {
|
||||
await Client.PostWithOptionalResponse("/path", { body: 1 }, signal);
|
||||
expect(axios.post).toHaveBeenLastCalledWith("/path", { body: 1 }, { signal });
|
||||
|
||||
await Client.PostWithOptionalResponseRateLimited("/path", { body: 1 }, signal);
|
||||
expect(axios.post).toHaveBeenLastCalledWith("/path", { body: 1 }, expect.objectContaining({ signal }));
|
||||
|
||||
await Client.Put("/path", { body: 1 }, signal);
|
||||
expect(axios.put).toHaveBeenLastCalledWith("/path", { body: 1 }, { signal });
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import {
|
||||
RateLimitedData,
|
||||
ServiceResponse,
|
||||
hasServiceError,
|
||||
toData,
|
||||
toDataRateLimited,
|
||||
validateStatusTooManyRequests,
|
||||
} from "@services/Api";
|
||||
import { ServiceResponse, hasServiceError, toData } from "@services/Api";
|
||||
|
||||
export async function PutWithOptionalResponse<T = undefined>(
|
||||
path: string,
|
||||
@@ -41,27 +34,6 @@ export async function PostWithOptionalResponse<T = undefined>(
|
||||
return toData<T>(res);
|
||||
}
|
||||
|
||||
export async function PostWithOptionalResponseRateLimited<T = undefined>(
|
||||
path: string,
|
||||
body?: any,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RateLimitedData<T> | undefined> {
|
||||
const res = await axios.post<ServiceResponse<T>>(path, body, {
|
||||
signal,
|
||||
validateStatus: validateStatusTooManyRequests,
|
||||
});
|
||||
|
||||
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||
if (res.status === 429) {
|
||||
return toDataRateLimited<T>(res);
|
||||
}
|
||||
|
||||
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
|
||||
}
|
||||
|
||||
return toDataRateLimited<T>(res);
|
||||
}
|
||||
|
||||
export async function DeleteWithOptionalResponse<T = undefined>(
|
||||
path: string,
|
||||
body?: any,
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {
|
||||
DeleteWithOptionalResponse,
|
||||
PostWithOptionalResponse,
|
||||
PostWithOptionalResponseRateLimited,
|
||||
} from "@services/Client";
|
||||
import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
|
||||
import { completeTOTPRegister, completeTOTPSignIn, stopTOTPRegister } from "@services/OneTimePassword";
|
||||
|
||||
vi.mock("@services/Api", () => ({
|
||||
@@ -16,13 +12,12 @@ vi.mock("@services/Api", () => ({
|
||||
vi.mock("@services/Client", () => ({
|
||||
DeleteWithOptionalResponse: vi.fn(),
|
||||
PostWithOptionalResponse: vi.fn(),
|
||||
PostWithOptionalResponseRateLimited: vi.fn(),
|
||||
}));
|
||||
|
||||
it("completes totp sign in", async () => {
|
||||
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
|
||||
(PostWithOptionalResponse as any).mockResolvedValue("response");
|
||||
const result = await completeTOTPSignIn("123456", "url", "flow", "flowtype", "sub", "code");
|
||||
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/totp/signin", {
|
||||
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/totp/signin", {
|
||||
flow: "flowtype",
|
||||
flowID: "flow",
|
||||
subflow: "sub",
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api";
|
||||
import {
|
||||
DeleteWithOptionalResponse,
|
||||
PostWithOptionalResponse,
|
||||
PostWithOptionalResponseRateLimited,
|
||||
} from "@services/Client";
|
||||
import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
|
||||
import { SignInResponse } from "@services/SignIn";
|
||||
|
||||
interface CompleteTOTPSignInBody {
|
||||
@@ -36,7 +32,7 @@ export function completeTOTPSignIn(
|
||||
userCode,
|
||||
};
|
||||
|
||||
return PostWithOptionalResponseRateLimited<SignInResponse>(CompleteTOTPSignInPath, body);
|
||||
return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
|
||||
}
|
||||
|
||||
export function completeTOTPRegister(passcode: string) {
|
||||
|
||||
@@ -61,7 +61,7 @@ it("posts first factor reauthenticate without response", async () => {
|
||||
});
|
||||
|
||||
it("posts second factor with response", async () => {
|
||||
const mockResponse = { token: "abc" };
|
||||
const mockResponse = { redirect: "abc" };
|
||||
(PostWithOptionalResponse as any).mockResolvedValue(mockResponse);
|
||||
const result = await postSecondFactor("pass", "url", "flow", "flowtype", "sub");
|
||||
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/password/signin", {
|
||||
@@ -77,5 +77,5 @@ it("posts second factor with response", async () => {
|
||||
it("posts second factor without response", async () => {
|
||||
(PostWithOptionalResponse as any).mockResolvedValue(undefined);
|
||||
const result = await postSecondFactor("pass");
|
||||
expect(result).toEqual({});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -101,6 +101,5 @@ export async function postSecondFactor(
|
||||
targetURL,
|
||||
};
|
||||
|
||||
const res = await PostWithOptionalResponse<SignInResponse>(CompletePasswordSignInPath, data);
|
||||
return res ?? ({} as SignInResponse);
|
||||
return PostWithOptionalResponse<SignInResponse>(CompletePasswordSignInPath, data);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
|
||||
import { Get, PostWithOptionalResponse } from "@services/Client";
|
||||
import {
|
||||
completeDuoDeviceSelectionProcess,
|
||||
completePushNotificationSignIn,
|
||||
@@ -17,13 +17,12 @@ vi.mock("@services/Api", () => ({
|
||||
vi.mock("@services/Client", () => ({
|
||||
Get: vi.fn(),
|
||||
PostWithOptionalResponse: vi.fn(),
|
||||
PostWithOptionalResponseRateLimited: vi.fn(),
|
||||
}));
|
||||
|
||||
it("completes push notification sign in", async () => {
|
||||
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
|
||||
(PostWithOptionalResponse as any).mockResolvedValue("response");
|
||||
const result = await completePushNotificationSignIn("url", "flow", "flowtype", "sub", "code");
|
||||
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith(
|
||||
expect(PostWithOptionalResponse).toHaveBeenCalledWith(
|
||||
"/duo/signin",
|
||||
{
|
||||
flow: "flowtype",
|
||||
@@ -69,12 +68,12 @@ it("gets preferred duo device", async () => {
|
||||
it("forwards the abort signal when provided", async () => {
|
||||
const { getPreferredDuoDevice } = await import("@services/PushNotification");
|
||||
const signal = new AbortController().signal;
|
||||
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
|
||||
(PostWithOptionalResponse as any).mockResolvedValue("response");
|
||||
(PostWithOptionalResponse as any).mockResolvedValue("response");
|
||||
(Get as any).mockResolvedValue("response");
|
||||
|
||||
await completePushNotificationSignIn("url", "flow", "flowtype", "sub", "code", signal);
|
||||
expect(PostWithOptionalResponseRateLimited).toHaveBeenLastCalledWith("/duo/signin", expect.any(Object), signal);
|
||||
expect(PostWithOptionalResponse).toHaveBeenLastCalledWith("/duo/signin", expect.any(Object), signal);
|
||||
|
||||
await initiateDuoDeviceSelectionProcess(signal);
|
||||
expect(Get).toHaveBeenLastCalledWith("/duo/devices", signal);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CompletePushNotificationSignInPath,
|
||||
InitiateDuoDeviceSelectionPath,
|
||||
} from "@services/Api";
|
||||
import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
|
||||
import { Get, PostWithOptionalResponse } from "@services/Client";
|
||||
|
||||
interface CompletePushSignInBody {
|
||||
targetURL?: string;
|
||||
@@ -33,7 +33,7 @@ export function completePushNotificationSignIn(
|
||||
userCode,
|
||||
};
|
||||
|
||||
return PostWithOptionalResponseRateLimited<DuoSignInResponse>(CompletePushNotificationSignInPath, body, signal);
|
||||
return PostWithOptionalResponse<DuoSignInResponse>(CompletePushNotificationSignInPath, body, signal);
|
||||
}
|
||||
|
||||
export interface DuoSignInResponse {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import axios, { AxiosAdapter, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
import {
|
||||
DefaultRetryAfterSeconds,
|
||||
MaximumRetryAfterSeconds,
|
||||
MinimumRetryAfterSeconds,
|
||||
RateLimitedError,
|
||||
getRetryAfter,
|
||||
registerRateLimitInterceptor,
|
||||
} from "@services/RateLimit";
|
||||
|
||||
function newResponse(status: number, headers: Record<string, string> = {}): AxiosResponse {
|
||||
return {
|
||||
config: { headers: {} } as InternalAxiosRequestConfig,
|
||||
data: { message: "Too Many Requests", status: "KO" },
|
||||
headers,
|
||||
status,
|
||||
statusText: "",
|
||||
};
|
||||
}
|
||||
|
||||
function newInstance(status: number, headers: Record<string, string> = {}, validateStatus?: (s: number) => boolean) {
|
||||
const adapter: AxiosAdapter = async (config) => {
|
||||
const resp = { ...newResponse(status, headers), config };
|
||||
|
||||
if (config.validateStatus && !config.validateStatus(status)) {
|
||||
throw new AxiosError("Request failed", undefined, config, undefined, resp);
|
||||
}
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
const instance = validateStatus ? axios.create({ adapter, validateStatus }) : axios.create({ adapter });
|
||||
|
||||
registerRateLimitInterceptor(instance);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
describe("getRetryAfter", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ expected: 30, headers: { "retry-after": "30" }, name: "delta seconds" },
|
||||
{ expected: MinimumRetryAfterSeconds, headers: { "retry-after": "0" }, name: "zero delta seconds" },
|
||||
{ expected: 60, headers: { "retry-after": "Thu, 01 Jan 2026 00:01:00 GMT" }, name: "http date" },
|
||||
{
|
||||
expected: MinimumRetryAfterSeconds,
|
||||
headers: { "retry-after": "Thu, 01 Jan 2026 00:00:00 GMT" },
|
||||
name: "http date now",
|
||||
},
|
||||
{
|
||||
expected: MinimumRetryAfterSeconds,
|
||||
headers: { "retry-after": "Wed, 31 Dec 2025 23:59:00 GMT" },
|
||||
name: "http date in the past",
|
||||
},
|
||||
{
|
||||
expected: MaximumRetryAfterSeconds,
|
||||
headers: { "retry-after": String(MaximumRetryAfterSeconds) },
|
||||
name: "maximum delta seconds",
|
||||
},
|
||||
{
|
||||
expected: MaximumRetryAfterSeconds,
|
||||
headers: { "retry-after": String(MaximumRetryAfterSeconds + 1) },
|
||||
name: "delta seconds above the maximum",
|
||||
},
|
||||
{
|
||||
expected: DefaultRetryAfterSeconds,
|
||||
headers: { "retry-after": "9".repeat(400) },
|
||||
name: "delta seconds too large to represent",
|
||||
},
|
||||
{
|
||||
expected: MaximumRetryAfterSeconds,
|
||||
headers: { "retry-after": "Sat, 01 Jan 2028 00:00:00 GMT" },
|
||||
name: "http date beyond the maximum",
|
||||
},
|
||||
{ expected: DefaultRetryAfterSeconds, headers: {}, name: "missing header" },
|
||||
{ expected: DefaultRetryAfterSeconds, headers: { "retry-after": "" }, name: "empty header" },
|
||||
{ expected: DefaultRetryAfterSeconds, headers: { "retry-after": "invalid" }, name: "invalid header" },
|
||||
] as { expected: number; headers: Record<string, string>; name: string }[])(
|
||||
"returns the retry after for $name",
|
||||
({ expected, headers }) => {
|
||||
expect(getRetryAfter(newResponse(429, headers))).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("rounds a partial second up", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:59.500Z"));
|
||||
|
||||
expect(getRetryAfter(newResponse(429, { "retry-after": "Thu, 01 Jan 2026 00:01:00 GMT" }))).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerRateLimitInterceptor", () => {
|
||||
it("throws a RateLimitedError when the response is rejected with a 429", async () => {
|
||||
const instance = newInstance(429, { "retry-after": "30" });
|
||||
|
||||
const err = await instance.get("/api").catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(RateLimitedError);
|
||||
expect((err as RateLimitedError).retryAfter).toBe(30);
|
||||
expect((err as RateLimitedError).message).toBe("Too Many Requests");
|
||||
});
|
||||
|
||||
it("throws a RateLimitedError when validateStatus accepts a 429", async () => {
|
||||
const instance = newInstance(429, { "retry-after": "15" }, () => true);
|
||||
|
||||
const err = await instance.get("/api").catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(RateLimitedError);
|
||||
expect((err as RateLimitedError).retryAfter).toBe(15);
|
||||
});
|
||||
|
||||
it("uses the default retry after when the header is missing", async () => {
|
||||
const instance = newInstance(429);
|
||||
|
||||
const err = await instance.get("/api").catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(RateLimitedError);
|
||||
expect((err as RateLimitedError).retryAfter).toBe(DefaultRetryAfterSeconds);
|
||||
});
|
||||
|
||||
it("passes through other errors unchanged", async () => {
|
||||
const instance = newInstance(401);
|
||||
|
||||
const err = await instance.get("/api").catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(AxiosError);
|
||||
expect(err).not.toBeInstanceOf(RateLimitedError);
|
||||
expect((err as AxiosError).response?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("passes through successful responses unchanged", async () => {
|
||||
const instance = newInstance(200);
|
||||
|
||||
const resp = await instance.get("/api");
|
||||
|
||||
expect(resp.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-FileCopyrightText: 2026 Authelia
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
|
||||
export const DefaultRetryAfterSeconds = 10;
|
||||
export const MinimumRetryAfterSeconds = 1;
|
||||
export const MaximumRetryAfterSeconds = 86400;
|
||||
|
||||
export class RateLimitedError extends Error {
|
||||
readonly retryAfter: number;
|
||||
|
||||
constructor(retryAfter: number) {
|
||||
super("Too Many Requests");
|
||||
this.name = "RateLimitedError";
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRetryAfter(resp: AxiosResponse): number {
|
||||
const value = resp.headers?.["retry-after"];
|
||||
|
||||
if (typeof value !== "string" || value === "") {
|
||||
return DefaultRetryAfterSeconds;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(value)) {
|
||||
return clampRetryAfter(Number.parseInt(value, 10));
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return DefaultRetryAfterSeconds;
|
||||
}
|
||||
|
||||
return clampRetryAfter(Math.ceil((date.getTime() - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function clampRetryAfter(seconds: number): number {
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return DefaultRetryAfterSeconds;
|
||||
}
|
||||
|
||||
return Math.min(MaximumRetryAfterSeconds, Math.max(MinimumRetryAfterSeconds, seconds));
|
||||
}
|
||||
|
||||
export function registerRateLimitInterceptor(instance: AxiosInstance = axios) {
|
||||
return instance.interceptors.response.use(
|
||||
(resp) => {
|
||||
if (resp.status === 429) {
|
||||
throw new RateLimitedError(getRetryAfter(resp));
|
||||
}
|
||||
|
||||
return resp;
|
||||
},
|
||||
(err) => {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
||||
throw new RateLimitedError(getRetryAfter(err.response));
|
||||
}
|
||||
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
|
||||
import { PostWithOptionalResponse } from "@services/Client";
|
||||
import {
|
||||
completeResetPasswordProcess,
|
||||
deleteResetPasswordToken,
|
||||
@@ -17,24 +17,22 @@ vi.mock("@services/Api", () => ({
|
||||
CompleteResetPasswordPath: "/reset-password/finish",
|
||||
InitiateResetPasswordPath: "/reset-password/start",
|
||||
ResetPasswordPath: "/reset-password",
|
||||
validateStatusTooManyRequests: vi.fn(),
|
||||
}));
|
||||
vi.mock("@services/Client", () => ({
|
||||
PostWithOptionalResponse: vi.fn(),
|
||||
PostWithOptionalResponseRateLimited: vi.fn(),
|
||||
}));
|
||||
|
||||
it("initiates reset password process", async () => {
|
||||
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
|
||||
(PostWithOptionalResponse as any).mockResolvedValue("response");
|
||||
const result = await initiateResetPasswordProcess("user");
|
||||
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/reset-password/start", { username: "user" });
|
||||
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/reset-password/start", { username: "user" });
|
||||
expect(result).toBe("response");
|
||||
});
|
||||
|
||||
it("completes reset password process", async () => {
|
||||
(PostWithOptionalResponseRateLimited as any).mockResolvedValue("response");
|
||||
(PostWithOptionalResponse as any).mockResolvedValue("response");
|
||||
const result = await completeResetPasswordProcess("token123");
|
||||
expect(PostWithOptionalResponseRateLimited).toHaveBeenCalledWith("/reset-password/finish", { token: "token123" });
|
||||
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/reset-password/finish", { token: "token123" });
|
||||
expect(result).toBe("response");
|
||||
});
|
||||
|
||||
@@ -53,7 +51,6 @@ it("deletes reset password token with ok", async () => {
|
||||
data: { token: "token123" },
|
||||
method: "DELETE",
|
||||
url: "/reset-password",
|
||||
validateStatus: expect.any(Function),
|
||||
});
|
||||
expect(result).toEqual({ ok: true, status: 200 });
|
||||
});
|
||||
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
InitiateResetPasswordPath,
|
||||
OKResponse,
|
||||
ResetPasswordPath,
|
||||
validateStatusTooManyRequests,
|
||||
} from "@services/Api";
|
||||
import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
|
||||
import { PostWithOptionalResponse } from "@services/Client";
|
||||
|
||||
export async function initiateResetPasswordProcess(username: string) {
|
||||
return PostWithOptionalResponseRateLimited(InitiateResetPasswordPath, { username });
|
||||
return PostWithOptionalResponse(InitiateResetPasswordPath, { username });
|
||||
}
|
||||
|
||||
export async function completeResetPasswordProcess(token: string) {
|
||||
return PostWithOptionalResponseRateLimited(CompleteResetPasswordPath, { token });
|
||||
return PostWithOptionalResponse(CompleteResetPasswordPath, { token });
|
||||
}
|
||||
|
||||
export async function resetPassword(newPassword: string) {
|
||||
@@ -31,7 +30,6 @@ export async function deleteResetPasswordToken(token: string) {
|
||||
data: { token: token },
|
||||
method: "DELETE",
|
||||
url: ResetPasswordPath,
|
||||
validateStatus: validateStatusTooManyRequests,
|
||||
});
|
||||
|
||||
return { ok: res.status === 200 && res.data.status === "OK", status: res.status };
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { hasServiceError, toData, validateStatusOneTimeCode } from "@services/Api";
|
||||
import { PostWithOptionalResponse } from "@services/Client";
|
||||
import {
|
||||
deleteUserSessionElevation,
|
||||
generateUserSessionElevation,
|
||||
@@ -19,6 +20,9 @@ vi.mock("@services/Api", () => ({
|
||||
UserSessionElevationPath: "/user/elevation",
|
||||
validateStatusOneTimeCode: vi.fn(),
|
||||
}));
|
||||
vi.mock("@services/Client", () => ({
|
||||
PostWithOptionalResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
it("gets user session elevation successfully", async () => {
|
||||
const mockRes = { data: { data: "elevation", status: "OK" }, status: 200 };
|
||||
@@ -45,23 +49,18 @@ it("gets user session elevation with error", async () => {
|
||||
});
|
||||
|
||||
it("generates user session elevation successfully", async () => {
|
||||
const mockRes = { data: { data: "generate", status: "OK" }, status: 200 };
|
||||
(axios as any).mockResolvedValue(mockRes);
|
||||
(hasServiceError as any).mockReturnValue({ errored: false });
|
||||
(toData as any).mockReturnValue("generate");
|
||||
const mockResponse = { delete_id: "abc" };
|
||||
(PostWithOptionalResponse as any).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await generateUserSessionElevation();
|
||||
expect(axios).toHaveBeenCalledWith({
|
||||
method: "POST",
|
||||
url: "/user/elevation",
|
||||
});
|
||||
expect(result).toBe("generate");
|
||||
expect(PostWithOptionalResponse).toHaveBeenCalledWith("/user/elevation");
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("generates user session elevation with error", async () => {
|
||||
const mockRes = { data: { message: "error", status: "KO" }, status: 400 };
|
||||
(axios as any).mockResolvedValue(mockRes);
|
||||
(hasServiceError as any).mockReturnValue({ errored: true, message: "error" });
|
||||
(PostWithOptionalResponse as any).mockRejectedValue(
|
||||
new Error("Failed POST to /user/elevation. Code: 400. Message: error"),
|
||||
);
|
||||
|
||||
await expect(generateUserSessionElevation()).rejects.toThrow(
|
||||
"Failed POST to /user/elevation. Code: 400. Message: error",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
toData,
|
||||
validateStatusOneTimeCode,
|
||||
} from "@services/Api";
|
||||
import { PostWithOptionalResponse } from "@services/Client";
|
||||
|
||||
export interface UserSessionElevation {
|
||||
require_second_factor: boolean;
|
||||
@@ -43,18 +44,7 @@ export async function getUserSessionElevation() {
|
||||
}
|
||||
|
||||
export async function generateUserSessionElevation() {
|
||||
const res = await axios<ServiceResponse<UserSessionElevationGenerateData>>({
|
||||
method: "POST",
|
||||
url: UserSessionElevationPath,
|
||||
});
|
||||
|
||||
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||
throw new Error(
|
||||
`Failed POST to ${UserSessionElevationPath}. Code: ${res.status}. Message: ${hasServiceError(res).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return toData<UserSessionElevationGenerateData>(res);
|
||||
return PostWithOptionalResponse<UserSessionElevationGenerateData>(UserSessionElevationPath);
|
||||
}
|
||||
|
||||
export async function verifyUserSessionElevation(otc: string) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { completeTOTPSignIn } from "@services/OneTimePassword";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod";
|
||||
|
||||
@@ -101,7 +102,7 @@ beforeEach(() => {
|
||||
mocks.configError = undefined;
|
||||
mocks.queryParams = {};
|
||||
mocks.userCode = null;
|
||||
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -268,7 +269,7 @@ describe("sign in", () => {
|
||||
});
|
||||
|
||||
it("reports success with no redirect when the response carries no data", async () => {
|
||||
completeSignInMock.mockResolvedValue({} as any);
|
||||
completeSignInMock.mockResolvedValue(undefined);
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -277,21 +278,6 @@ describe("sign in", () => {
|
||||
await waitFor(() => expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined));
|
||||
});
|
||||
|
||||
it("reports a wrong code when the response is empty", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
fireEvent.click(screen.getByTestId("dial-full"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(props.onSignInError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "The One-Time Password might be wrong" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
|
||||
});
|
||||
|
||||
it("reports a wrong code when the request throws", async () => {
|
||||
completeSignInMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
@@ -317,7 +303,7 @@ describe("sign in", () => {
|
||||
|
||||
it("reports rate limiting and recovers after the retry window", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -339,7 +325,7 @@ describe("sign in", () => {
|
||||
|
||||
it("clears the rate limit timer on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
|
||||
|
||||
const { unmount } = renderMethod();
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import { RedirectionURL } from "@constants/SearchParams";
|
||||
import { useFlow } from "@hooks/Flow";
|
||||
import { useUserCode } from "@hooks/OpenIDConnect";
|
||||
import { useQueryParam } from "@hooks/QueryParam";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
|
||||
import { completeTOTPSignIn } from "@services/OneTimePassword";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
import LoadingPage from "@views/LoadingPage/LoadingPage";
|
||||
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
|
||||
@@ -47,7 +50,7 @@ const OneTimePasswordMethod = function (props: Props) {
|
||||
const { onSignInError, onSignInSuccess } = props;
|
||||
const onSignInErrorCallbackRef = useRef(onSignInError);
|
||||
const onSignInSuccessCallbackRef = useRef(onSignInSuccess);
|
||||
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => dispatch({ type: State.Idle }));
|
||||
|
||||
useEffect(() => {
|
||||
onSignInErrorCallbackRef.current = onSignInError;
|
||||
@@ -57,15 +60,6 @@ const OneTimePasswordMethod = function (props: Props) {
|
||||
onSignInSuccessCallbackRef.current = onSignInSuccess;
|
||||
}, [onSignInSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRateLimitRef.current !== null) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
@@ -82,20 +76,13 @@ const OneTimePasswordMethod = function (props: Props) {
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
if (timeoutRateLimitRef.current) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
}
|
||||
|
||||
dispatch({ type: State.RateLimited });
|
||||
|
||||
onSignInErrorCallbackRef.current(new Error(translate("You have made too many requests")));
|
||||
|
||||
timeoutRateLimitRef.current = setTimeout(() => {
|
||||
dispatch({ type: State.Idle });
|
||||
timeoutRateLimitRef.current = null;
|
||||
}, retryAfter * 1000);
|
||||
triggerRateLimit(retryAfter);
|
||||
},
|
||||
[onSignInErrorCallbackRef, translate],
|
||||
[onSignInErrorCallbackRef, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,19 +107,16 @@ const OneTimePasswordMethod = function (props: Props) {
|
||||
dispatch({ type: State.InProgress });
|
||||
const res = await completeTOTPSignIn(passcodeStr, redirectionURL, flowID, flow, subflow, userCode);
|
||||
|
||||
if (!res) {
|
||||
dispatch({ type: State.Success });
|
||||
onSignInSuccessCallbackRef.current(res?.redirect);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
} else {
|
||||
console.error(err);
|
||||
onSignInErrorCallbackRef.current(new Error(translate("The One-Time Password might be wrong")));
|
||||
dispatch({ type: State.Failure });
|
||||
} else if (res.limited) {
|
||||
handleRateLimited(res.retryAfter);
|
||||
} else {
|
||||
dispatch({ type: State.Success });
|
||||
onSignInSuccessCallbackRef.current(res?.data?.redirect);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
onSignInErrorCallbackRef.current(new Error(translate("The One-Time Password might be wrong")));
|
||||
dispatch({ type: State.Failure });
|
||||
}
|
||||
setPasscode("");
|
||||
};
|
||||
@@ -183,6 +167,7 @@ const OneTimePasswordMethod = function (props: Props) {
|
||||
) : (
|
||||
<LoadingPage />
|
||||
)}
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
</div>
|
||||
</MethodContainer>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { postSecondFactor } from "@services/Password";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import PasswordForm from "@views/LoginPortal/SecondFactor/PasswordForm";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -111,7 +112,7 @@ describe("validation", () => {
|
||||
|
||||
describe("sign in", () => {
|
||||
it("posts the password and reports success", async () => {
|
||||
postSecondFactorMock.mockResolvedValue({ redirect: "https://example.com" } as any);
|
||||
postSecondFactorMock.mockResolvedValue({ redirect: "https://example.com" });
|
||||
|
||||
const { onAuthenticationSuccess } = renderForm();
|
||||
|
||||
@@ -187,6 +188,39 @@ describe("sign in", () => {
|
||||
expect(getSubmit()).not.toBeDisabled();
|
||||
expect(onAuthenticationSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies and disables the form until the retry period elapses when rate limited", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
try {
|
||||
postSecondFactorMock.mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
const { onAuthenticationSuccess } = renderForm();
|
||||
|
||||
fireEvent.change(getPassword(), { target: { value: "secret" } });
|
||||
fireEvent.click(getSubmit());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
|
||||
);
|
||||
expect(getPassword()).toHaveValue("");
|
||||
expect(getPassword()).toBeDisabled();
|
||||
expect(getSubmit()).toBeDisabled();
|
||||
expect(onAuthenticationSuccess).not.toHaveBeenCalled();
|
||||
expect(document.getElementById("rate-limit-countdown")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getPassword()).not.toBeDisabled());
|
||||
expect(getPassword()).toHaveFocus();
|
||||
expect(getSubmit()).not.toBeDisabled();
|
||||
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard handling", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import { Alert, AlertTitle } from "@components/UI/Alert";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import { Input } from "@components/UI/Input";
|
||||
@@ -17,8 +18,10 @@ import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { useFlow } from "@hooks/Flow";
|
||||
import { usePasswordVisibility } from "@hooks/PasswordVisibility";
|
||||
import { useQueryParam } from "@hooks/QueryParam";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import { IsCapsLockModified } from "@services/CapsLock";
|
||||
import { postSecondFactor } from "@services/Password";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
|
||||
export interface Props {
|
||||
onAuthenticationSuccess: (_redirectURL: string | undefined) => void;
|
||||
@@ -37,6 +40,7 @@ const PasswordForm = function (props: Props) {
|
||||
const [passwordCapsLock, setPasswordCapsLock] = useState(false);
|
||||
const [passwordCapsLockPartial, setPasswordCapsLockPartial] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const { limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
|
||||
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -51,7 +55,28 @@ const PasswordForm = function (props: Props) {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [focusPassword]);
|
||||
|
||||
const wasRateLimitedRef = useRef(rateLimited);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRateLimitedRef.current && !rateLimited) focusPassword();
|
||||
|
||||
wasRateLimitedRef.current = rateLimited;
|
||||
}, [focusPassword, rateLimited]);
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
triggerRateLimit(retryAfter);
|
||||
setPassword("");
|
||||
setLoading(false);
|
||||
|
||||
createErrorNotification(translate("You have made too many requests"));
|
||||
},
|
||||
[createErrorNotification, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
const handleSignIn = useCallback(async () => {
|
||||
if (rateLimited) return;
|
||||
|
||||
if (password === "") {
|
||||
setPasswordError(true);
|
||||
|
||||
@@ -62,15 +87,34 @@ const PasswordForm = function (props: Props) {
|
||||
|
||||
try {
|
||||
const res = await postSecondFactor(password, redirectionURL, flowID, flow, subflow);
|
||||
props.onAuthenticationSuccess(res ? res.redirect : undefined);
|
||||
|
||||
props.onAuthenticationSuccess(res?.redirect);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
createErrorNotification(translate("Incorrect password"));
|
||||
setPassword("");
|
||||
setLoading(false);
|
||||
focusPassword();
|
||||
}
|
||||
}, [createErrorNotification, focusPassword, password, props, redirectionURL, translate, flowID, flow, subflow]);
|
||||
}, [
|
||||
createErrorNotification,
|
||||
focusPassword,
|
||||
handleRateLimited,
|
||||
password,
|
||||
props,
|
||||
rateLimited,
|
||||
redirectionURL,
|
||||
translate,
|
||||
flowID,
|
||||
flow,
|
||||
subflow,
|
||||
]);
|
||||
|
||||
const handlePasswordKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -119,7 +163,7 @@ const PasswordForm = function (props: Props) {
|
||||
ref={passwordRef}
|
||||
id="password-textfield"
|
||||
required
|
||||
disabled={loading}
|
||||
disabled={loading || rateLimited}
|
||||
value={password}
|
||||
className="pr-10"
|
||||
error={passwordError}
|
||||
@@ -152,13 +196,14 @@ const PasswordForm = function (props: Props) {
|
||||
id="sign-in-button"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
disabled={loading || rateLimited}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
{translate("Authenticate", { ns: "settings" })}
|
||||
{loading ? <Spinner size={20} className="ml-2 h-5 w-5" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getPreferredDuoDevice,
|
||||
initiateDuoDeviceSelectionProcess,
|
||||
} from "@services/PushNotification";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod";
|
||||
|
||||
@@ -108,7 +109,7 @@ beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
getPreferredMock.mockResolvedValue({} as any);
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
completeSignInMock.mockResolvedValue(undefined);
|
||||
initiateSelectionMock.mockResolvedValue({} as any);
|
||||
completeSelectionMock.mockResolvedValue(undefined as any);
|
||||
});
|
||||
@@ -180,7 +181,7 @@ describe("preferred device lookup", () => {
|
||||
describe("sign in", () => {
|
||||
it("reports success after the success delay", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" } as any);
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -192,9 +193,9 @@ describe("sign in", () => {
|
||||
expect(props.onSignInSuccess).toHaveBeenCalledWith("https://example.com");
|
||||
});
|
||||
|
||||
it("reports success with no redirect when the response carries no data", async () => {
|
||||
it("reports success with no redirect when the response carries no redirect", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ data: undefined } as any);
|
||||
completeSignInMock.mockResolvedValue({} as any);
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -205,17 +206,18 @@ describe("sign in", () => {
|
||||
expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("fails when the sign in response is missing", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
it("reports success with no redirect when the sign in response is missing", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue(undefined);
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(props.onSignInError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "There was an issue completing sign in process" }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
|
||||
await vi.waitFor(() => expect(screen.getByTestId("success-icon")).toBeInTheDocument());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(props.onSignInSuccess).toHaveBeenCalledWith(undefined);
|
||||
expect(props.onSignInError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when the sign in request throws", async () => {
|
||||
@@ -243,10 +245,8 @@ describe("sign in", () => {
|
||||
|
||||
it("shows the device selection when the result requires auth", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
|
||||
renderMethod();
|
||||
@@ -259,9 +259,7 @@ describe("sign in", () => {
|
||||
});
|
||||
|
||||
it("moves to the enroll state when no compatible device exists", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
|
||||
} as any);
|
||||
completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
|
||||
|
||||
const { props } = renderMethod({ duoSelfEnrollment: true });
|
||||
|
||||
@@ -275,9 +273,7 @@ describe("sign in", () => {
|
||||
});
|
||||
|
||||
it("does not offer self enrollment when it is disabled", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
|
||||
} as any);
|
||||
completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
|
||||
|
||||
renderMethod({ duoSelfEnrollment: false });
|
||||
|
||||
@@ -288,7 +284,7 @@ describe("sign in", () => {
|
||||
});
|
||||
|
||||
it("does not offer self enrollment when no enroll url is returned", async () => {
|
||||
completeSignInMock.mockResolvedValue({ data: { result: "enroll" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ result: "enroll" } as any);
|
||||
|
||||
renderMethod({ duoSelfEnrollment: true });
|
||||
|
||||
@@ -299,7 +295,7 @@ describe("sign in", () => {
|
||||
});
|
||||
|
||||
it("fails when the Duo policy denies the device", async () => {
|
||||
completeSignInMock.mockResolvedValue({ data: { result: "deny" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ result: "deny" } as any);
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -313,7 +309,7 @@ describe("sign in", () => {
|
||||
|
||||
it("reports rate limiting and recovers after the retry window", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -332,7 +328,7 @@ describe("sign in", () => {
|
||||
});
|
||||
|
||||
it("retries the sign in from the failure state", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
completeSignInMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
renderMethod();
|
||||
|
||||
@@ -468,10 +464,8 @@ describe("device selection", () => {
|
||||
|
||||
it("returns to the previous state when selection is dismissed", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
|
||||
renderMethod();
|
||||
@@ -485,10 +479,8 @@ describe("device selection", () => {
|
||||
|
||||
it("persists a newly selected device and signs in again", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
|
||||
renderMethod({ registered: false });
|
||||
@@ -507,10 +499,8 @@ describe("device selection", () => {
|
||||
|
||||
it("notifies the parent when an unregistered user picks a device", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
|
||||
const { props } = renderMethod({ registered: false });
|
||||
@@ -524,10 +514,8 @@ describe("device selection", () => {
|
||||
|
||||
it("does not notify the parent when the user is already registered", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
|
||||
const { props } = renderMethod({ registered: true });
|
||||
@@ -542,10 +530,8 @@ describe("device selection", () => {
|
||||
|
||||
it("reports failures updating the preferred Duo device", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
completeSelectionMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
@@ -564,10 +550,8 @@ describe("device selection", () => {
|
||||
|
||||
it("swallows cancelled preferred device updates", async () => {
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
} as any);
|
||||
completeSelectionMock.mockRejectedValue({ __CANCEL__: true });
|
||||
|
||||
@@ -603,9 +587,7 @@ describe("device selection", () => {
|
||||
describe("self enrollment", () => {
|
||||
it("opens the enrollment url in a new tab", async () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
completeSignInMock.mockResolvedValue({
|
||||
data: { enroll_url: "https://enroll.example.com", result: "enroll" },
|
||||
} as any);
|
||||
completeSignInMock.mockResolvedValue({ enroll_url: "https://enroll.example.com", result: "enroll" } as any);
|
||||
|
||||
renderMethod({ duoSelfEnrollment: true });
|
||||
|
||||
@@ -622,11 +604,11 @@ describe("self enrollment", () => {
|
||||
describe("cleanup and edge cases", () => {
|
||||
it("clears both pending timers on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
|
||||
|
||||
const { unmount } = renderMethod();
|
||||
|
||||
await vi.waitFor(() => expect(completeSignInMock).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(document.getElementById("rate-limit-countdown")).toBeInTheDocument());
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
@@ -637,7 +619,7 @@ describe("cleanup and edge cases", () => {
|
||||
|
||||
it("clears the success timer on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ data: { redirect: "https://example.com" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" } as any);
|
||||
|
||||
const { props, unmount } = renderMethod();
|
||||
|
||||
@@ -654,7 +636,7 @@ describe("cleanup and edge cases", () => {
|
||||
|
||||
it("replaces an in flight rate limit timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 5 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(5));
|
||||
|
||||
const { props } = renderMethod();
|
||||
|
||||
@@ -705,7 +687,7 @@ describe("cleanup and edge cases", () => {
|
||||
});
|
||||
|
||||
it("returns to the pre-selection state after browsing devices from the method container", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
completeSignInMock.mockRejectedValue(new Error("boom"));
|
||||
initiateSelectionMock.mockResolvedValue({
|
||||
devices: [{ capabilities: ["push"], device: "device-a", display_name: "Phone" }],
|
||||
result: "auth",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import FailureIcon from "@components/FailureIcon";
|
||||
import PushNotificationIcon from "@components/PushNotificationIcon";
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import SuccessIcon from "@components/SuccessIcon";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import { RedirectionURL } from "@constants/SearchParams";
|
||||
@@ -16,6 +17,7 @@ import { useAbortSignal } from "@hooks/Abort";
|
||||
import { useFlow } from "@hooks/Flow";
|
||||
import { useUserCode } from "@hooks/OpenIDConnect";
|
||||
import { useQueryParam } from "@hooks/QueryParam";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import {
|
||||
DuoDevicePostRequest,
|
||||
completeDuoDeviceSelectionProcess,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
getPreferredDuoDevice,
|
||||
initiateDuoDeviceSelectionProcess,
|
||||
} from "@services/PushNotification";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
import DeviceSelectionContainer, {
|
||||
SelectableDevice,
|
||||
@@ -68,15 +71,11 @@ const PushNotificationMethod = function (props: Props) {
|
||||
const { onSignInError, onSignInSuccess } = props;
|
||||
const stateRef = useRef<null | State>(null);
|
||||
|
||||
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => setState(State.Failure));
|
||||
const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRateLimitRef.current !== null) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}
|
||||
if (timeoutSuccessRef.current !== null) {
|
||||
clearTimeout(timeoutSuccessRef.current);
|
||||
timeoutSuccessRef.current = null;
|
||||
@@ -120,20 +119,13 @@ const PushNotificationMethod = function (props: Props) {
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
if (timeoutRateLimitRef.current) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
}
|
||||
|
||||
setState(State.RateLimited);
|
||||
|
||||
onSignInError(new Error(translate("You have made too many requests")));
|
||||
|
||||
timeoutRateLimitRef.current = setTimeout(() => {
|
||||
setState(State.Failure);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}, retryAfter * 1000);
|
||||
triggerRateLimit(retryAfter);
|
||||
},
|
||||
[onSignInError, translate],
|
||||
[onSignInError, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
const handleFetchDuoDevices = useCallback(async () => {
|
||||
@@ -186,24 +178,17 @@ const PushNotificationMethod = function (props: Props) {
|
||||
getSignal(),
|
||||
);
|
||||
if (!res) {
|
||||
throw new Error(translate("There was an issue completing sign in process"));
|
||||
}
|
||||
if (res.limited) {
|
||||
handleRateLimited(res.retryAfter);
|
||||
return;
|
||||
}
|
||||
if (!res.data) {
|
||||
handleSuccess(undefined);
|
||||
return;
|
||||
}
|
||||
switch (res.data.result) {
|
||||
switch (res.result) {
|
||||
case "auth":
|
||||
setDevices(processDevices(res.data.devices));
|
||||
setDevices(processDevices(res.devices));
|
||||
setState(State.Selection);
|
||||
break;
|
||||
case "enroll":
|
||||
onSignInError(new Error(translate("No compatible device found")));
|
||||
if (res.data.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.data.enroll_url);
|
||||
if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
|
||||
setState(State.Enroll);
|
||||
break;
|
||||
case "deny":
|
||||
@@ -211,10 +196,14 @@ const PushNotificationMethod = function (props: Props) {
|
||||
setState(State.Failure);
|
||||
break;
|
||||
default:
|
||||
handleSuccess(res.data.redirect);
|
||||
handleSuccess(res.redirect);
|
||||
}
|
||||
} catch (err) {
|
||||
if (axios.isCancel(err)) return;
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
onSignInError(new Error(translate("There was an issue completing sign in process")));
|
||||
setState(State.Failure);
|
||||
@@ -336,6 +325,7 @@ const PushNotificationMethod = function (props: Props) {
|
||||
onRegisterClick={() => window.open(enrollUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<div className="flex w-full justify-center">{icon}</div>
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
<div className={state === State.Failure ? "" : "hidden"}>
|
||||
<Button variant={"ghost"} color={"secondary"} onClick={handleSignIn}>
|
||||
{translate("Retry")}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
@@ -71,7 +72,7 @@ it("shows error when username is empty", async () => {
|
||||
|
||||
it("navigates to index on successful reset", async () => {
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: false, retryAfter: 0 });
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined);
|
||||
|
||||
render(<ResetPasswordStep1 />);
|
||||
|
||||
@@ -89,7 +90,7 @@ it("navigates to index on successful reset", async () => {
|
||||
|
||||
it("shows rate limited error", async () => {
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
|
||||
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
render(<ResetPasswordStep1 />);
|
||||
|
||||
@@ -131,7 +132,7 @@ describe("rate limiting", () => {
|
||||
it("re-enables the reset button after the retry window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
|
||||
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
render(<ResetPasswordStep1 />);
|
||||
|
||||
@@ -154,7 +155,7 @@ describe("rate limiting", () => {
|
||||
it("replaces an in flight rate limit timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
|
||||
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
render(<ResetPasswordStep1 />);
|
||||
|
||||
@@ -196,7 +197,7 @@ describe("rate limiting", () => {
|
||||
it("clears the rate limit timer on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: true, retryAfter: 30 });
|
||||
vi.mocked(initiateResetPasswordProcess).mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
const { unmount } = render(<ResetPasswordStep1 />);
|
||||
|
||||
@@ -219,7 +220,7 @@ describe("rate limiting", () => {
|
||||
describe("keyboard handling", () => {
|
||||
it("submits on Enter", async () => {
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue({ limited: false, retryAfter: 0 });
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined);
|
||||
|
||||
render(<ResetPasswordStep1 />);
|
||||
|
||||
@@ -243,20 +244,6 @@ describe("keyboard handling", () => {
|
||||
});
|
||||
|
||||
describe("responses", () => {
|
||||
it("reports a missing response", async () => {
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
vi.mocked(initiateResetPasswordProcess).mockResolvedValue(undefined as any);
|
||||
|
||||
render(<ResetPasswordStep1 />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "john" } });
|
||||
fireEvent.click(screen.getByText("Reset"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateError).toHaveBeenCalledWith("There was an issue initiating the password reset process"),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the form while loading", async () => {
|
||||
const { initiateResetPasswordProcess } = await import("@services/ResetPassword");
|
||||
let resolve: (value: unknown) => void = () => {};
|
||||
@@ -270,7 +257,7 @@ describe("responses", () => {
|
||||
await waitFor(() => expect(screen.getByLabelText("Username")).toBeDisabled());
|
||||
expect(document.getElementById("cancel-button")).toBeDisabled();
|
||||
|
||||
resolve({ limited: false, retryAfter: 0 });
|
||||
resolve(undefined);
|
||||
await waitFor(() => expect(screen.getByLabelText("Username")).not.toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,22 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import ComponentWithTooltip from "@components/ComponentWithTooltip";
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import { Input } from "@components/UI/Input";
|
||||
import { Label } from "@components/UI/Label";
|
||||
import { Spinner } from "@components/UI/Spinner";
|
||||
import { IndexRoute } from "@constants/Routes";
|
||||
import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import MinimalLayout from "@layouts/MinimalLayout";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { initiateResetPasswordProcess } from "@services/ResetPassword";
|
||||
|
||||
const ResetPasswordStep1 = function () {
|
||||
@@ -22,38 +25,19 @@ const ResetPasswordStep1 = function () {
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const { limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
|
||||
|
||||
const { createErrorNotification, createInfoNotification } = useNotifications();
|
||||
const navigate = useNavigate();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRateLimitRef.current !== null) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
if (timeoutRateLimitRef.current) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
}
|
||||
|
||||
setRateLimited(true);
|
||||
triggerRateLimit(retryAfter);
|
||||
|
||||
createErrorNotification(translate("You have made too many requests"));
|
||||
|
||||
timeoutRateLimitRef.current = setTimeout(() => {
|
||||
setRateLimited(false);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}, retryAfter * 1000);
|
||||
},
|
||||
[createErrorNotification, translate],
|
||||
[createErrorNotification, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
const doInitiateResetPasswordProcess = async () => {
|
||||
@@ -68,17 +52,15 @@ const ResetPasswordStep1 = function () {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await initiateResetPasswordProcess(username);
|
||||
if (response?.limited === false) {
|
||||
createInfoNotification(translate("An email has been sent to your address to complete the process"));
|
||||
navigate(IndexRoute);
|
||||
} else if (response?.limited) {
|
||||
handleRateLimited(response.retryAfter);
|
||||
await initiateResetPasswordProcess(username);
|
||||
createInfoNotification(translate("An email has been sent to your address to complete the process"));
|
||||
navigate(IndexRoute);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
} else {
|
||||
createErrorNotification(translate("There was an issue initiating the password reset process"));
|
||||
}
|
||||
} catch {
|
||||
createErrorNotification(translate("There was an issue initiating the password reset process"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -141,6 +123,7 @@ const ResetPasswordStep1 = function () {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
</div>
|
||||
</div>
|
||||
</MinimalLayout>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router";
|
||||
|
||||
import { PasswordPolicyMode } from "@models/PasswordPolicy";
|
||||
import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword";
|
||||
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
|
||||
|
||||
@@ -102,7 +103,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
queryParam.token = "test-token";
|
||||
completeProcessMock.mockResolvedValue({} as any);
|
||||
completeProcessMock.mockResolvedValue(undefined);
|
||||
getPolicyMock.mockResolvedValue(standardPolicy);
|
||||
resetPasswordMock.mockResolvedValue(undefined as any);
|
||||
});
|
||||
@@ -192,7 +193,7 @@ describe("process initiation", () => {
|
||||
});
|
||||
|
||||
it("notifies and keeps the form disabled when rate limited", async () => {
|
||||
completeProcessMock.mockResolvedValue({ limited: true, retryAfter: 30 } as any);
|
||||
completeProcessMock.mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
renderStep();
|
||||
|
||||
@@ -203,6 +204,33 @@ describe("process initiation", () => {
|
||||
expect(getPolicyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a countdown and retries the process once the retry period elapses when rate limited", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
try {
|
||||
completeProcessMock.mockRejectedValueOnce(new RateLimitedError(30));
|
||||
|
||||
renderStep();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
|
||||
);
|
||||
expect(document.getElementById("rate-limit-countdown")).toBeInTheDocument();
|
||||
expect(completeProcessMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(completeProcessMock).toHaveBeenCalledTimes(2));
|
||||
expect(completeProcessMock).toHaveBeenLastCalledWith("test-token");
|
||||
await waitFor(() => expect(getReset()).not.toBeDisabled());
|
||||
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies and keeps the form disabled when the token expired", async () => {
|
||||
completeProcessMock.mockRejectedValue(new Error("expired"));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import PasswordMeter from "@components/PasswordMeter";
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import { Input } from "@components/UI/Input";
|
||||
import { Label } from "@components/UI/Label";
|
||||
@@ -17,9 +18,11 @@ import { IdentityToken } from "@constants/SearchParams";
|
||||
import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { usePasswordVisibility } from "@hooks/PasswordVisibility";
|
||||
import { useQueryParam } from "@hooks/QueryParam";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import MinimalLayout from "@layouts/MinimalLayout";
|
||||
import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy";
|
||||
import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword";
|
||||
|
||||
const uninitiated = Symbol("uninitiated");
|
||||
@@ -52,20 +55,27 @@ const ResetPasswordStep2 = function () {
|
||||
const processToken = useQueryParam(IdentityToken);
|
||||
|
||||
const resetInitiatedRef = useRef<null | string | typeof uninitiated | undefined>(uninitiated);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const attemptInitiatedRef = useRef(attempt);
|
||||
|
||||
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() => setAttempt((value) => value + 1));
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(_retryAfter: number) => {
|
||||
createErrorNotification(translate("You have made too many requests")); // TODO: Do we want to add the amount of seconds a user should retry in the message?
|
||||
(retryAfter: number) => {
|
||||
triggerRateLimit(retryAfter);
|
||||
|
||||
createErrorNotification(translate("You have made too many requests"));
|
||||
},
|
||||
[createErrorNotification, translate],
|
||||
[createErrorNotification, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetInitiatedRef.current === processToken) {
|
||||
if (resetInitiatedRef.current === processToken && attemptInitiatedRef.current === attempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetInitiatedRef.current = processToken;
|
||||
attemptInitiatedRef.current = attempt;
|
||||
|
||||
const submitReset = async () => {
|
||||
if (!processToken) {
|
||||
@@ -75,17 +85,17 @@ const ResetPasswordStep2 = function () {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await completeResetPasswordProcess(processToken);
|
||||
|
||||
if (response?.limited) {
|
||||
handleRateLimited(response.retryAfter);
|
||||
return;
|
||||
}
|
||||
await completeResetPasswordProcess(processToken);
|
||||
|
||||
const policy = await getPasswordPolicyConfiguration();
|
||||
setPPolicy(policy);
|
||||
setFormDisabled(false);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
createErrorNotification(
|
||||
translate("There was an issue completing the process the verification token might have expired"),
|
||||
@@ -95,7 +105,7 @@ const ResetPasswordStep2 = function () {
|
||||
};
|
||||
|
||||
submitReset().catch(console.error);
|
||||
}, [processToken, createErrorNotification, translate, handleRateLimited]);
|
||||
}, [attempt, processToken, createErrorNotification, translate, handleRateLimited]);
|
||||
|
||||
const doResetPassword = async () => {
|
||||
setPassword1("");
|
||||
@@ -217,6 +227,7 @@ const ResetPasswordStep2 = function () {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
</div>
|
||||
</div>
|
||||
</MinimalLayout>
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import RevokeResetPasswordTokenView from "@views/Revoke/RevokeResetPasswordTokenView";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number }) =>
|
||||
options?.count === undefined ? key : `${key}:${options.count}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
@@ -68,15 +72,15 @@ it("shows error when token is not provided", async () => {
|
||||
expect(mockCreateError).toHaveBeenCalledWith("The Token was not provided");
|
||||
});
|
||||
|
||||
it("shows rate limited error on 429 response", async () => {
|
||||
it("shows rate limited error when the request is rate limited", async () => {
|
||||
const { deleteResetPasswordToken } = await import("@services/ResetPassword");
|
||||
vi.mocked(deleteResetPasswordToken).mockResolvedValueOnce({ ok: false, status: 429 });
|
||||
vi.mocked(deleteResetPasswordToken).mockRejectedValueOnce(new RateLimitedError(30));
|
||||
|
||||
await act(async () => {
|
||||
render(<RevokeResetPasswordTokenView />);
|
||||
});
|
||||
|
||||
expect(mockCreateError).toHaveBeenCalledWith("You have made too many requests");
|
||||
expect(mockCreateError).toHaveBeenCalledWith("You have made too many requests, try again in {{count}} seconds:30");
|
||||
});
|
||||
|
||||
it("reports a successful revocation", async () => {
|
||||
@@ -102,6 +106,17 @@ it("reports a failed revocation", async () => {
|
||||
expect(mockCreateError).toHaveBeenCalledWith("Failed to revoke the Token");
|
||||
});
|
||||
|
||||
it("reports a failed revocation when the request throws", async () => {
|
||||
const { deleteResetPasswordToken } = await import("@services/ResetPassword");
|
||||
vi.mocked(deleteResetPasswordToken).mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
await act(async () => {
|
||||
render(<RevokeResetPasswordTokenView />);
|
||||
});
|
||||
|
||||
expect(mockCreateError).toHaveBeenCalledWith("Failed to revoke the Token");
|
||||
});
|
||||
|
||||
it("navigates back to the index route after the delay", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IndexRoute } from "@constants/Routes";
|
||||
import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { useToken } from "@hooks/Revoke";
|
||||
import { useRouterNavigate } from "@hooks/RouterNavigate";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import { deleteResetPasswordToken } from "@services/ResetPassword";
|
||||
import LoadingPage from "@views/LoadingPage/LoadingPage";
|
||||
|
||||
@@ -29,14 +30,25 @@ const RevokeResetPasswordTokenView = function () {
|
||||
const handleRevoke = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
const { ok, status } = await deleteResetPasswordToken(token);
|
||||
try {
|
||||
const { ok } = await deleteResetPasswordToken(token);
|
||||
|
||||
if (ok) {
|
||||
createSuccessNotification(translate("Successfully revoked the Token"));
|
||||
} else if (status === 429) {
|
||||
createErrorNotification(translate("You have made too many requests"));
|
||||
} else {
|
||||
createErrorNotification(translate("Failed to revoke the Token"));
|
||||
if (ok) {
|
||||
createSuccessNotification(translate("Successfully revoked the Token"));
|
||||
} else {
|
||||
createErrorNotification(translate("Failed to revoke the Token"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
createErrorNotification(
|
||||
translate("You have made too many requests, try again in {{count}} seconds", {
|
||||
count: err.retryAfter,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.error(err);
|
||||
createErrorNotification(translate("Failed to revoke the Token"));
|
||||
}
|
||||
}
|
||||
|
||||
handleRedirect();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import {
|
||||
deleteUserSessionElevation,
|
||||
generateUserSessionElevation,
|
||||
@@ -16,7 +17,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number }) =>
|
||||
options?.count === undefined ? key : `${key}:${options.count}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@contexts/NotificationsContext", () => ({
|
||||
@@ -86,8 +90,8 @@ function clickCancel() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
generateMock.mockResolvedValue({ delete_id: "delete-id" } as any);
|
||||
verifyMock.mockResolvedValue(true as any);
|
||||
generateMock.mockResolvedValue({ delete_id: "delete-id" });
|
||||
verifyMock.mockResolvedValue(true);
|
||||
deleteMock.mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
@@ -162,6 +166,19 @@ describe("code generation failures", () => {
|
||||
expect(handleClosed).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("notifies and closes when generation is rate limited", async () => {
|
||||
generateMock.mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
const { handleClosed } = renderDialog();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith(
|
||||
"You have made too many requests, try again in {{count}} seconds:30",
|
||||
),
|
||||
);
|
||||
expect(handleClosed).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("notifies and closes when generation returns nothing", async () => {
|
||||
generateMock.mockResolvedValue(null as any);
|
||||
|
||||
@@ -239,7 +256,7 @@ describe("verification", () => {
|
||||
});
|
||||
|
||||
it("notifies, clears and refocuses the field on a wrong code", async () => {
|
||||
verifyMock.mockResolvedValue(false as any);
|
||||
verifyMock.mockResolvedValue(false);
|
||||
|
||||
renderDialog();
|
||||
await screen.findByText("Verify");
|
||||
@@ -257,6 +274,44 @@ describe("verification", () => {
|
||||
expect(getCode()).toHaveFocus();
|
||||
});
|
||||
|
||||
it("notifies and disables the controls until the retry period elapses when rate limited", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
try {
|
||||
verifyMock.mockRejectedValue(new RateLimitedError(30));
|
||||
|
||||
const { handleClosed } = renderDialog();
|
||||
await screen.findByText("Verify");
|
||||
|
||||
fireEvent.change(getCode(), { target: { value: "123456" } });
|
||||
clickVerify();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
|
||||
);
|
||||
expect(getCode()).toHaveValue("");
|
||||
expect(getCode()).toBeDisabled();
|
||||
expect(document.getElementById("dialog-verify")).toBeDisabled();
|
||||
expect(handleClosed).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
document.getElementById("dialog-cancel")!.focus();
|
||||
});
|
||||
|
||||
expect(getCode()).not.toHaveFocus();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getCode()).not.toBeDisabled());
|
||||
expect(getCode()).toHaveFocus();
|
||||
expect(document.getElementById("dialog-verify")).not.toBeDisabled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies, clears and re-enables the field when verification fails", async () => {
|
||||
verifyMock.mockRejectedValue(new Error("Network Error"));
|
||||
|
||||
@@ -277,7 +332,7 @@ describe("verification", () => {
|
||||
});
|
||||
|
||||
it("clears the error once the user retypes", async () => {
|
||||
verifyMock.mockResolvedValue(false as any);
|
||||
verifyMock.mockResolvedValue(false);
|
||||
|
||||
renderDialog();
|
||||
await screen.findByText("Verify");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, us
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import OneTimeCodeTextField from "@components/OneTimeCodeTextField";
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import SuccessIcon from "@components/SuccessIcon";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import {
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
} from "@components/UI/Dialog";
|
||||
import { Spinner } from "@components/UI/Spinner";
|
||||
import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import {
|
||||
UserSessionElevation,
|
||||
deleteUserSessionElevation,
|
||||
@@ -46,6 +49,7 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
const [codeDelete, setCodeDelete] = useState<string>();
|
||||
const [codeError, setCodeError] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const { clear: clearRateLimit, limited: rateLimited, secondsRemaining, trigger: triggerRateLimit } = useRateLimit();
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
const timeoutSuccessRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const attemptRef = useRef(0);
|
||||
@@ -61,6 +65,14 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
|
||||
const open = useMemo(() => ready && !closing && opening && !!elevation, [ready, closing, opening, elevation]);
|
||||
|
||||
const wasRateLimitedRef = useRef(rateLimited);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRateLimitedRef.current && !rateLimited && open) codeRef.current?.focus();
|
||||
|
||||
wasRateLimitedRef.current = rateLimited;
|
||||
}, [open, rateLimited]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(ok: boolean) => {
|
||||
setCodeInput("");
|
||||
@@ -70,9 +82,10 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
setSuccess(false);
|
||||
setClosing(false);
|
||||
setReady(false);
|
||||
clearRateLimit();
|
||||
handleClosed(ok);
|
||||
},
|
||||
[handleClosed],
|
||||
[clearRateLimit, handleClosed],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
@@ -114,25 +127,36 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
codeRef.current?.focus();
|
||||
}, [createErrorNotification, translate]);
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
setCodeInput("");
|
||||
setLoading(false);
|
||||
triggerRateLimit(retryAfter);
|
||||
|
||||
createErrorNotification(translate("You have made too many requests", { ns: "portal" }));
|
||||
},
|
||||
[createErrorNotification, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (codeInput === "") return;
|
||||
if (codeInput === "" || rateLimited) return;
|
||||
|
||||
setLoading(true);
|
||||
const attempt = ++attemptRef.current;
|
||||
const success = await verifyUserSessionElevation(codeInput).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
return false;
|
||||
});
|
||||
const result = await verifyUserSessionElevation(codeInput).catch((err: unknown) => err);
|
||||
|
||||
if (attempt !== attemptRef.current) return;
|
||||
|
||||
if (success) {
|
||||
if (result instanceof RateLimitedError) {
|
||||
handleRateLimited(result.retryAfter);
|
||||
} else if (result === true) {
|
||||
handleSuccess();
|
||||
} else {
|
||||
if (result instanceof Error) console.error(result);
|
||||
|
||||
handleFailure();
|
||||
}
|
||||
}, [codeInput, handleFailure, handleSuccess]);
|
||||
}, [codeInput, handleFailure, handleRateLimited, handleSuccess, rateLimited]);
|
||||
|
||||
const handleSubmitKeyDown = useCallback(
|
||||
async (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -163,8 +187,18 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
setReady(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
createErrorNotification(translate("Failed to generate the One-Time Code. Please try again later."));
|
||||
if (error instanceof RateLimitedError) {
|
||||
createErrorNotification(
|
||||
translate("You have made too many requests, try again in {{count}} seconds", {
|
||||
count: error.retryAfter,
|
||||
ns: "portal",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.error(error);
|
||||
createErrorNotification(translate("Failed to generate the One-Time Code. Please try again later."));
|
||||
}
|
||||
|
||||
handleClose(false);
|
||||
});
|
||||
}, [closing, opening, elevation, ready, translate, handleClose, handleOpened, createErrorNotification]);
|
||||
@@ -206,11 +240,12 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
value={codeInput}
|
||||
onChange={handleChange}
|
||||
error={codeError}
|
||||
disabled={loading}
|
||||
disabled={loading || rateLimited}
|
||||
inputRef={codeRef}
|
||||
onKeyDown={handleSubmitKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
</div>
|
||||
)}
|
||||
{success ? null : (
|
||||
@@ -228,7 +263,7 @@ const IdentityVerificationDialog = function (props: Props) {
|
||||
id={"dialog-verify"}
|
||||
variant={"ghost"}
|
||||
color={"primary"}
|
||||
disabled={loading}
|
||||
disabled={loading || rateLimited}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading ? <Spinner size={20} /> : null}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
completePushNotificationSignIn,
|
||||
initiateDuoDeviceSelectionProcess,
|
||||
} from "@services/PushNotification";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import SecondFactorMethodMobilePush from "@views/Settings/Common/SecondFactorMethodMobilePush";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -53,13 +54,11 @@ const initiateSelectionMock = vi.mocked(initiateDuoDeviceSelectionProcess);
|
||||
const completeSelectionMock = vi.mocked(completeDuoDeviceSelectionProcess);
|
||||
|
||||
const authResponse = {
|
||||
data: {
|
||||
devices: [
|
||||
{ capabilities: ["push", "sms"], device: "device-a", display_name: "Phone" },
|
||||
{ capabilities: ["push"], device: "device-b", display_name: "Tablet" },
|
||||
],
|
||||
result: "auth",
|
||||
},
|
||||
devices: [
|
||||
{ capabilities: ["push", "sms"], device: "device-a", display_name: "Phone" },
|
||||
{ capabilities: ["push"], device: "device-b", display_name: "Tablet" },
|
||||
],
|
||||
result: "auth",
|
||||
};
|
||||
|
||||
function renderPush(onSecondFactorSuccess = vi.fn()) {
|
||||
@@ -72,7 +71,7 @@ function renderPush(onSecondFactorSuccess = vi.fn()) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
completeSignInMock.mockRejectedValue(new Error("boom"));
|
||||
initiateSelectionMock.mockResolvedValue({} as any);
|
||||
completeSelectionMock.mockResolvedValue(undefined as any);
|
||||
});
|
||||
@@ -103,7 +102,7 @@ describe("rendering", () => {
|
||||
|
||||
describe("push response handling", () => {
|
||||
it("reports success and hides the device link", async () => {
|
||||
completeSignInMock.mockResolvedValue({ data: { result: "allow" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ result: "allow" } as any);
|
||||
|
||||
const { onSecondFactorSuccess } = renderPush();
|
||||
|
||||
@@ -128,7 +127,7 @@ describe("push response handling", () => {
|
||||
});
|
||||
|
||||
it("fails when the response requires enrollment", async () => {
|
||||
completeSignInMock.mockResolvedValue({ data: { result: "enroll" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ result: "enroll" } as any);
|
||||
|
||||
renderPush();
|
||||
|
||||
@@ -137,7 +136,7 @@ describe("push response handling", () => {
|
||||
});
|
||||
|
||||
it("fails when the Duo policy denies the request", async () => {
|
||||
completeSignInMock.mockResolvedValue({ data: { result: "deny" } } as any);
|
||||
completeSignInMock.mockResolvedValue({ result: "deny" } as any);
|
||||
|
||||
renderPush();
|
||||
|
||||
@@ -147,26 +146,27 @@ describe("push response handling", () => {
|
||||
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fails when the response is missing", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
it("reports success when the response carries no data", async () => {
|
||||
completeSignInMock.mockResolvedValue(undefined);
|
||||
|
||||
renderPush();
|
||||
const { onSecondFactorSuccess } = renderPush();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"),
|
||||
);
|
||||
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
|
||||
await waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
|
||||
expect(mocks.createErrorNotification).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("failure-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Select a Device" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fails when the response carries no data", async () => {
|
||||
it("fails when the response carries an empty payload", async () => {
|
||||
completeSignInMock.mockResolvedValue({} as any);
|
||||
|
||||
renderPush();
|
||||
const { onSecondFactorSuccess } = renderPush();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("There was an issue completing sign in process"),
|
||||
);
|
||||
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
|
||||
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when the push request throws", async () => {
|
||||
@@ -182,7 +182,7 @@ describe("push response handling", () => {
|
||||
|
||||
it("reports rate limiting and recovers after the retry window", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 3 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(3));
|
||||
|
||||
renderPush();
|
||||
|
||||
@@ -198,20 +198,8 @@ describe("push response handling", () => {
|
||||
expect(screen.getByTestId("failure-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a limited response carrying data as rate limited", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ data: { result: "allow" }, limited: true, retryAfter: 1 } as any);
|
||||
|
||||
const { onSecondFactorSuccess } = renderPush();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
|
||||
);
|
||||
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries the push from the failure state", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
completeSignInMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
renderPush();
|
||||
|
||||
@@ -314,6 +302,40 @@ describe("device selection", () => {
|
||||
await waitFor(() => expect(completeSignInMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("clears the rate limit when a device is selected during the retry window", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockRejectedValueOnce(new RateLimitedError(3));
|
||||
initiateSelectionMock.mockResolvedValue(authResponse as any);
|
||||
|
||||
const { onSecondFactorSuccess } = renderPush();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
|
||||
);
|
||||
|
||||
completeSignInMock.mockResolvedValue({ result: "allow" } as any);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Select a Device" }));
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByTestId("device-selection")).toBeInTheDocument());
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("device-select"));
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
|
||||
expect(document.getElementById("rate-limit-countdown")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("failure-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Select a Device" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logs failures updating the preferred Duo device", async () => {
|
||||
completeSignInMock.mockResolvedValue(authResponse as any);
|
||||
completeSelectionMock.mockRejectedValue(new Error("boom"));
|
||||
@@ -335,7 +357,7 @@ describe("device selection", () => {
|
||||
describe("cleanup", () => {
|
||||
it("clears the rate limit timer on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 3 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(3));
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
@@ -345,7 +367,6 @@ describe("cleanup", () => {
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("You have made too many requests"),
|
||||
);
|
||||
|
||||
// Identify the component's own rate limit timer rather than any timer React happens to clear.
|
||||
const index = setTimeoutSpy.mock.calls.findIndex(([, timeout]) => timeout === 3000);
|
||||
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Fragment, ReactNode, useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { Fragment, ReactNode, useCallback, useEffect, useReducer } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import FailureIcon from "@components/FailureIcon";
|
||||
import PushNotificationIcon from "@components/PushNotificationIcon";
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import { Button } from "@components/UI/Button";
|
||||
import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import {
|
||||
DuoDevicePostRequest,
|
||||
DuoSignInResponse,
|
||||
completeDuoDeviceSelectionProcess,
|
||||
completePushNotificationSignIn,
|
||||
initiateDuoDeviceSelectionProcess,
|
||||
} from "@services/PushNotification";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import DeviceSelectionContainer, {
|
||||
SelectableDevice,
|
||||
SelectedDevice,
|
||||
@@ -30,6 +34,7 @@ type Action =
|
||||
| { type: "pushFailure" }
|
||||
| { type: "pushSuccess" }
|
||||
| { type: "rateLimited" }
|
||||
| { type: "rateLimitExpired" }
|
||||
| { type: "selectDevices"; devices: SelectableDevice[] }
|
||||
| { type: "setDevices"; devices: SelectableDevice[] }
|
||||
| { type: "setStatus"; status: ComponentState["status"] }
|
||||
@@ -56,6 +61,8 @@ function reducer(state: ComponentState, action: Action): ComponentState {
|
||||
return { ...state, devices: action.devices, status: "selecting" };
|
||||
case "rateLimited":
|
||||
return { ...state, status: "rate_limited" };
|
||||
case "rateLimitExpired":
|
||||
return state.status === "rate_limited" ? { ...state, status: "failure" } : state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -72,73 +79,55 @@ const SecondFactorMethodMobilePush = function (props: Props) {
|
||||
|
||||
const { createErrorNotification } = useNotifications();
|
||||
|
||||
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRateLimitRef.current !== null) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const {
|
||||
clear: clearRateLimit,
|
||||
secondsRemaining,
|
||||
trigger: triggerRateLimit,
|
||||
} = useRateLimit(() => dispatch({ type: "rateLimitExpired" }));
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
if (timeoutRateLimitRef.current) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
}
|
||||
|
||||
dispatch({ type: "rateLimited" });
|
||||
|
||||
createErrorNotification(translate("You have made too many requests"));
|
||||
|
||||
timeoutRateLimitRef.current = setTimeout(() => {
|
||||
dispatch({ type: "pushFailure" });
|
||||
timeoutRateLimitRef.current = null;
|
||||
}, retryAfter * 1000);
|
||||
triggerRateLimit(retryAfter);
|
||||
},
|
||||
[createErrorNotification, translate],
|
||||
[createErrorNotification, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
const handlePushResponse = useCallback(
|
||||
(res: any) => {
|
||||
if (res) {
|
||||
if (res.data && !res.limited) {
|
||||
switch (res.data.result) {
|
||||
case "auth": {
|
||||
const selectableDevices = [] as SelectableDevice[];
|
||||
for (const d of res.data.devices) {
|
||||
selectableDevices.push({ id: d.device, methods: d.capabilities, name: d.display_name });
|
||||
}
|
||||
dispatch({ devices: selectableDevices, type: "selectDevices" });
|
||||
break;
|
||||
}
|
||||
case "enroll":
|
||||
createErrorNotification(translate("No compatible device found"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
break;
|
||||
case "deny":
|
||||
createErrorNotification(translate("Device selection was denied by Duo policy"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
break;
|
||||
default:
|
||||
dispatch({ type: "pushSuccess" });
|
||||
props.onSecondFactorSuccess();
|
||||
break;
|
||||
}
|
||||
} else if (res.limited) {
|
||||
handleRateLimited(res.retryAfter);
|
||||
} else {
|
||||
createErrorNotification(translate("There was an issue completing sign in process"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
}
|
||||
} else {
|
||||
(res: DuoSignInResponse | undefined) => {
|
||||
if (res !== undefined && typeof res.result !== "string") {
|
||||
createErrorNotification(translate("There was an issue completing sign in process"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (res?.result) {
|
||||
case "auth": {
|
||||
const selectableDevices = [] as SelectableDevice[];
|
||||
for (const d of res.devices) {
|
||||
selectableDevices.push({ id: d.device, methods: d.capabilities, name: d.display_name });
|
||||
}
|
||||
dispatch({ devices: selectableDevices, type: "selectDevices" });
|
||||
break;
|
||||
}
|
||||
case "enroll":
|
||||
createErrorNotification(translate("No compatible device found"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
break;
|
||||
case "deny":
|
||||
createErrorNotification(translate("Device selection was denied by Duo policy"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
break;
|
||||
default:
|
||||
dispatch({ type: "pushSuccess" });
|
||||
props.onSecondFactorSuccess();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[createErrorNotification, handleRateLimited, props, translate],
|
||||
[createErrorNotification, props, translate],
|
||||
);
|
||||
|
||||
const handleSelectDevice = useCallback(async () => {
|
||||
@@ -177,23 +166,29 @@ const SecondFactorMethodMobilePush = function (props: Props) {
|
||||
const res = await completePushNotificationSignIn();
|
||||
handlePushResponse(res);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
createErrorNotification(translate("There was an issue completing sign in process"));
|
||||
dispatch({ type: "pushFailure" });
|
||||
}
|
||||
}, [handlePushResponse, createErrorNotification, translate]);
|
||||
}, [handlePushResponse, handleRateLimited, createErrorNotification, translate]);
|
||||
|
||||
const updateDuoDevice = useCallback(
|
||||
async function (device: DuoDevicePostRequest) {
|
||||
try {
|
||||
await completeDuoDeviceSelectionProcess(device);
|
||||
clearRateLimit();
|
||||
dispatch({ type: "startPush" });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
console.error(new Error(translate("There was an issue updating preferred Duo device")));
|
||||
}
|
||||
},
|
||||
[translate],
|
||||
[clearRateLimit, translate],
|
||||
);
|
||||
|
||||
const handleDuoDeviceSelected = useCallback(
|
||||
@@ -232,6 +227,7 @@ const SecondFactorMethodMobilePush = function (props: Props) {
|
||||
<Fragment>
|
||||
<div className="h-[120px]">
|
||||
<div className="inline-block size-17.5">{icon}</div>
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
<div className={state.status === "failure" ? "" : "hidden"}>
|
||||
<Button variant={"ghost"} color={"secondary"} onClick={() => dispatch({ type: "startPush" })}>
|
||||
{translate("Retry")}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { completeTOTPSignIn } from "@services/OneTimePassword";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import SecondFactorMethodOneTimePassword from "@views/Settings/Common/SecondFactorMethodOneTimePassword";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -66,7 +67,7 @@ beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mocks.config = { digits: 6, period: 30 };
|
||||
mocks.configError = null;
|
||||
completeSignInMock.mockResolvedValue({} as any);
|
||||
completeSignInMock.mockResolvedValue({ redirect: "https://example.com" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -133,34 +134,33 @@ describe("sign in", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-passcode", ""));
|
||||
});
|
||||
|
||||
it("notifies and fails when the code is rejected", async () => {
|
||||
completeSignInMock.mockResolvedValue(null as any);
|
||||
it("succeeds when the response carries no data", async () => {
|
||||
completeSignInMock.mockResolvedValue(undefined);
|
||||
|
||||
const { onSecondFactorSuccess } = renderMethod();
|
||||
|
||||
fireEvent.click(screen.getByTestId("dial-full"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.createErrorNotification).toHaveBeenCalledWith("The One-Time Password might be wrong"),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
|
||||
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(onSecondFactorSuccess).toHaveBeenCalled());
|
||||
expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "2");
|
||||
});
|
||||
|
||||
it("fails when the request throws", async () => {
|
||||
completeSignInMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
renderMethod();
|
||||
const { onSecondFactorSuccess } = renderMethod();
|
||||
|
||||
fireEvent.click(screen.getByTestId("dial-full"));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "3"));
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
expect(mocks.createErrorNotification).not.toHaveBeenCalled();
|
||||
expect(onSecondFactorSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports rate limiting and recovers after the retry window", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(4));
|
||||
|
||||
renderMethod();
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("sign in", () => {
|
||||
|
||||
it("ignores new submissions while rate limited", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(4));
|
||||
|
||||
renderMethod();
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("sign in", () => {
|
||||
|
||||
expect(completeSignInMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({});
|
||||
resolve(undefined);
|
||||
await waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "2"));
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("sign in", () => {
|
||||
|
||||
it("clears the rate limit timer on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeSignInMock.mockResolvedValue({ limited: true, retryAfter: 4 } as any);
|
||||
completeSignInMock.mockRejectedValue(new RateLimitedError(4));
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
@@ -231,7 +231,6 @@ describe("sign in", () => {
|
||||
fireEvent.click(screen.getByTestId("dial-full"));
|
||||
await vi.waitFor(() => expect(screen.getByTestId("otp-dial")).toHaveAttribute("data-state", "4"));
|
||||
|
||||
// Identify the component's own rate limit timer rather than any timer React happens to clear.
|
||||
const index = setTimeoutSpy.mock.calls.findIndex(([, timeout]) => timeout === 4000);
|
||||
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Fragment, useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { Fragment, useCallback, useEffect, useReducer } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import RateLimitCountdown from "@components/RateLimitCountdown";
|
||||
import { useNotifications } from "@contexts/NotificationsContext";
|
||||
import { useRateLimit } from "@hooks/RateLimit";
|
||||
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
|
||||
import { completeTOTPSignIn } from "@services/OneTimePassword";
|
||||
import { RateLimitedError } from "@services/RateLimit";
|
||||
import LoadingPage from "@views/LoadingPage/LoadingPage";
|
||||
import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial";
|
||||
|
||||
@@ -49,16 +52,9 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
|
||||
|
||||
const [config, fetchConfig, , fetchConfigError] = useUserInfoTOTPConfiguration();
|
||||
|
||||
const timeoutRateLimitRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRateLimitRef.current !== null) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
timeoutRateLimitRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const { secondsRemaining, trigger: triggerRateLimit } = useRateLimit(() =>
|
||||
dispatch({ status: State.Idle, type: "setStatus" }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchConfigError) {
|
||||
@@ -73,20 +69,13 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
|
||||
|
||||
const handleRateLimited = useCallback(
|
||||
(retryAfter: number) => {
|
||||
if (timeoutRateLimitRef.current) {
|
||||
clearTimeout(timeoutRateLimitRef.current);
|
||||
}
|
||||
|
||||
dispatch({ status: State.RateLimited, type: "setStatus" });
|
||||
|
||||
createErrorNotification(translate("You have made too many requests", { ns: "portal" }));
|
||||
|
||||
timeoutRateLimitRef.current = setTimeout(() => {
|
||||
dispatch({ status: State.Idle, type: "setStatus" });
|
||||
timeoutRateLimitRef.current = null;
|
||||
}, retryAfter * 1000);
|
||||
triggerRateLimit(retryAfter);
|
||||
},
|
||||
[createErrorNotification, translate],
|
||||
[createErrorNotification, translate, triggerRateLimit],
|
||||
);
|
||||
|
||||
const handleSignIn = useCallback(
|
||||
@@ -102,27 +91,22 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
|
||||
try {
|
||||
dispatch({ status: State.InProgress, type: "setStatus" });
|
||||
|
||||
const res = await completeTOTPSignIn(passcodeStr);
|
||||
await completeTOTPSignIn(passcodeStr);
|
||||
|
||||
if (res) {
|
||||
if (res.limited) {
|
||||
handleRateLimited(res.retryAfter);
|
||||
} else {
|
||||
dispatch({ status: State.Success, type: "setStatus" });
|
||||
onSecondFactorSuccess();
|
||||
}
|
||||
dispatch({ status: State.Success, type: "setStatus" });
|
||||
onSecondFactorSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimitedError) {
|
||||
handleRateLimited(err.retryAfter);
|
||||
} else {
|
||||
createErrorNotification(translate("The One-Time Password might be wrong", { ns: "portal" }));
|
||||
console.error(err);
|
||||
dispatch({ status: State.Failure, type: "setStatus" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
dispatch({ status: State.Failure, type: "setStatus" });
|
||||
}
|
||||
|
||||
dispatch({ passcode: "", type: "setPasscode" });
|
||||
},
|
||||
[config, handleRateLimited, createErrorNotification, translate, onSecondFactorSuccess],
|
||||
[config, handleRateLimited, onSecondFactorSuccess],
|
||||
);
|
||||
|
||||
const handlePasscodeChange = useCallback(
|
||||
@@ -153,6 +137,7 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
|
||||
) : (
|
||||
<LoadingPage />
|
||||
)}
|
||||
<RateLimitCountdown secondsRemaining={secondsRemaining} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user