summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames Elliott <james-d-elliott@users.noreply.github.com>2025-03-01 14:28:19 +1100
committerGitHub <noreply@github.com>2025-03-01 03:28:19 +0000
commitef5051b0c3b55349e5be4026131ef22844a729a9 (patch)
tree7d969bac1ed2ec997266409fbd9617293e1f54ad
parent178c7475ff7b6031928c3b63d2cbb8f5b3ad8961 (diff)
feat(middlewares): tokenized bucket rate limit (#8321)
This adds rate limits to the TOTP second factor endpoint, the Duo second factor endpoint, Session Elevation endpoint, and the Reset Password endpoint. This protection exists as several configurable tokenized buckets anchored to the users remote IP address. In the event the rate limit is exceeded by the user the middleware will respond with a 429 status, a Retry-After header, and JSON body indicating it's rate limited, which the UI will gracefully handle. This has several benefits that compliment the 1FA regulation, specifically in simple architectures it limits the number of SMTP sends a unique client can make, as well as the number of requests a particular client can make in general on specific endpoints where too many requests may indicate either a fault or some form of abuse. Closes #7353, Closes #1947 Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
-rw-r--r--api/openapi.yml78
-rw-r--r--docs/content/configuration/miscellaneous/server-endpoint-rate-limits.md156
-rw-r--r--docs/content/configuration/miscellaneous/server.md9
-rw-r--r--docs/content/configuration/security/regulation.md5
-rw-r--r--docs/content/overview/security/measures.md21
-rw-r--r--docs/data/configkeys.json30
-rw-r--r--docs/static/schemas/v4.39/json-schema/configuration.json65
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/configuration/defaults.go10
-rw-r--r--internal/configuration/schema/keys.go24
-rw-r--r--internal/configuration/schema/server.go63
-rw-r--r--internal/configuration/validator/configuration.go4
-rw-r--r--internal/configuration/validator/const.go3
-rw-r--r--internal/configuration/validator/server.go56
-rw-r--r--internal/configuration/validator/server_test.go168
-rw-r--r--internal/middlewares/bridge.go16
-rw-r--r--internal/middlewares/const.go1
-rw-r--r--internal/middlewares/rate_limiting.go191
-rw-r--r--internal/server/handlers.go37
-rw-r--r--internal/server/locales/en/portal.json1
-rw-r--r--internal/suites/ActiveDirectory/configuration.yml10
-rw-r--r--internal/suites/Caddy/configuration.yml9
-rw-r--r--internal/suites/Caddy/users.yml54
-rw-r--r--internal/suites/DuoPush/configuration.yml8
-rw-r--r--internal/suites/Envoy/configuration.yml9
-rw-r--r--internal/suites/LDAP/configuration.yml10
-rw-r--r--internal/suites/PathPrefix/configuration.yml10
-rw-r--r--internal/suites/Standalone/configuration.yml22
-rw-r--r--internal/suites/Traefik/configuration.yml9
-rw-r--r--internal/suites/TwoFactor/configuration.yml18
-rw-r--r--web/src/components/ComponentWithTooltip.tsx28
-rw-r--r--web/src/components/TypographyWithTooltip.tsx2
-rw-r--r--web/src/services/Api.ts73
-rw-r--r--web/src/services/Client.ts28
-rw-r--r--web/src/services/OneTimePassword.ts8
-rw-r--r--web/src/services/PushNotification.ts4
-rw-r--r--web/src/services/ResetPassword.ts10
-rw-r--r--web/src/services/UserInfoTOTPConfiguration.ts2
-rw-r--r--web/src/views/LoginPortal/SecondFactor/OTPDial.tsx15
-rw-r--r--web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx56
-rw-r--r--web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx99
-rw-r--r--web/src/views/ResetPassword/ResetPasswordStep1.tsx67
-rw-r--r--web/src/views/ResetPassword/ResetPasswordStep2.tsx33
-rw-r--r--web/src/views/Revoke/RevokeResetPasswordTokenView.tsx4
-rw-r--r--web/src/views/Settings/Common/SecondFactorMethodMobilePush.tsx96
-rw-r--r--web/src/views/Settings/Common/SecondFactorMethodOneTimePassword.tsx55
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/OneTimePasswordRegisterDialog.tsx3
48 files changed, 1484 insertions, 199 deletions
diff --git a/api/openapi.yml b/api/openapi.yml
index 2c8ac69ff..eb15d39f3 100644
--- a/api/openapi.yml
+++ b/api/openapi.yml
@@ -588,6 +588,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/middlewares.Response.OK'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.Response.KO'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
security:
- authelia_auth: []
/api/reset-password/identity/finish:
@@ -614,6 +626,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/middlewares.Response.OK'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.Response.KO'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
security:
- authelia_auth: []
/api/reset-password:
@@ -639,6 +663,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/middlewares.Response.OK'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.Response.KO'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
security:
- authelia_auth: []
delete:
@@ -661,6 +697,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/middlewares.Response.API'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.Response.KO'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
security:
- authelia_auth: []
{{- end }}
@@ -984,6 +1032,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/middlewares.Response.KO'
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.Response.KO'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
security:
- authelia_auth: []
delete:
@@ -1254,6 +1314,18 @@ paths:
$ref: '#/components/schemas/handlers.redirectResponse'
"401":
description: Unauthorized
+ "429":
+ description: Too Many Requests
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.Response.KO'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
security:
- authelia_auth: []
/api/secondfactor/duo_devices:
@@ -1713,6 +1785,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/openid.spec.ErrorResponseGeneric'
+ headers:
+ Retry-After:
+ schema:
+ type: string
+ format: http-date
+ description: The date time that the request can be retried.
"500":
description: Internal Server Error
content:
diff --git a/docs/content/configuration/miscellaneous/server-endpoint-rate-limits.md b/docs/content/configuration/miscellaneous/server-endpoint-rate-limits.md
new file mode 100644
index 000000000..60988697a
--- /dev/null
+++ b/docs/content/configuration/miscellaneous/server-endpoint-rate-limits.md
@@ -0,0 +1,156 @@
+---
+title: "Server Endpoint Rate Limits"
+description: "Configuring the Server Authz Endpoint Settings."
+summary: "Authelia supports several authorization endpoints on the internal web server. This section describes how to configure and tune them."
+date: 2025-02-26T22:06:24+11:00
+draft: false
+images: []
+menu:
+configuration:
+parent: "miscellaneous"
+weight: 199210
+toc: true
+aliases:
+ - /c/authz
+seo:
+ title: "" # custom title (optional)
+ description: "" # custom description (recommended)
+ canonical: "" # custom canonical URL (optional)
+ noindex: false # false (default) or true
+---
+
+__Authelia__ imposes default rate limits on specific endpoints which can prevent faulty clients or bad actors from
+consuming too many resources or using brute-force to potentially compromise security. This should not be confused with
+[Regulation](../security/regulation.md) which is used to silently ban users from using the username / password form.
+
+## Configuration
+
+{{< config-alert-example >}}
+
+```yaml {title=configuration.yml}
+server:
+ endpoints:
+ rate_limits:
+ reset_password_start:
+ enable: true
+ buckets:
+ - period: '10 minutes'
+ requests: 5
+ - period: '15 minutes'
+ requests: 10
+ - period: '30 minutes'
+ requests: 15
+ reset_password_finish:
+ enable: true
+ buckets:
+ - period: '1 minute'
+ requests: 10
+ - period: '2 minutes'
+ requests: 15
+ second_factor_totp:
+ enable: true
+ buckets:
+ - period: '1 minute'
+ requests: 30
+ - period: '2 minutes'
+ requests: 40
+ - period: '10 minutes'
+ requests: 50
+ second_factor_duo:
+ enable: true
+ buckets:
+ - period: '1 minute'
+ requests: 10
+ - period: '2 minutes'
+ requests: 15
+ session_elevation_start:
+ enable: true
+ buckets:
+ - period: '5 minutes'
+ requests: 3
+ - period: '10 minutes'
+ requests: 5
+ - period: '1 hour'
+ requests: 15
+ session_elevation_finish:
+ enable: true
+ buckets:
+ - period: '10 minutes'
+ requests: 3
+ - period: '20 minutes'
+ requests: 5
+ - period: '1 hour'
+ requests: 15
+```
+
+## Common Options
+
+### enable
+
+{{< confkey type="boolean" default="true" required="no" >}}
+
+Enables the given rate limit configuration. These are enabled by default.
+
+### buckets
+
+{{< confkey type="list(object)" required="no" >}}
+
+The list of individual buckets to consider for each request.
+
+#### period
+
+{{< confkey type="string,integer" syntax="duration" required="situational">}}
+
+Configures the period of time the tokenized bucket applies to.
+
+Required if the [buckets](#buckets) have a configuration and [enable](#enable) is true.
+
+#### requests
+
+{{< confkey type="integer" required="situational">}}
+
+Configures the number of requests the tokenized bucket applies to.
+
+Required if the [buckets](#buckets) have a configuration and [enable](#enable) is true.
+
+## Options
+
+### reset_password_start
+
+Configures the rate limiter which applies to the endpoint that initializes the reset password flow.
+
+See [Common Options](#common-options) for the individual options for this section.
+
+### reset_password_finish
+
+Configures the rate limiter which applies to endpoints which consume tokens for the reset password flow.
+
+See [Common Options](#common-options) for the individual options for this section.
+
+### second_factor_totp
+
+Configures the rate limiter which applies to the [TOTP](../second-factor/time-based-one-time-password.md) endpoint code
+submissions for the second factor flow.
+
+See [Common Options](#common-options) for the individual options for this section.
+
+### second_factor_duo
+
+Configures the rate limiter which applies to the [Duo / Mobile Push](../second-factor/duo.md) endpoint which initializes
+the application authorization flow for the second factor flow.
+
+See [Common Options](#common-options) for the individual options for this section.
+
+### session_elevation_start
+
+Configures the rate limiter which applies to the [Elevated Session](../identity-validation/elevated-session.md) endpoint
+which initializes the code generation and notification for the elevated session flow.
+
+See [Common Options](#common-options) for the individual options for this section.
+
+### session_elevation_finish
+
+Configures the rate limiter which applies to the [Elevated Session](../identity-validation/elevated-session.md) endpoint
+which consumes the code for the elevated session flow.
+
+See [Common Options](#common-options) for the individual options for this section.
diff --git a/docs/content/configuration/miscellaneous/server.md b/docs/content/configuration/miscellaneous/server.md
index ece3bf24b..ead3030a3 100644
--- a/docs/content/configuration/miscellaneous/server.md
+++ b/docs/content/configuration/miscellaneous/server.md
@@ -42,6 +42,7 @@ server:
enable_pprof: false
enable_expvars: false
authz: {} ## See the dedicated "Server Authz Endpoints" configuration guide.
+ rate_limits: {} ## See the dedicated "Server Endpoint Rate Limits" configuration guide.
```
## Options
@@ -192,7 +193,13 @@ Enables the go [expvar](https://pkg.go.dev/expvar) endpoints.
This is an *__advanced__* option allowing configuration of the authorization endpoints and has its own section.
Generally this does not need to be configured for most use cases. See the
-[authz configuration](./server-endpoints-authz.md) for more information.
+[Server Authz Endpoints](./server-endpoints-authz.md) configuration guide for more information.
+
+#### rate_limits
+
+This is an *__advanced__* option allowing configuration of the endpoint rate limits and has its own section.
+Generally this does not need to be configured for most use cases. See the
+[Server Endpoint Rate Limits](./server-endpoint-rate-limits.md) configuration guide for more information.
## Additional Notes
diff --git a/docs/content/configuration/security/regulation.md b/docs/content/configuration/security/regulation.md
index 6d22e0bbf..116fd2079 100644
--- a/docs/content/configuration/security/regulation.md
+++ b/docs/content/configuration/security/regulation.md
@@ -16,9 +16,8 @@ seo:
noindex: false # false (default) or true
---
-
-__Authelia__ can temporarily ban accounts when there are too many
-authentication attempts. This helps prevent brute-force attacks.
+__Authelia__ can temporarily ban accounts when there are too many authentication attempts on the username / password
+endpoint. This helps prevent brute-force attacks.
## Configuration
diff --git a/docs/content/overview/security/measures.md b/docs/content/overview/security/measures.md
index 9f575c1a1..6da19923a 100644
--- a/docs/content/overview/security/measures.md
+++ b/docs/content/overview/security/measures.md
@@ -44,7 +44,7 @@ obtain the certificate.
Note that using [HSTS] has consequences, and you should do adequate research into understanding [HSTS] before you enable
it. For example the [nginx blog] has a good article helping users understand it.
-## Protection against username enumeration
+## Protection against username enumeration and password brute-force attacks
Authelia adaptively delays authentication attempts based on the mean (average) of the previous 10 successful attempts
in addition to a small random interval of time. The result of this delay is that it makes it incredibly difficult to
@@ -61,10 +61,15 @@ the added effect of creating an additional delay for all authentication attempts
attack will take, this combined with regulation greatly delays brute-force attacks and the effectiveness of them in
general.
-## Protections against password cracking (File authentication provider)
+## Protections against password brute-force attacks
-Authelia implements a variety of measures to prevent an attacker cracking passwords if they somehow obtain the file used
-by the file authentication provider, this is unrelated to LDAP auth.
+Authelia implements a variety of measures to prevent an attacker brute-forcing passwords if they somehow obtain the file
+used by the file authentication provider.
+
+{{< callout context="note" title="Note" icon="outline/info-circle" >}}
+The LDAP authentication provider honors the password modify extended operation if available which delegates this task to
+the LDAP server.
+{{< /callout >}}
First and foremost Authelia only uses very secure hashing algorithms with sane and secure defaults. The first and
default hashing algorithm we use is Argon2id which is currently considered the most secure hashing algorithm. We also
@@ -78,6 +83,14 @@ Lastly Authelia's implementation of Argon2id is highly tunable. You can tune the
(time), parallelism, and memory usage. To read more about this, please read how to
[configure](../../configuration/first-factor/file.md) file authentication.
+## Protection against request brute-force attacks
+
+Authelia implements a tokenized bucket rate limiter on specific endpoints which greatly reduces the chances these
+endpoints can be brute-forced for various outcomes including guessing secret values, or inundating an inbox with emails.
+
+These rate limiters are applied on a per-IP basis and can be
+[configured](../../configuration/miscellaneous/server-endpoint-rate-limits.md) depending on a particular use case.
+
## Protections against return oriented programming attacks and general hardening
Authelia is built as a position independent executable which makes Return Oriented Programming (ROP) attacks
diff --git a/docs/data/configkeys.json b/docs/data/configkeys.json
index dd621ea17..833e341c1 100644
--- a/docs/data/configkeys.json
+++ b/docs/data/configkeys.json
@@ -805,6 +805,36 @@
"env": "AUTHELIA_SERVER_ENDPOINTS_ENABLE_PPROF"
},
{
+ "path": "server.endpoints.rate_limits.reset_password_finish.enable",
+ "secret": false,
+ "env": "AUTHELIA_SERVER_ENDPOINTS_RATE_LIMITS_RESET_PASSWORD_FINISH_ENABLE"
+ },
+ {
+ "path": "server.endpoints.rate_limits.reset_password_start.enable",
+ "secret": false,
+ "env": "AUTHELIA_SERVER_ENDPOINTS_RATE_LIMITS_RESET_PASSWORD_START_ENABLE"
+ },
+ {
+ "path": "server.endpoints.rate_limits.second_factor_duo.enable",
+ "secret": false,
+ "env": "AUTHELIA_SERVER_ENDPOINTS_RATE_LIMITS_SECOND_FACTOR_DUO_ENABLE"
+ },
+ {
+ "path": "server.endpoints.rate_limits.second_factor_totp.enable",
+ "secret": false,
+ "env": "AUTHELIA_SERVER_ENDPOINTS_RATE_LIMITS_SECOND_FACTOR_TOTP_ENABLE"
+ },
+ {
+ "path": "server.endpoints.rate_limits.session_elevation_finish.enable",
+ "secret": false,
+ "env": "AUTHELIA_SERVER_ENDPOINTS_RATE_LIMITS_SESSION_ELEVATION_FINISH_ENABLE"
+ },
+ {
+ "path": "server.endpoints.rate_limits.session_elevation_start.enable",
+ "secret": false,
+ "env": "AUTHELIA_SERVER_ENDPOINTS_RATE_LIMITS_SESSION_ELEVATION_START_ENABLE"
+ },
+ {
"path": "server.headers.csp_template",
"secret": false,
"env": "AUTHELIA_SERVER_HEADERS_CSP_TEMPLATE"
diff --git a/docs/static/schemas/v4.39/json-schema/configuration.json b/docs/static/schemas/v4.39/json-schema/configuration.json
index 1cf4d26c3..0ad5f3fbe 100644
--- a/docs/static/schemas/v4.39/json-schema/configuration.json
+++ b/docs/static/schemas/v4.39/json-schema/configuration.json
@@ -3189,6 +3189,68 @@
"type": "object",
"description": "ServerBuffers represents server buffer configurations."
},
+ "ServerEndpointRateLimit": {
+ "properties": {
+ "enable": {
+ "type": "boolean"
+ },
+ "buckets": {
+ "items": {
+ "$ref": "#/$defs/ServerEndpointRateLimitBucket"
+ },
+ "type": "array"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object"
+ },
+ "ServerEndpointRateLimitBucket": {
+ "properties": {
+ "period": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^\\d+\\s*(y|M|w|d|h|m|s|ms|((year|month|week|day|hour|minute|second|millisecond)s?))(\\s*\\d+\\s*(y|M|w|d|h|m|s|ms|((year|month|week|day|hour|minute|second|millisecond)s?)))*$"
+ },
+ {
+ "type": "integer",
+ "description": "The duration in seconds"
+ }
+ ],
+ "description": "The period of time this rate limit bucket applies to."
+ },
+ "requests": {
+ "type": "integer",
+ "description": "The number of requests allowed in this rate limit bucket for the configured period before the rate limit kicks in."
+ }
+ },
+ "additionalProperties": false,
+ "type": "object"
+ },
+ "ServerEndpointRateLimits": {
+ "properties": {
+ "reset_password_start": {
+ "$ref": "#/$defs/ServerEndpointRateLimit"
+ },
+ "reset_password_finish": {
+ "$ref": "#/$defs/ServerEndpointRateLimit"
+ },
+ "second_factor_totp": {
+ "$ref": "#/$defs/ServerEndpointRateLimit"
+ },
+ "second_factor_duo": {
+ "$ref": "#/$defs/ServerEndpointRateLimit"
+ },
+ "session_elevation_start": {
+ "$ref": "#/$defs/ServerEndpointRateLimit"
+ },
+ "session_elevation_finish": {
+ "$ref": "#/$defs/ServerEndpointRateLimit"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object"
+ },
"ServerEndpoints": {
"properties": {
"enable_pprof": {
@@ -3203,6 +3265,9 @@
"description": "Enables the developer specific ExpVars endpoints which should not be used in production and only used for debugging purposes.",
"default": false
},
+ "rate_limits": {
+ "$ref": "#/$defs/ServerEndpointRateLimits"
+ },
"authz": {
"patternProperties": {
".*": {
diff --git a/go.mod b/go.mod
index a1b018e86..574688515 100644
--- a/go.mod
+++ b/go.mod
@@ -53,6 +53,7 @@ require (
golang.org/x/sync v0.11.0
golang.org/x/term v0.29.0
golang.org/x/text v0.22.0
+ golang.org/x/time v0.8.0
gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index 81524922c..f2e5c8c59 100644
--- a/go.sum
+++ b/go.sum
@@ -342,6 +342,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
+golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/internal/configuration/defaults.go b/internal/configuration/defaults.go
index ea1ea3de9..7ca3cfb2c 100644
--- a/internal/configuration/defaults.go
+++ b/internal/configuration/defaults.go
@@ -1,8 +1,14 @@
package configuration
var defaults = map[string]any{
- "webauthn.selection_criteria.discoverability": "preferred",
- "webauthn.selection_criteria.user_verification": "preferred",
+ "webauthn.selection_criteria.discoverability": "preferred",
+ "webauthn.selection_criteria.user_verification": "preferred",
+ "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.session_elevation_start.enable": true,
+ "server.endpoints.rate_limits.session_elevation_finish.enable": true,
}
// Defaults returns a copy of the defaults.
diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go
index f6ec52ac3..de65bd4ba 100644
--- a/internal/configuration/schema/keys.go
+++ b/internal/configuration/schema/keys.go
@@ -333,6 +333,30 @@ var Keys = []string{
"server.endpoints.authz.*.implementation",
"server.endpoints.enable_expvars",
"server.endpoints.enable_pprof",
+ "server.endpoints.rate_limits.reset_password_finish.buckets",
+ "server.endpoints.rate_limits.reset_password_finish.buckets[].period",
+ "server.endpoints.rate_limits.reset_password_finish.buckets[].requests",
+ "server.endpoints.rate_limits.reset_password_finish.enable",
+ "server.endpoints.rate_limits.reset_password_start.buckets",
+ "server.endpoints.rate_limits.reset_password_start.buckets[].period",
+ "server.endpoints.rate_limits.reset_password_start.buckets[].requests",
+ "server.endpoints.rate_limits.reset_password_start.enable",
+ "server.endpoints.rate_limits.second_factor_duo.buckets",
+ "server.endpoints.rate_limits.second_factor_duo.buckets[].period",
+ "server.endpoints.rate_limits.second_factor_duo.buckets[].requests",
+ "server.endpoints.rate_limits.second_factor_duo.enable",
+ "server.endpoints.rate_limits.second_factor_totp.buckets",
+ "server.endpoints.rate_limits.second_factor_totp.buckets[].period",
+ "server.endpoints.rate_limits.second_factor_totp.buckets[].requests",
+ "server.endpoints.rate_limits.second_factor_totp.enable",
+ "server.endpoints.rate_limits.session_elevation_finish.buckets",
+ "server.endpoints.rate_limits.session_elevation_finish.buckets[].period",
+ "server.endpoints.rate_limits.session_elevation_finish.buckets[].requests",
+ "server.endpoints.rate_limits.session_elevation_finish.enable",
+ "server.endpoints.rate_limits.session_elevation_start.buckets",
+ "server.endpoints.rate_limits.session_elevation_start.buckets[].period",
+ "server.endpoints.rate_limits.session_elevation_start.buckets[].requests",
+ "server.endpoints.rate_limits.session_elevation_start.enable",
"server.headers.csp_template",
"server.timeouts.idle",
"server.timeouts.read",
diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go
index 7ab781c63..1a4b1c2fe 100644
--- a/internal/configuration/schema/server.go
+++ b/internal/configuration/schema/server.go
@@ -24,6 +24,8 @@ type ServerEndpoints struct {
EnablePprof bool `koanf:"enable_pprof" json:"enable_pprof" jsonschema:"default=false,title=Enable PProf" jsonschema_description:"Enables the developer specific pprof endpoints which should not be used in production and only used for debugging purposes."`
EnableExpvars bool `koanf:"enable_expvars" json:"enable_expvars" jsonschema:"default=false,title=Enable ExpVars" jsonschema_description:"Enables the developer specific ExpVars endpoints which should not be used in production and only used for debugging purposes."`
+ RateLimits ServerEndpointRateLimits `koanf:"rate_limits" json:"rate_limits"`
+
Authz map[string]ServerEndpointsAuthz `koanf:"authz" json:"authz" jsonschema:"title=Authz" jsonschema_description:"Configures the Authorization endpoints."`
}
@@ -53,6 +55,25 @@ type ServerHeaders struct {
CSPTemplate CSPTemplate `koanf:"csp_template" json:"csp_template" jsonschema:"title=CSP Template" jsonschema_description:"The Content Security Policy template."`
}
+type ServerEndpointRateLimits struct {
+ ResetPasswordStart ServerEndpointRateLimit `koanf:"reset_password_start" json:"reset_password_start"`
+ ResetPasswordFinish ServerEndpointRateLimit `koanf:"reset_password_finish" json:"reset_password_finish"`
+ SecondFactorTOTP ServerEndpointRateLimit `koanf:"second_factor_totp" json:"second_factor_totp"`
+ SecondFactorDuo ServerEndpointRateLimit `koanf:"second_factor_duo" json:"second_factor_duo"`
+ SessionElevationStart ServerEndpointRateLimit `koanf:"session_elevation_start" json:"session_elevation_start"`
+ SessionElevationFinish ServerEndpointRateLimit `koanf:"session_elevation_finish" json:"session_elevation_finish"`
+}
+
+type ServerEndpointRateLimit struct {
+ Enable bool `koanf:"enable" json:"enable"`
+ Buckets []ServerEndpointRateLimitBucket `koanf:"buckets" json:"buckets"`
+}
+
+type ServerEndpointRateLimitBucket struct {
+ Period time.Duration `koanf:"period" json:"period" jsonschema:"" jsonschema_description:"The period of time this rate limit bucket applies to."`
+ Requests int `koanf:"requests" json:"requests" jsonschema:"" jsonschema_description:"The number of requests allowed in this rate limit bucket for the configured period before the rate limit kicks in."`
+}
+
// DefaultServerConfiguration represents the default values of the Server.
var DefaultServerConfiguration = Server{
Address: &AddressTCP{Address{true, false, -1, 9091, &url.URL{Scheme: AddressSchemeTCP, Host: ":9091", Path: "/"}}},
@@ -115,5 +136,47 @@ var DefaultServerConfiguration = Server{
},
},
},
+ RateLimits: ServerEndpointRateLimits{
+ ResetPasswordStart: ServerEndpointRateLimit{
+ Buckets: []ServerEndpointRateLimitBucket{
+ {Period: 10 * time.Minute, Requests: 5},
+ {Period: 15 * time.Minute, Requests: 10},
+ {Period: 30 * time.Minute, Requests: 15},
+ },
+ },
+ ResetPasswordFinish: ServerEndpointRateLimit{
+ Buckets: []ServerEndpointRateLimitBucket{
+ {Period: 1 * time.Minute, Requests: 10},
+ {Period: 2 * time.Minute, Requests: 15},
+ },
+ },
+ SecondFactorTOTP: ServerEndpointRateLimit{
+ Buckets: []ServerEndpointRateLimitBucket{
+ {Period: 1 * time.Minute, Requests: 30},
+ {Period: 2 * time.Minute, Requests: 40},
+ {Period: 10 * time.Minute, Requests: 50},
+ },
+ },
+ SecondFactorDuo: ServerEndpointRateLimit{
+ Buckets: []ServerEndpointRateLimitBucket{
+ {Period: 1 * time.Minute, Requests: 10},
+ {Period: 2 * time.Minute, Requests: 15},
+ },
+ },
+ SessionElevationStart: ServerEndpointRateLimit{
+ Buckets: []ServerEndpointRateLimitBucket{
+ {Period: 1, Requests: 3}, // 3 requests per 1.0x of identity_validation.elevated_session.code_lifespan.
+ {Period: 2, Requests: 5}, // 5 requests per 2.0x of identity_validation.elevated_session.code_lifespan.
+ {Period: 12, Requests: 15}, // 15 requests per 12.0x of identity_validation.elevated_session.code_lifespan.
+ },
+ },
+ SessionElevationFinish: ServerEndpointRateLimit{
+ Buckets: []ServerEndpointRateLimitBucket{
+ {Period: 1, Requests: 3}, // 3 requests per 1.0x of identity_validation.elevated_session.elevation_lifespan.
+ {Period: 2, Requests: 5}, // 5 requests per 2.0x of identity_validation.elevated_session.elevation_lifespan.
+ {Period: 6, Requests: 15}, // 15 requests per 6.0x of identity_validation.elevated_session.elevation_lifespan.
+ },
+ },
+ },
},
}
diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go
index f484c66c1..177ca7a12 100644
--- a/internal/configuration/validator/configuration.go
+++ b/internal/configuration/validator/configuration.go
@@ -45,6 +45,8 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc
ValidateWebAuthn(config, validator)
+ ValidateIdentityValidation(config, validator)
+
ValidateAuthenticationBackend(&config.AuthenticationBackend, validator)
ValidateDefinitions(config, validator)
@@ -67,8 +69,6 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc
ValidateIdentityProviders(ctx, config, validator)
- ValidateIdentityValidation(config, validator)
-
ValidateNTP(config, validator)
ValidatePasswordPolicy(&config.PasswordPolicy, validator)
diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go
index 7d3340d68..a465a3568 100644
--- a/internal/configuration/validator/const.go
+++ b/internal/configuration/validator/const.go
@@ -427,6 +427,9 @@ const (
errFmtServerEndpointsAuthzStrategySchemeOnlyOption = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option '%s' can't be configured unless the '%s' scheme is configured but only the %s schemes are configured"
errFmtServerEndpointsAuthzStrategyDuplicate = "server: endpoints: authz: %s: authn_strategies: duplicate strategy name detected with name '%s'"
errFmtServerEndpointsAuthzPrefixDuplicate = "server: endpoints: authz: %s: endpoint starts with the same prefix as the '%s' endpoint with the '%s' implementation which accepts prefixes as part of its implementation"
+ errFmtServerEndpointsRateLimitsBucketPeriodZero = "server: endpoints: rate_limits: %s: bucket %d: option 'period' must have a value"
+ errFmtServerEndpointsRateLimitsBucketPeriodTooLow = "server: endpoints: rate_limits: %s: bucket %d: option 'period' has a value of '%s' but it must be greater than 10 seconds"
+ errFmtServerEndpointsRateLimitsBucketRequestsZero = "server: endpoints: rate_limits: %s: bucket %d: option 'requests' has a value of '%d' but it must be greater than 1"
errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters"
errFmtServerEndpointsAuthzLegacyInvalidImplementation = "server: endpoints: authz: %s: option 'implementation' is invalid: the endpoint with the name 'legacy' must use the 'Legacy' implementation"
diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go
index 95589ab1b..2b7dfb79a 100644
--- a/internal/configuration/validator/server.go
+++ b/internal/configuration/validator/server.go
@@ -6,6 +6,7 @@ import (
"os"
"sort"
"strings"
+ "time"
"github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/utils"
@@ -110,6 +111,8 @@ func ValidateServerAddress(config *schema.Configuration, validator *schema.Struc
// ValidateServerEndpoints configures the default endpoints and checks the configuration of custom endpoints.
func ValidateServerEndpoints(config *schema.Configuration, validator *schema.StructValidator) {
+ validateServerEndpointsRateLimits(config, validator)
+
if config.Server.Endpoints.EnableExpvars {
validator.PushWarning(fmt.Errorf("server: endpoints: option 'enable_expvars' should not be enabled in production"))
}
@@ -158,6 +161,59 @@ func ValidateServerEndpoints(config *schema.Configuration, validator *schema.Str
}
}
+func validateServerEndpointsRateLimits(config *schema.Configuration, validator *schema.StructValidator) {
+ 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)
+
+ 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
+ }
+
+ 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))
+
+ for i := range defaults.Buckets {
+ config.Buckets[i] = schema.ServerEndpointRateLimitBucket{
+ Period: weight * defaults.Buckets[i].Period,
+ Requests: defaults.Buckets[i].Requests,
+ }
+ }
+
+ return
+ }
+
+ validateServerEndpointsRateLimitBuckets(name, config, validator)
+}
+
+func validateServerEndpointsRateLimitBuckets(name string, config *schema.ServerEndpointRateLimit, validator *schema.StructValidator) {
+ for i, bucket := range config.Buckets {
+ if bucket.Period == 0 {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsRateLimitsBucketPeriodZero, name, i+1))
+ } else if bucket.Period < (time.Second * 10) {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsRateLimitsBucketPeriodTooLow, name, i+1, bucket.Period))
+ }
+
+ if bucket.Requests <= 0 {
+ validator.Push(fmt.Errorf(errFmtServerEndpointsRateLimitsBucketRequestsZero, name, i+1, bucket.Requests))
+ }
+ }
+}
+
func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name string, endpoint schema.ServerEndpointsAuthz, validator *schema.StructValidator) {
if name == legacy {
switch endpoint.Implementation {
diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go
index 24c080baa..5e04ac802 100644
--- a/internal/configuration/validator/server_test.go
+++ b/internal/configuration/validator/server_test.go
@@ -26,6 +26,9 @@ func TestShouldSetDefaultServerValues(t *testing.T) {
assert.Equal(t, schema.DefaultServerConfiguration.Address, config.Server.Address)
assert.Equal(t, schema.DefaultServerConfiguration.Buffers.Read, config.Server.Buffers.Read)
assert.Equal(t, schema.DefaultServerConfiguration.Buffers.Write, config.Server.Buffers.Write)
+ assert.Equal(t, schema.DefaultServerConfiguration.Timeouts.Read, config.Server.Timeouts.Read)
+ assert.Equal(t, schema.DefaultServerConfiguration.Timeouts.Write, config.Server.Timeouts.Write)
+ assert.Equal(t, schema.DefaultServerConfiguration.Timeouts.Idle, config.Server.Timeouts.Idle)
assert.Equal(t, schema.DefaultServerConfiguration.TLS.Key, config.Server.TLS.Key)
assert.Equal(t, schema.DefaultServerConfiguration.TLS.Certificate, config.Server.TLS.Certificate)
assert.Equal(t, schema.DefaultServerConfiguration.Endpoints.EnableExpvars, config.Server.Endpoints.EnableExpvars)
@@ -33,17 +36,138 @@ func TestShouldSetDefaultServerValues(t *testing.T) {
assert.Equal(t, schema.DefaultServerConfiguration.Endpoints.Authz, config.Server.Endpoints.Authz)
}
-func TestShouldSetDefaultConfig(t *testing.T) {
- validator := schema.NewStructValidator()
- config := &schema.Configuration{}
+func TestShouldSetDefaultConfigRateLimits(t *testing.T) {
+ testCases := []struct {
+ name string
+ config *schema.Configuration
+ expected schema.ServerEndpointRateLimits
+ }{
+ {
+ name: "ShouldSetDefaults",
+ config: &schema.Configuration{
+ IdentityValidation: schema.IdentityValidation{
+ ResetPassword: schema.IdentityValidationResetPassword{
+ JWTSecret: "dfgdfgdzfgdgdfgdfg",
+ },
+ },
+ },
+ expected: schema.ServerEndpointRateLimits{
+ ResetPasswordStart: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: 10 * time.Minute, Requests: 5},
+ {Period: 15 * time.Minute, Requests: 10},
+ {Period: 30 * time.Minute, Requests: 15},
+ },
+ },
+ ResetPasswordFinish: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: 1 * time.Minute, Requests: 10},
+ {Period: 2 * time.Minute, Requests: 15},
+ },
+ },
+ SecondFactorTOTP: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: 1 * time.Minute, Requests: 30},
+ {Period: 2 * time.Minute, Requests: 40},
+ {Period: 10 * time.Minute, Requests: 50},
+ },
+ },
+ SecondFactorDuo: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: 1 * time.Minute, Requests: 10},
+ {Period: 2 * time.Minute, Requests: 15},
+ },
+ },
+ SessionElevationStart: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 1, Requests: 3},
+ {Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 2, Requests: 5},
+ {Period: schema.DefaultIdentityValidation.ElevatedSession.CodeLifespan * 12, Requests: 15},
+ },
+ },
+ SessionElevationFinish: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: schema.DefaultIdentityValidation.ElevatedSession.ElevationLifespan * 1, Requests: 3},
+ {Period: schema.DefaultIdentityValidation.ElevatedSession.ElevationLifespan * 2, Requests: 5},
+ {Period: schema.DefaultIdentityValidation.ElevatedSession.ElevationLifespan * 6, Requests: 15},
+ },
+ },
+ },
+ },
+ }
- ValidateServer(config, validator)
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ validator := schema.NewStructValidator()
- assert.Len(t, validator.Errors(), 0)
- assert.Len(t, validator.Warnings(), 0)
+ ValidateIdentityValidation(tc.config, validator)
+ ValidateServer(tc.config, validator)
- assert.Equal(t, schema.DefaultServerConfiguration.Buffers.Read, config.Server.Buffers.Read)
- assert.Equal(t, schema.DefaultServerConfiguration.Buffers.Write, config.Server.Buffers.Write)
+ assert.Len(t, validator.Errors(), 0)
+ assert.Len(t, validator.Warnings(), 0)
+
+ 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)
+ })
+ }
+}
+
+func TestValidateRateLimitErrors(t *testing.T) {
+ have := &schema.Configuration{
+ Server: schema.Server{
+ Endpoints: schema.ServerEndpoints{
+ RateLimits: schema.ServerEndpointRateLimits{
+ ResetPasswordStart: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: time.Second, Requests: 5},
+ },
+ },
+ ResetPasswordFinish: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: time.Second, Requests: 5},
+ },
+ },
+ SecondFactorTOTP: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: time.Second, Requests: 5},
+ },
+ },
+ SecondFactorDuo: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: time.Second, Requests: 5},
+ },
+ },
+ SessionElevationStart: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: time.Second, Requests: 5},
+ },
+ },
+ SessionElevationFinish: schema.ServerEndpointRateLimit{
+ Buckets: []schema.ServerEndpointRateLimitBucket{
+ {Period: time.Duration(0), Requests: 0},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ validator := schema.NewStructValidator()
+
+ ValidateServer(have, validator)
+
+ require.Len(t, validator.Errors(), 7)
+ assert.EqualError(t, validator.Errors()[0], "server: endpoints: rate_limits: reset_password_start: bucket 1: option 'period' has a value of '1s' but it must be greater than 10 seconds")
+ assert.EqualError(t, validator.Errors()[1], "server: endpoints: rate_limits: reset_password_finish: bucket 1: option 'period' has a value of '1s' but it must be greater than 10 seconds")
+ assert.EqualError(t, validator.Errors()[2], "server: endpoints: rate_limits: second_factor_totp: bucket 1: option 'period' has a value of '1s' but it must be greater than 10 seconds")
+ assert.EqualError(t, validator.Errors()[3], "server: endpoints: rate_limits: second_factor_duo: bucket 1: option 'period' has a value of '1s' but it must be greater than 10 seconds")
+ assert.EqualError(t, validator.Errors()[4], "server: endpoints: rate_limits: session_elevation_start: bucket 1: option 'period' has a value of '1s' but it must be greater than 10 seconds")
+ assert.EqualError(t, validator.Errors()[5], "server: endpoints: rate_limits: session_elevation_finish: bucket 1: option 'period' must have a value")
+ assert.EqualError(t, validator.Errors()[6], "server: endpoints: rate_limits: session_elevation_finish: bucket 1: option 'requests' has a value of '0' but it must be greater than 1")
}
func TestValidateSeverAddress(t *testing.T) {
@@ -77,24 +201,24 @@ func TestValidateServerShouldCorrectlyIdentifyValidAddressSchemes(t *testing.T)
{"http", "server: option 'address' with value 'http://:9091' is invalid: scheme must be one of 'tcp', 'tcp4', 'tcp6', or 'unix' but is configured as 'http'"},
}
- have := &schema.Configuration{
- Server: schema.Server{
- Buffers: schema.ServerBuffers{
- Read: -1,
- Write: -1,
- },
- Timeouts: schema.ServerTimeouts{
- Read: time.Second * -1,
- Write: time.Second * -1,
- Idle: time.Second * -1,
- },
- },
- }
-
validator := schema.NewStructValidator()
for _, tc := range testCases {
t.Run(tc.have, func(t *testing.T) {
+ have := &schema.Configuration{
+ Server: schema.Server{
+ Buffers: schema.ServerBuffers{
+ Read: -1,
+ Write: -1,
+ },
+ Timeouts: schema.ServerTimeouts{
+ Read: time.Second * -1,
+ Write: time.Second * -1,
+ Idle: time.Second * -1,
+ },
+ },
+ }
+
validator.Clear()
switch tc.have {
diff --git a/internal/middlewares/bridge.go b/internal/middlewares/bridge.go
index 5b4390566..fd0ff2b22 100644
--- a/internal/middlewares/bridge.go
+++ b/internal/middlewares/bridge.go
@@ -30,7 +30,13 @@ func (b *BridgeBuilder) WithProviders(providers Providers) *BridgeBuilder {
// WithPreMiddlewares sets the Middleware's used with this BridgeBuilder which are applied before the actual Bridge.
func (b *BridgeBuilder) WithPreMiddlewares(middlewares ...Middleware) *BridgeBuilder {
- b.preMiddlewares = middlewares
+ for _, middleware := range middlewares {
+ if middleware == nil {
+ continue
+ }
+
+ b.preMiddlewares = append(b.preMiddlewares, middleware)
+ }
return b
}
@@ -38,7 +44,13 @@ func (b *BridgeBuilder) WithPreMiddlewares(middlewares ...Middleware) *BridgeBui
// WithPostMiddlewares sets the AutheliaMiddleware's used with this BridgeBuilder which are applied after the actual
// Bridge.
func (b *BridgeBuilder) WithPostMiddlewares(middlewares ...AutheliaMiddleware) *BridgeBuilder {
- b.postMiddlewares = middlewares
+ for _, middleware := range middlewares {
+ if middleware == nil {
+ continue
+ }
+
+ b.postMiddlewares = append(b.postMiddlewares, middleware)
+ }
return b
}
diff --git a/internal/middlewares/const.go b/internal/middlewares/const.go
index fd6ce084c..25bf67093 100644
--- a/internal/middlewares/const.go
+++ b/internal/middlewares/const.go
@@ -33,6 +33,7 @@ var (
headerAccessControlMaxAge = []byte(fasthttp.HeaderAccessControlMaxAge)
headerAccessControlRequestHeaders = []byte(fasthttp.HeaderAccessControlRequestHeaders)
headerAccessControlRequestMethod = []byte(fasthttp.HeaderAccessControlRequestMethod)
+ headerRetryAfter = []byte(fasthttp.HeaderRetryAfter)
headerXContentTypeOptions = []byte(fasthttp.HeaderXContentTypeOptions)
headerReferrerPolicy = []byte(fasthttp.HeaderReferrerPolicy)
diff --git a/internal/middlewares/rate_limiting.go b/internal/middlewares/rate_limiting.go
new file mode 100644
index 000000000..de0bf79b3
--- /dev/null
+++ b/internal/middlewares/rate_limiting.go
@@ -0,0 +1,191 @@
+package middlewares
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/valyala/fasthttp"
+ "golang.org/x/time/rate"
+
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+)
+
+// RateLimitBucket describes an implementation of a bucket which can be leveraged for rate limiting.
+type RateLimitBucket interface {
+ FetchCtx(ctx *AutheliaCtx) (limiter *RateLimiter)
+ GC()
+}
+
+// The RateLimitBucketConfig describes a limit (number of seconds), and a burst (number of events) that can occur for a
+// given rate limiter.
+type RateLimitBucketConfig struct {
+ Period time.Duration
+ Requests int
+}
+
+// NewIPRateLimit given a series of RateLimitBucketConfig items produces an AutheliaMiddleware which handles requests based
+// on the IPRateLimitBucket.
+func NewIPRateLimit(bs ...RateLimitBucketConfig) AutheliaMiddleware {
+ return NewRateLimiter(NewIPRateLimitBucket, HandlerRateLimitAPI, bs...)
+}
+
+type NewRateLimiterFunc func(bucket RateLimitBucketConfig) RateLimitBucket
+
+func HandlerRateLimitAPI(ctx *AutheliaCtx) {
+ ctx.SetJSONError(fasthttp.StatusMessage(fasthttp.StatusTooManyRequests))
+}
+
+func NewRateLimiter(newBucket func(bucket RateLimitBucketConfig) RateLimitBucket, handler RequestHandler, bs ...RateLimitBucketConfig) AutheliaMiddleware {
+ buckets := make([]RateLimitBucket, len(bs))
+
+ for i, b := range bs {
+ buckets[i] = newBucket(b)
+ }
+
+ if handler == nil {
+ handler = HandlerRateLimitAPI
+ }
+
+ ticker := time.NewTicker(time.Minute * 30)
+
+ go func() {
+ for range ticker.C {
+ for _, bucket := range buckets {
+ bucket.GC()
+ }
+ }
+ }()
+
+ return func(next RequestHandler) RequestHandler {
+ return func(ctx *AutheliaCtx) {
+ var (
+ retryAfter time.Duration
+ )
+
+ for i, bucket := range buckets {
+ limiter := bucket.FetchCtx(ctx)
+
+ if !limiter.Allow() {
+ reservation := limiter.ReserveN(time.Now().UTC(), 1)
+ limiter.updated = time.Now().UTC()
+
+ ctx.Logger.WithFields(map[string]any{"bucket": i + 1, "delay": reservation.Delay().Seconds()}).Warn("Rate Limit Exceeded")
+
+ if reservation.Delay() > retryAfter {
+ retryAfter = reservation.Delay()
+ }
+
+ reservation.Cancel()
+ }
+ }
+
+ if retryAfter > 0 {
+ ctx.Response.Header.SetBytesK(headerRetryAfter, time.Now().UTC().Add(retryAfter).Format(http.TimeFormat))
+ ctx.SetStatusCode(fasthttp.StatusTooManyRequests)
+
+ handler(ctx)
+
+ return
+ }
+
+ next(ctx)
+ }
+ }
+}
+
+// NewIPRateLimitBucket returns a IPRateLimitBucket given a RateLimitBucketConfig.
+func NewIPRateLimitBucket(bucket RateLimitBucketConfig) (limiter RateLimitBucket) {
+ return &IPRateLimitBucket{
+ bucket: make(map[string]*RateLimiter),
+ mu: sync.Mutex{},
+ p: bucket.Period,
+ r: rate.Every(bucket.Period),
+ b: bucket.Requests,
+ }
+}
+
+type RateLimiter struct {
+ *rate.Limiter
+
+ updated time.Time
+}
+
+// IPRateLimitBucket is a RateLimitBucket which limits requests based on each of the buckets delimited by IP.
+type IPRateLimitBucket struct {
+ bucket map[string]*RateLimiter
+ mu sync.Mutex
+ p time.Duration
+ r rate.Limit
+ b int
+}
+
+func (l *IPRateLimitBucket) Fetch(key string) (limiter *RateLimiter) {
+ l.mu.Lock()
+
+ defer l.mu.Unlock()
+
+ var ok bool
+
+ if limiter, ok = l.bucket[key]; !ok {
+ limiter = l.new(key)
+ }
+
+ return limiter
+}
+
+func (l *IPRateLimitBucket) GC() {
+ if len(l.bucket) == 0 {
+ return
+ }
+
+ l.mu.Lock()
+
+ defer l.mu.Unlock()
+
+ for k, limiter := range l.bucket {
+ if limiter.updated.Add(l.p).Before(time.Now().UTC()) {
+ delete(l.bucket, k)
+ }
+ }
+}
+
+func (l *IPRateLimitBucket) FetchCtx(ctx *AutheliaCtx) (limiter *RateLimiter) {
+ return l.Fetch(ctx.RemoteIP().String())
+}
+
+func (l *IPRateLimitBucket) new(ip string) (limiter *RateLimiter) {
+ limiter = &RateLimiter{Limiter: rate.NewLimiter(l.r, l.b), updated: time.Now().UTC()}
+
+ l.bucket[ip] = limiter
+
+ return limiter
+}
+
+func NewRateLimitHandler(config schema.ServerEndpointRateLimit, next RequestHandler) RequestHandler {
+ if !config.Enable || len(config.Buckets) == 0 {
+ return next
+ }
+
+ middleware := NewIPRateLimit(NewRateLimitBucketsConfig(config)...)
+
+ return middleware(next)
+}
+
+func NewRateLimit(config schema.ServerEndpointRateLimit) AutheliaMiddleware {
+ if !config.Enable || len(config.Buckets) == 0 {
+ return nil
+ }
+
+ return NewIPRateLimit(NewRateLimitBucketsConfig(config)...)
+}
+
+func NewRateLimitBucketsConfig(config schema.ServerEndpointRateLimit) []RateLimitBucketConfig {
+ buckets := make([]RateLimitBucketConfig, len(config.Buckets))
+
+ for i, bucket := range config.Buckets {
+ buckets[i] = RateLimitBucketConfig{Period: bucket.Period, Requests: bucket.Requests}
+ }
+
+ return buckets
+}
diff --git a/internal/server/handlers.go b/internal/server/handlers.go
index ff24c246e..db0a4e20e 100644
--- a/internal/server/handlers.go
+++ b/internal/server/handlers.go
@@ -258,12 +258,14 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
// Only register endpoints if forgot password is not disabled.
if !config.AuthenticationBackend.PasswordReset.Disable &&
config.AuthenticationBackend.PasswordReset.CustomURL.String() == "" {
+ resetPasswordTokenRL := middlewares.NewIPRateLimit(middlewares.NewRateLimitBucketsConfig(config.Server.Endpoints.RateLimits.ResetPasswordFinish)...)
+
// Password reset related endpoints.
- r.POST("/api/reset-password/identity/start", middlewareAPI(handlers.ResetPasswordIdentityStart))
- r.POST("/api/reset-password/identity/finish", middlewareAPI(handlers.ResetPasswordIdentityFinish))
+ r.POST("/api/reset-password/identity/start", middlewareAPI(middlewares.NewRateLimitHandler(config.Server.Endpoints.RateLimits.ResetPasswordStart, handlers.ResetPasswordIdentityStart)))
+ r.POST("/api/reset-password/identity/finish", middlewareAPI(resetPasswordTokenRL(handlers.ResetPasswordIdentityFinish)))
r.POST("/api/reset-password", middlewareAPI(handlers.ResetPasswordPOST))
- r.DELETE("/api/reset-password", middlewareAPI(handlers.ResetPasswordDELETE))
+ r.DELETE("/api/reset-password", middlewareAPI(resetPasswordTokenRL(handlers.ResetPasswordDELETE)))
}
// Information about the user.
@@ -272,18 +274,31 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
r.POST("/api/user/info/2fa_method", middleware1FA(handlers.MethodPreferencePOST))
// User Session Elevation.
- middlewareDelaySecond := middlewares.ArbitraryDelay(time.Second)
+ middlewareElevatePOST := middlewares.NewBridgeBuilder(*config, providers).
+ WithPreMiddlewares(middlewares.SecurityHeadersBase, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone).
+ WithPostMiddlewares(middlewares.NewRateLimit(config.Server.Endpoints.RateLimits.SessionElevationStart), middlewares.Require1FA).
+ Build()
+
+ middlewareElevatePUT := middlewares.NewBridgeBuilder(*config, providers).
+ WithPreMiddlewares(middlewares.SecurityHeadersBase, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone, middlewares.ArbitraryDelay(time.Second)).
+ WithPostMiddlewares(middlewares.NewRateLimit(config.Server.Endpoints.RateLimits.SessionElevationFinish), middlewares.Require1FA).
+ Build()
r.GET("/api/user/session/elevation", middleware1FA(handlers.UserSessionElevationGET))
- r.POST("/api/user/session/elevation", middleware1FA(handlers.UserSessionElevationPOST))
- r.PUT("/api/user/session/elevation", middlewareDelaySecond(middleware1FA(handlers.UserSessionElevationPUT)))
+ r.POST("/api/user/session/elevation", middlewareElevatePOST(handlers.UserSessionElevationPOST))
+ r.PUT("/api/user/session/elevation", middlewareElevatePUT(handlers.UserSessionElevationPUT))
r.DELETE("/api/user/session/elevation/{id}", middlewareAPI(handlers.UserSessionElevateDELETE))
if !config.TOTP.Disable {
+ middlewareRateLimitTOTP := middlewares.NewBridgeBuilder(*config, providers).
+ WithPreMiddlewares(middlewares.SecurityHeadersBase, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone).
+ WithPostMiddlewares(middlewares.NewRateLimit(config.Server.Endpoints.RateLimits.SecondFactorTOTP), middlewares.Require1FA).
+ Build()
+
// TOTP related endpoints.
r.GET("/api/secondfactor/totp", middleware1FA(handlers.TimeBasedOneTimePasswordGET))
- r.POST("/api/secondfactor/totp", middleware1FA(handlers.TimeBasedOneTimePasswordPOST))
+ r.POST("/api/secondfactor/totp", middlewareRateLimitTOTP(handlers.TimeBasedOneTimePasswordPOST))
r.DELETE("/api/secondfactor/totp", middleware1FA(handlers.TOTPConfigurationDELETE))
r.GET("/api/secondfactor/totp/register", middlewareElevated1FA(handlers.TOTPRegisterGET))
@@ -316,6 +331,7 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
// Configure DUO api endpoint only if configuration exists.
if !config.DuoAPI.Disable {
var duoAPI duo.API
+
if os.Getenv("ENVIRONMENT") == dev {
duoAPI = duo.NewDuoAPI(duoapi.NewDuoApi(
config.DuoAPI.IntegrationKey,
@@ -328,8 +344,13 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
config.DuoAPI.Hostname, ""))
}
+ middlewareRateLimitDuo := middlewares.NewBridgeBuilder(*config, providers).
+ WithPreMiddlewares(middlewares.SecurityHeadersBase, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone).
+ WithPostMiddlewares(middlewares.NewRateLimit(config.Server.Endpoints.RateLimits.SecondFactorDuo), middlewares.Require1FA).
+ Build()
+
r.GET("/api/secondfactor/duo_devices", middleware1FA(handlers.DuoDevicesGET(duoAPI)))
- r.POST("/api/secondfactor/duo", middleware1FA(handlers.DuoPOST(duoAPI)))
+ r.POST("/api/secondfactor/duo", middlewareRateLimitDuo(handlers.DuoPOST(duoAPI)))
r.POST("/api/secondfactor/duo_device", middleware1FA(handlers.DuoDevicePOST))
}
diff --git a/internal/server/locales/en/portal.json b/internal/server/locales/en/portal.json
index 9b5d84dcb..264c68a11 100644
--- a/internal/server/locales/en/portal.json
+++ b/internal/server/locales/en/portal.json
@@ -99,6 +99,7 @@
"Username": "Username",
"Username is required": "Username is required",
"You cancelled the assertion request": "You cancelled the assertion request",
+ "You have made too many requests": "You have made too many requests",
"You must view and accept the Privacy Policy before using": "You must view and accept the <0>Privacy Policy</0> before using",
"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",
diff --git a/internal/suites/ActiveDirectory/configuration.yml b/internal/suites/ActiveDirectory/configuration.yml
index 657bacc2c..9d55e6761 100644
--- a/internal/suites/ActiveDirectory/configuration.yml
+++ b/internal/suites/ActiveDirectory/configuration.yml
@@ -7,6 +7,16 @@ server:
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
+ endpoints:
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
log:
level: 'debug'
diff --git a/internal/suites/Caddy/configuration.yml b/internal/suites/Caddy/configuration.yml
index b88051b5b..f6b75af5c 100644
--- a/internal/suites/Caddy/configuration.yml
+++ b/internal/suites/Caddy/configuration.yml
@@ -16,6 +16,15 @@ server:
caddy:
implementation: 'ForwardAuth'
authn_strategies: []
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
log:
level: 'debug'
diff --git a/internal/suites/Caddy/users.yml b/internal/suites/Caddy/users.yml
index 0de5cdad5..07414d9a3 100644
--- a/internal/suites/Caddy/users.yml
+++ b/internal/suites/Caddy/users.yml
@@ -1,28 +1,28 @@
----
users:
- john:
- displayname: "John Doe"
- password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
- email: john.doe@authelia.com
- groups:
- - admins
- - dev
-
- harry:
- displayname: "Harry Potter"
- password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
- email: harry.potter@authelia.com
- groups: []
-
- bob:
- displayname: "Bob Dylan"
- password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
- email: bob.dylan@authelia.com
- groups:
- - dev
-
- james:
- displayname: "James Dean"
- password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length
- email: james.dean@authelia.com
-...
+ bob:
+ password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
+ displayname: Bob Dylan
+ email: bob.dylan@authelia.com
+ groups:
+ - dev
+ disabled: false
+ harry:
+ password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
+ displayname: Harry Potter
+ email: harry.potter@authelia.com
+ groups: []
+ disabled: false
+ james:
+ password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
+ displayname: James Dean
+ email: james.dean@authelia.com
+ groups: []
+ disabled: false
+ john:
+ password: $argon2id$v=19$m=65536,t=3,p=4$iXZMAhHdjgNaOEZYErSQNA$tDz6aVwWp80xfuTEFOaAOnnpKdu1qn3MR99zP86cyCo
+ displayname: John Doe
+ email: john.doe@authelia.com
+ groups:
+ - admins
+ - dev
+ disabled: false
diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml
index 8f76fee7d..9a60ccdf1 100644
--- a/internal/suites/DuoPush/configuration.yml
+++ b/internal/suites/DuoPush/configuration.yml
@@ -6,6 +6,14 @@ server:
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
+ endpoints:
+ rate_limits:
+ second_factor_duo:
+ buckets:
+ - period: '20 seconds'
+ requests: 20
+ - period: '30 seconds'
+ requests: 30
log:
level: 'trace'
diff --git a/internal/suites/Envoy/configuration.yml b/internal/suites/Envoy/configuration.yml
index 4ef66a80a..b2f0cd4d9 100644
--- a/internal/suites/Envoy/configuration.yml
+++ b/internal/suites/Envoy/configuration.yml
@@ -12,6 +12,15 @@ server:
ext-authz:
implementation: 'ExtAuthz'
authn_strategies: []
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
log:
level: 'debug'
diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml
index 72876c0b5..dd13ff94b 100644
--- a/internal/suites/LDAP/configuration.yml
+++ b/internal/suites/LDAP/configuration.yml
@@ -7,6 +7,16 @@ server:
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
+ endpoints:
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
log:
level: 'debug'
diff --git a/internal/suites/PathPrefix/configuration.yml b/internal/suites/PathPrefix/configuration.yml
index aa33b28f0..30fdf7bc8 100644
--- a/internal/suites/PathPrefix/configuration.yml
+++ b/internal/suites/PathPrefix/configuration.yml
@@ -6,6 +6,16 @@ server:
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
+ endpoints:
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
log:
level: 'debug'
diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml
index d69e21321..e8c3ba6f2 100644
--- a/internal/suites/Standalone/configuration.yml
+++ b/internal/suites/Standalone/configuration.yml
@@ -7,6 +7,28 @@ server:
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
+ endpoints:
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ session_elevation_start:
+ buckets:
+ - period: '20 seconds'
+ requests: 20
+ - period: '30 seconds'
+ requests: 30
+ session_elevation_finish:
+ buckets:
+ - period: '20 seconds'
+ requests: 20
+ - period: '30 seconds'
+ requests: 30
telemetry:
metrics:
diff --git a/internal/suites/Traefik/configuration.yml b/internal/suites/Traefik/configuration.yml
index 40f26bbb3..d49fcfd07 100644
--- a/internal/suites/Traefik/configuration.yml
+++ b/internal/suites/Traefik/configuration.yml
@@ -12,6 +12,15 @@ server:
forward-auth:
implementation: 'ForwardAuth'
authn_strategies: []
+ rate_limits:
+ reset_password_start:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
+ reset_password_finish:
+ buckets:
+ - period: '2 minutes'
+ requests: 20
log:
level: 'debug'
diff --git a/internal/suites/TwoFactor/configuration.yml b/internal/suites/TwoFactor/configuration.yml
index 53edde0a5..815e1efbc 100644
--- a/internal/suites/TwoFactor/configuration.yml
+++ b/internal/suites/TwoFactor/configuration.yml
@@ -7,6 +7,24 @@ server:
tls:
certificate: '/pki/public.backend.crt'
key: '/pki/private.backend.pem'
+ endpoints:
+ rate_limits:
+ second_factor_totp:
+ buckets:
+ - period: '2 minutes'
+ requests: 50
+ session_elevation_start:
+ buckets:
+ - period: '20 seconds'
+ requests: 20
+ - period: '30 seconds'
+ requests: 30
+ session_elevation_finish:
+ buckets:
+ - period: '20 seconds'
+ requests: 20
+ - period: '30 seconds'
+ requests: 30
telemetry:
metrics:
diff --git a/web/src/components/ComponentWithTooltip.tsx b/web/src/components/ComponentWithTooltip.tsx
new file mode 100644
index 000000000..4c6c8c8ec
--- /dev/null
+++ b/web/src/components/ComponentWithTooltip.tsx
@@ -0,0 +1,28 @@
+import React, { Fragment } from "react";
+
+import { Tooltip } from "@mui/material";
+import { TooltipProps } from "@mui/material/Tooltip";
+
+export interface Props extends TooltipProps {
+ render: boolean;
+}
+
+interface ComponentProps extends Omit<Props, "render"> {}
+
+const ComponentWithTooltip = function (props: Props): React.JSX.Element {
+ const tooltipProps = props as ComponentProps;
+
+ return (
+ <Fragment>
+ {props.render ? (
+ <Tooltip {...tooltipProps}>
+ <span>{props.children}</span>
+ </Tooltip>
+ ) : (
+ props.children
+ )}
+ </Fragment>
+ );
+};
+
+export default ComponentWithTooltip;
diff --git a/web/src/components/TypographyWithTooltip.tsx b/web/src/components/TypographyWithTooltip.tsx
index 340a261c7..651aced32 100644
--- a/web/src/components/TypographyWithTooltip.tsx
+++ b/web/src/components/TypographyWithTooltip.tsx
@@ -11,7 +11,7 @@ export interface Props {
tooltip?: string;
}
-const TypographyWithTooltip = function (props: Props): JSX.Element {
+const TypographyWithTooltip = function (props: Props): React.JSX.Element {
return (
<Fragment>
{props.tooltip ? (
diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts
index 65bf01376..a6f818013 100644
--- a/web/src/services/Api.ts
+++ b/web/src/services/Api.ts
@@ -52,6 +52,11 @@ export interface ErrorResponse {
message: string;
}
+export interface ErrorRateLimitResponse extends ErrorResponse {
+ limited: boolean;
+ retryAfter: number;
+}
+
export interface Response<T> extends OKResponse {
data: T;
}
@@ -73,6 +78,7 @@ function toErrorResponse<T>(resp: AxiosResponse<ServiceResponse<T>>): ErrorRespo
if (resp.data && "status" in resp.data && resp.data["status"] === "KO") {
return resp.data as ErrorResponse;
}
+
return undefined;
}
@@ -80,17 +86,76 @@ export function toData<T>(resp: AxiosResponse<ServiceResponse<T>>): T | undefine
if (resp.data && "status" in resp.data && resp.data["status"] === "OK") {
return resp.data.data as T;
}
+
return undefined;
}
-export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
- const errResp = toErrorResponse(resp);
- if (errResp && errResp.status === "KO") {
- return { errored: true, message: errResp.message };
+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 = parseFloat(valueRetryAfter);
+
+ if (Number.isNaN(retryAfter)) {
+ throw new Error("Header Retry-After has an invalid number value");
+ }
+
+ return retryAfter;
+ } else {
+ const date = new Date(valueRetryAfter);
+ if (isNaN(date.getTime())) {
+ throw new Error("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 { limited: false, retryAfter: 0, data: resp.data.data as T };
+ } 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 && err.status === "KO") {
+ return { errored: true, message: err.message };
}
+
return { errored: false, message: null };
}
+export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
+ const errResp = toErrorResponse(resp);
+
+ 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;
}
diff --git a/web/src/services/Client.ts b/web/src/services/Client.ts
index 83d82f9ac..7668d428a 100644
--- a/web/src/services/Client.ts
+++ b/web/src/services/Client.ts
@@ -1,6 +1,13 @@
import axios from "axios";
-import { ServiceResponse, hasServiceError, toData } from "@services/Api";
+import {
+ RateLimitedData,
+ ServiceResponse,
+ hasServiceError,
+ toData,
+ toDataRateLimited,
+ validateStatusTooManyRequests,
+} from "@services/Api";
export async function PutWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
const res = await axios.put<ServiceResponse<T>>(path, body);
@@ -22,6 +29,25 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body
return toData<T>(res);
}
+export async function PostWithOptionalResponseRateLimited<T = undefined>(
+ path: string,
+ body?: any,
+): Promise<RateLimitedData<T> | undefined> {
+ const res = await axios.post<ServiceResponse<T>>(path, body, {
+ 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): Promise<T | undefined> {
const res = await axios.delete<ServiceResponse<T>>(path, body);
diff --git a/web/src/services/OneTimePassword.ts b/web/src/services/OneTimePassword.ts
index 110821aba..2b4796d2d 100644
--- a/web/src/services/OneTimePassword.ts
+++ b/web/src/services/OneTimePassword.ts
@@ -1,5 +1,9 @@
import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api";
-import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
+import {
+ DeleteWithOptionalResponse,
+ PostWithOptionalResponse,
+ PostWithOptionalResponseRateLimited,
+} from "@services/Client";
import { SignInResponse } from "@services/SignIn";
interface CompleteTOTPSignInBody {
@@ -17,7 +21,7 @@ export function completeTOTPSignIn(passcode: string, targetURL?: string, workflo
workflowID: workflowID,
};
- return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
+ return PostWithOptionalResponseRateLimited<SignInResponse>(CompleteTOTPSignInPath, body);
}
export function completeTOTPRegister(passcode: string) {
diff --git a/web/src/services/PushNotification.ts b/web/src/services/PushNotification.ts
index c24b08a50..c221efe40 100644
--- a/web/src/services/PushNotification.ts
+++ b/web/src/services/PushNotification.ts
@@ -3,7 +3,7 @@ import {
CompletePushNotificationSignInPath,
InitiateDuoDeviceSelectionPath,
} from "@services/Api";
-import { Get, PostWithOptionalResponse } from "@services/Client";
+import { Get, PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client";
interface CompletePushSignInBody {
targetURL?: string;
@@ -18,7 +18,7 @@ export function completePushNotificationSignIn(targetURL?: string, workflow?: st
workflowID: workflowID,
};
- return PostWithOptionalResponse<DuoSignInResponse>(CompletePushNotificationSignInPath, body);
+ return PostWithOptionalResponseRateLimited<DuoSignInResponse>(CompletePushNotificationSignInPath, body);
}
export interface DuoSignInResponse {
diff --git a/web/src/services/ResetPassword.ts b/web/src/services/ResetPassword.ts
index 5813c11b5..e7ccfe348 100644
--- a/web/src/services/ResetPassword.ts
+++ b/web/src/services/ResetPassword.ts
@@ -6,15 +6,16 @@ import {
InitiateResetPasswordPath,
OKResponse,
ResetPasswordPath,
+ validateStatusTooManyRequests,
} from "@services/Api";
-import { PostWithOptionalResponse } from "@services/Client";
+import { PostWithOptionalResponse, PostWithOptionalResponseRateLimited } from "@services/Client.ts";
export async function initiateResetPasswordProcess(username: string) {
- return PostWithOptionalResponse(InitiateResetPasswordPath, { username });
+ return PostWithOptionalResponseRateLimited(InitiateResetPasswordPath, { username });
}
export async function completeResetPasswordProcess(token: string) {
- return PostWithOptionalResponse(CompleteResetPasswordPath, { token });
+ return PostWithOptionalResponseRateLimited(CompleteResetPasswordPath, { token });
}
export async function resetPassword(newPassword: string) {
@@ -26,7 +27,8 @@ export async function deleteResetPasswordToken(token: string) {
method: "DELETE",
url: ResetPasswordPath,
data: { token: token },
+ validateStatus: validateStatusTooManyRequests,
});
- return res.status === 200 && res.data.status === "OK";
+ return { ok: res.status === 200 && res.data.status === "OK", status: res.status };
}
diff --git a/web/src/services/UserInfoTOTPConfiguration.ts b/web/src/services/UserInfoTOTPConfiguration.ts
index e25e016e6..5bfd6a260 100644
--- a/web/src/services/UserInfoTOTPConfiguration.ts
+++ b/web/src/services/UserInfoTOTPConfiguration.ts
@@ -80,7 +80,7 @@ export async function getTOTPOptions(): Promise<TOTPOptions> {
}
export async function deleteUserTOTPConfiguration() {
- return await axios<AuthenticationOKResponse>({
+ return axios<AuthenticationOKResponse>({
method: "DELETE",
url: CompleteTOTPSignInPath,
validateStatus: validateStatusAuthentication,
diff --git a/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx b/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx
index 0f046f536..f93466e4f 100644
--- a/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx
@@ -8,7 +8,6 @@ import OtpInput from "react18-input-otp";
import SuccessIcon from "@components/SuccessIcon";
import TimerIcon from "@components/TimerIcon";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
-import { State } from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod";
export interface Props {
passcode: string;
@@ -20,6 +19,14 @@ export interface Props {
onChange: (passcode: string) => void;
}
+export enum State {
+ Idle = 1,
+ InProgress = 2,
+ Success = 3,
+ Failure = 4,
+ RateLimited = 5,
+}
+
const OTPDial = function (props: Props) {
const styles = useStyles();
@@ -31,7 +38,11 @@ const OTPDial = function (props: Props) {
onChange={props.onChange}
value={props.passcode}
numInputs={props.digits}
- isDisabled={props.state === State.InProgress || props.state === State.Success}
+ isDisabled={
+ props.state === State.InProgress ||
+ props.state === State.Success ||
+ props.state === State.RateLimited
+ }
isInputNum
hasErrored={props.state === State.Failure}
autoComplete="one-time-code"
diff --git a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
index 795245e88..6b509d178 100644
--- a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx
@@ -11,14 +11,7 @@ import { completeTOTPSignIn } from "@services/OneTimePassword";
import { AuthenticationLevel } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
-import OTPDial from "@views/LoginPortal/SecondFactor/OTPDial";
-
-export enum State {
- Idle = 1,
- InProgress = 2,
- Success = 3,
- Failure = 4,
-}
+import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial";
export interface Props {
id: string;
@@ -44,6 +37,8 @@ const OneTimePasswordMethod = function (props: Props) {
const onSignInSuccessCallback = useRef(onSignInSuccess).current;
const [resp, fetch, , err] = useUserInfoTOTPConfiguration();
+ const timeoutRateLimit = useRef<NodeJS.Timeout>();
+
useEffect(() => {
if (err) {
console.error(err);
@@ -58,6 +53,28 @@ const OneTimePasswordMethod = function (props: Props) {
}
}, [fetch, props.authenticationLevel, props.registered]);
+ useEffect(() => {
+ return clearTimeout(timeoutRateLimit.current);
+ }, []);
+
+ const handleRateLimited = useCallback(
+ (retryAfter: number) => {
+ if (timeoutRateLimit.current) {
+ clearTimeout(timeoutRateLimit.current);
+ }
+
+ setState(State.RateLimited);
+
+ onSignInErrorCallback(new Error(translate("You have made too many requests")));
+
+ timeoutRateLimit.current = setTimeout(() => {
+ setState(State.Idle);
+ timeoutRateLimit.current = undefined;
+ }, retryAfter * 1000);
+ },
+ [onSignInErrorCallback, translate],
+ );
+
const signInFunc = useCallback(async () => {
if (!props.registered || props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
@@ -72,8 +89,16 @@ const OneTimePasswordMethod = function (props: Props) {
try {
setState(State.InProgress);
const res = await completeTOTPSignIn(passcodeStr, redirectionURL, workflow, workflowID);
- setState(State.Success);
- onSignInSuccessCallback(res ? res.redirect : undefined);
+
+ if (!res) {
+ onSignInErrorCallback(new Error(translate("The One-Time Password might be wrong")));
+ setState(State.Failure);
+ } else if (!res.limited) {
+ setState(State.Success);
+ onSignInSuccessCallback(res && res.data ? res.data.redirect : undefined);
+ } else {
+ handleRateLimited(res.retryAfter);
+ }
} catch (err) {
console.error(err);
onSignInErrorCallback(new Error(translate("The One-Time Password might be wrong")));
@@ -81,16 +106,17 @@ const OneTimePasswordMethod = function (props: Props) {
}
setPasscode("");
}, [
- onSignInErrorCallback,
- onSignInSuccessCallback,
+ props.registered,
+ props.authenticationLevel,
passcode,
+ resp?.digits,
redirectionURL,
workflow,
workflowID,
- resp,
- props.authenticationLevel,
- props.registered,
+ onSignInErrorCallback,
translate,
+ onSignInSuccessCallback,
+ handleRateLimited,
]);
// Set successful state if user is already authenticated.
diff --git a/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx b/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx
index 69c13297d..43278706e 100644
--- a/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/PushNotificationMethod.tsx
@@ -30,6 +30,7 @@ export enum State {
Failure = 3,
Selection = 4,
Enroll = 5,
+ RateLimited = 6,
}
export interface Props {
@@ -57,7 +58,31 @@ const PushNotificationMethod = function (props: Props) {
const onSignInErrorCallback = useRef(onSignInError).current;
const onSignInSuccessCallback = useRef(onSignInSuccess).current;
- const fetchDuoDevicesFunc = useCallback(async () => {
+ const timeoutRateLimit = useRef<NodeJS.Timeout>();
+
+ useEffect(() => {
+ return clearTimeout(timeoutRateLimit.current);
+ }, []);
+
+ const handleRateLimited = useCallback(
+ (retryAfter: number) => {
+ if (timeoutRateLimit.current) {
+ clearTimeout(timeoutRateLimit.current);
+ }
+
+ setState(State.RateLimited);
+
+ onSignInErrorCallback(new Error(translate("You have made too many requests")));
+
+ timeoutRateLimit.current = setTimeout(() => {
+ setState(State.Failure);
+ timeoutRateLimit.current = undefined;
+ }, retryAfter * 1000);
+ },
+ [onSignInErrorCallback, translate],
+ );
+
+ const handleFetchDuoDevices = useCallback(async () => {
try {
const res = await initiateDuoDeviceSelectionProcess();
if (!mounted.current) return;
@@ -91,7 +116,7 @@ const PushNotificationMethod = function (props: Props) {
}
}, [props.duoSelfEnrollment, mounted, onSignInErrorCallback, translate]);
- const signInFunc = useCallback(async () => {
+ const handleSignIn = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
}
@@ -102,32 +127,43 @@ const PushNotificationMethod = function (props: Props) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
- if (res && res.result === "auth") {
- let selectableDevices = [] as SelectableDevice[];
- res.devices.forEach((d) =>
- selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
- );
- setDevices(selectableDevices);
- setState(State.Selection);
- return;
- }
- if (res && res.result === "enroll") {
- onSignInErrorCallback(new Error(translate("No compatible device found")));
- if (res.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.enroll_url);
- setState(State.Enroll);
- return;
- }
- if (res && res.result === "deny") {
- onSignInErrorCallback(new Error(translate("Device selection was denied by Duo policy")));
+ if (res) {
+ if (res.data && !res.limited) {
+ switch (res.data.result) {
+ case "auth":
+ let selectableDevices = [] as SelectableDevice[];
+ res.data.devices.forEach((d) =>
+ selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
+ );
+ setDevices(selectableDevices);
+ setState(State.Selection);
+ break;
+ case "enroll":
+ onSignInErrorCallback(new Error(translate("No compatible device found")));
+ if (res.data.enroll_url && props.duoSelfEnrollment) setEnrollUrl(res.data.enroll_url);
+ setState(State.Enroll);
+ break;
+ case "deny":
+ onSignInErrorCallback(new Error(translate("Device selection was denied by Duo policy")));
+ setState(State.Failure);
+ break;
+ default:
+ setState(State.Success);
+ setTimeout(() => {
+ if (!mounted.current) return;
+ onSignInSuccessCallback(res.data ? res.data.redirect : undefined);
+ }, 1500);
+ }
+ } else if (res.limited) {
+ handleRateLimited(res.retryAfter);
+ } else {
+ onSignInErrorCallback(new Error(translate("There was an issue completing sign in process")));
+ setState(State.Failure);
+ }
+ } else {
+ onSignInErrorCallback(new Error(translate("There was an issue completing sign in process")));
setState(State.Failure);
- return;
}
-
- setState(State.Success);
- setTimeout(() => {
- if (!mounted.current) return;
- onSignInSuccessCallback(res ? res.redirect : undefined);
- }, 1500);
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
@@ -145,9 +181,10 @@ const PushNotificationMethod = function (props: Props) {
workflowID,
mounted,
onSignInErrorCallback,
+ translate,
onSignInSuccessCallback,
+ handleRateLimited,
state,
- translate,
]);
const updateDuoDevice = useCallback(
@@ -183,8 +220,8 @@ const PushNotificationMethod = function (props: Props) {
}, [props.authenticationLevel, setState]);
useEffect(() => {
- if (state === State.SignInInProgress) signInFunc();
- }, [signInFunc, state]);
+ if (state === State.SignInInProgress) handleSignIn();
+ }, [handleSignIn, state]);
if (state === State.Selection)
return (
@@ -222,12 +259,12 @@ const PushNotificationMethod = function (props: Props) {
duoSelfEnrollment={enroll_url ? props.duoSelfEnrollment : false}
registered={props.registered}
state={methodState}
- onSelectClick={fetchDuoDevicesFunc}
+ onSelectClick={handleFetchDuoDevices}
onRegisterClick={() => window.open(enroll_url, "_blank")}
>
<div className={styles.icon}>{icon}</div>
<div className={state !== State.Failure ? "hidden" : ""}>
- <Button color="secondary" onClick={signInFunc}>
+ <Button color="secondary" onClick={handleSignIn}>
{translate("Retry")}
</Button>
</div>
diff --git a/web/src/views/ResetPassword/ResetPasswordStep1.tsx b/web/src/views/ResetPassword/ResetPasswordStep1.tsx
index 383bce14e..899968400 100644
--- a/web/src/views/ResetPassword/ResetPasswordStep1.tsx
+++ b/web/src/views/ResetPassword/ResetPasswordStep1.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button, CircularProgress, FormControl, Theme } from "@mui/material";
import Grid from "@mui/material/Grid2";
@@ -7,6 +7,7 @@ import makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
+import ComponentWithTooltip from "@components/ComponentWithTooltip";
import { IndexRoute } from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext";
import MinimalLayout from "@layouts/MinimalLayout";
@@ -17,10 +18,36 @@ const ResetPasswordStep1 = function () {
const [username, setUsername] = useState("");
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
+
+ const [rateLimited, setRateLimited] = useState(false);
+ const timeoutRateLimit = useRef<NodeJS.Timeout>();
+
const { createInfoNotification, createErrorNotification } = useNotifications();
const navigate = useNavigate();
const { t: translate } = useTranslation();
+ useEffect(() => {
+ return clearTimeout(timeoutRateLimit.current);
+ }, []);
+
+ const handleRateLimited = useCallback(
+ (retryAfter: number) => {
+ if (timeoutRateLimit.current) {
+ clearTimeout(timeoutRateLimit.current);
+ }
+
+ setRateLimited(true);
+
+ createErrorNotification(translate("You have made too many requests"));
+
+ timeoutRateLimit.current = setTimeout(() => {
+ setRateLimited(false);
+ timeoutRateLimit.current = undefined;
+ }, retryAfter * 1000);
+ },
+ [createErrorNotification, translate],
+ );
+
const doInitiateResetPasswordProcess = async () => {
setError(false);
setLoading(true);
@@ -33,8 +60,15 @@ const ResetPasswordStep1 = function () {
}
try {
- await initiateResetPasswordProcess(username);
- createInfoNotification(translate("An email has been sent to your address to complete the process"));
+ const response = await initiateResetPasswordProcess(username);
+ if (response && !response.limited) {
+ createInfoNotification(translate("An email has been sent to your address to complete the process"));
+ navigate(IndexRoute);
+ } else if (response && response.limited) {
+ handleRateLimited(response.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"));
}
@@ -65,24 +99,29 @@ const ResetPasswordStep1 = function () {
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
- doInitiateResetPasswordProcess();
ev.preventDefault();
+ doInitiateResetPasswordProcess();
}
}}
/>
</Grid>
<Grid size={{ xs: 6 }}>
- <Button
- id="reset-button"
- variant="contained"
- disabled={loading}
- color="primary"
- fullWidth
- onClick={handleResetClick}
- startIcon={loading ? <CircularProgress color="inherit" size={20} /> : <></>}
+ <ComponentWithTooltip
+ render={rateLimited}
+ title={translate(translate("You have made too many requests"))}
>
- {translate("Reset")}
- </Button>
+ <Button
+ id="reset-button"
+ variant="contained"
+ disabled={loading || rateLimited}
+ color="primary"
+ fullWidth
+ onClick={handleResetClick}
+ startIcon={loading ? <CircularProgress color="inherit" size={20} /> : <></>}
+ >
+ {translate("Reset")}
+ </Button>
+ </ComponentWithTooltip>
</Grid>
<Grid size={{ xs: 6 }}>
<Button
diff --git a/web/src/views/ResetPassword/ResetPasswordStep2.tsx b/web/src/views/ResetPassword/ResetPasswordStep2.tsx
index d6766cb45..3aaf8767d 100644
--- a/web/src/views/ResetPassword/ResetPasswordStep2.tsx
+++ b/web/src/views/ResetPassword/ResetPasswordStep2.tsx
@@ -46,7 +46,14 @@ const ResetPasswordStep2 = function () {
// the secret for OTP.
const processToken = useQueryParam(IdentityToken);
- const completeProcess = useCallback(async () => {
+ const handleRateLimited = useCallback(
+ (retryAfter: number) => {
+ createErrorNotification(translate("You have made too many requests"));
+ },
+ [createErrorNotification, translate],
+ );
+
+ const handleSubmitReset = useCallback(async () => {
if (!processToken) {
setFormDisabled(true);
createErrorNotification(translate("No verification token provided"));
@@ -55,7 +62,15 @@ const ResetPasswordStep2 = function () {
try {
setFormDisabled(true);
- await completeResetPasswordProcess(processToken);
+
+ const response = await completeResetPasswordProcess(processToken);
+
+ if (response && response.limited) {
+ handleRateLimited(response.retryAfter);
+
+ return;
+ }
+
const policy = await getPasswordPolicyConfiguration();
setPPolicy(policy);
setFormDisabled(false);
@@ -66,13 +81,16 @@ const ResetPasswordStep2 = function () {
);
setFormDisabled(true);
}
- }, [processToken, createErrorNotification, translate]);
+ }, [processToken, createErrorNotification, translate, handleRateLimited]);
useEffect(() => {
- completeProcess();
- }, [completeProcess]);
+ handleSubmitReset();
+ }, [handleSubmitReset]);
const doResetPassword = async () => {
+ setPassword1("");
+ setPassword2("");
+
if (password1 === "" || password2 === "") {
if (password1 === "") {
setErrorPassword1(true);
@@ -82,6 +100,7 @@ const ResetPasswordStep2 = function () {
}
return;
}
+
if (password1 !== password2) {
setErrorPassword1(true);
setErrorPassword2(true);
@@ -89,11 +108,13 @@ const ResetPasswordStep2 = function () {
return;
}
+ setFormDisabled(true);
+
try {
await resetPassword(password1);
+
createSuccessNotification(translate("Password has been reset"));
setTimeout(() => navigate(IndexRoute), 1500);
- setFormDisabled(true);
} catch (err) {
console.error(err);
if ((err as Error).message.includes("0000052D.")) {
diff --git a/web/src/views/Revoke/RevokeResetPasswordTokenView.tsx b/web/src/views/Revoke/RevokeResetPasswordTokenView.tsx
index e5ccc96ca..c035606e9 100644
--- a/web/src/views/Revoke/RevokeResetPasswordTokenView.tsx
+++ b/web/src/views/Revoke/RevokeResetPasswordTokenView.tsx
@@ -25,10 +25,12 @@ const RevokeResetPasswordTokenView = function () {
const handleRevoke = useCallback(async () => {
if (!token) return;
- const ok = await deleteResetPasswordToken(token);
+ const { ok, status } = 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"));
}
diff --git a/web/src/views/Settings/Common/SecondFactorMethodMobilePush.tsx b/web/src/views/Settings/Common/SecondFactorMethodMobilePush.tsx
index d749e08fd..636533686 100644
--- a/web/src/views/Settings/Common/SecondFactorMethodMobilePush.tsx
+++ b/web/src/views/Settings/Common/SecondFactorMethodMobilePush.tsx
@@ -1,4 +1,4 @@
-import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react";
+import React, { Fragment, ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, Link, Theme } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import FailureIcon from "@components/FailureIcon";
import PushNotificationIcon from "@components/PushNotificationIcon";
import { useIsMountedRef } from "@hooks/Mounted";
+import { useNotifications } from "@hooks/NotificationsContext";
import {
DuoDevicePostRequest,
completeDuoDeviceSelectionProcess,
@@ -23,6 +24,7 @@ export enum State {
Success = 2,
Failure = 3,
Selection = 4,
+ RateLimited = 5,
}
export interface Props {
@@ -38,6 +40,32 @@ const SecondFactorMethodMobilePush = function (props: Props) {
const mounted = useIsMountedRef();
const [devices, setDevices] = useState([] as SelectableDevice[]);
+ const { createErrorNotification } = useNotifications();
+
+ const timeoutRateLimit = useRef<NodeJS.Timeout>();
+
+ useEffect(() => {
+ return clearTimeout(timeoutRateLimit.current);
+ }, []);
+
+ const handleRateLimited = useCallback(
+ (retryAfter: number) => {
+ if (timeoutRateLimit.current) {
+ clearTimeout(timeoutRateLimit.current);
+ }
+
+ setState(State.RateLimited);
+
+ createErrorNotification(translate("You have made too many requests"));
+
+ timeoutRateLimit.current = setTimeout(() => {
+ setState(State.Failure);
+ timeoutRateLimit.current = undefined;
+ }, retryAfter * 1000);
+ },
+ [createErrorNotification, translate],
+ );
+
const handleSelectDevice = useCallback(async () => {
try {
const res = await initiateDuoDeviceSelectionProcess();
@@ -52,24 +80,24 @@ const SecondFactorMethodMobilePush = function (props: Props) {
setState(State.Selection);
break;
case "allow":
- console.error(new Error(translate("Device selection was bypassed by Duo policy")));
+ createErrorNotification(translate("Device selection was bypassed by Duo policy"));
setState(State.Success);
break;
case "deny":
- console.error(new Error(translate("Device selection was denied by Duo policy")));
+ createErrorNotification(translate("Device selection was denied by Duo policy"));
setState(State.Failure);
break;
case "enroll":
- console.error(new Error(translate("No compatible device found")));
+ createErrorNotification(translate("No compatible device found"));
setState(State.Failure);
break;
}
} catch (err) {
if (!mounted.current) return;
console.error(err);
- console.error(new Error(translate("There was an issue fetching Duo device(s)")));
+ createErrorNotification(translate("There was an issue fetching Duo device(s)"));
}
- }, [mounted, translate]);
+ }, [createErrorNotification, mounted, translate]);
const handleDuoPush = useCallback(async () => {
try {
@@ -78,39 +106,51 @@ const SecondFactorMethodMobilePush = function (props: Props) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
+
if (res) {
- switch (res.result) {
- case "auth":
- let selectableDevices = [] as SelectableDevice[];
- res.devices.forEach((d) =>
- selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
- );
- setDevices(selectableDevices);
- setState(State.Selection);
- return;
- case "enroll":
- console.error(new Error(translate("No compatible device found")));
- setState(State.Failure);
- return;
- case "deny":
- console.error(new Error(translate("Device selection was denied by Duo policy")));
- setState(State.Failure);
- return;
+ if (res.data && !res.limited) {
+ switch (res.data.result) {
+ case "auth":
+ let selectableDevices = [] as SelectableDevice[];
+ res.data.devices.forEach((d) =>
+ selectableDevices.push({ id: d.device, name: d.display_name, methods: d.capabilities }),
+ );
+ setDevices(selectableDevices);
+ setState(State.Selection);
+ break;
+ case "enroll":
+ createErrorNotification(translate("No compatible device found"));
+ setState(State.Failure);
+ break;
+ case "deny":
+ createErrorNotification(translate("Device selection was denied by Duo policy"));
+ setState(State.Failure);
+ break;
+ default:
+ setState(State.Success);
+ props.onSecondFactorSuccess();
+ break;
+ }
+ } else if (res.limited) {
+ handleRateLimited(res.retryAfter);
+ } else {
+ createErrorNotification(translate("There was an issue completing sign in process"));
+ setState(State.Failure);
}
+ } else {
+ createErrorNotification(translate("There was an issue completing sign in process"));
+ setState(State.Failure);
}
-
- setState(State.Success);
- props.onSecondFactorSuccess();
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current || state !== State.SignInInProgress) return;
console.error(err);
- console.error(new Error(translate("There was an issue completing sign in process")));
+ createErrorNotification(translate("There was an issue completing sign in process"));
setState(State.Failure);
}
- }, [mounted, props, state, translate]);
+ }, [createErrorNotification, handleRateLimited, mounted, props, state, translate]);
const updateDuoDevice = useCallback(
async function (device: DuoDevicePostRequest) {
diff --git a/web/src/views/Settings/Common/SecondFactorMethodOneTimePassword.tsx b/web/src/views/Settings/Common/SecondFactorMethodOneTimePassword.tsx
index 3fb51f396..4e769c680 100644
--- a/web/src/views/Settings/Common/SecondFactorMethodOneTimePassword.tsx
+++ b/web/src/views/Settings/Common/SecondFactorMethodOneTimePassword.tsx
@@ -1,18 +1,12 @@
-import React, { Fragment, useCallback, useEffect, useState } from "react";
+import React, { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useNotifications } from "@hooks/NotificationsContext";
import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration";
import { completeTOTPSignIn } from "@services/OneTimePassword";
import LoadingPage from "@views/LoadingPage/LoadingPage";
-import OTPDial from "@views/LoginPortal/SecondFactor/OTPDial";
-
-export enum State {
- Idle = 1,
- InProgress = 2,
- Success = 3,
- Failure = 4,
-}
+import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial";
export interface Props {
closing: boolean;
@@ -24,9 +18,12 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
const [passcode, setPasscode] = useState("");
const [state, setState] = useState(State.Idle);
+ const { createErrorNotification } = useNotifications();
const [config, fetchConfig, , fetchConfigError] = useUserInfoTOTPConfiguration();
+ const timeoutRateLimit = useRef<NodeJS.Timeout>();
+
useEffect(() => {
if (fetchConfigError) {
console.error(fetchConfigError);
@@ -38,6 +35,28 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
fetchConfig();
}, [fetchConfig]);
+ useEffect(() => {
+ return clearTimeout(timeoutRateLimit.current);
+ }, []);
+
+ const handleRateLimited = useCallback(
+ (retryAfter: number) => {
+ if (timeoutRateLimit.current) {
+ clearTimeout(timeoutRateLimit.current);
+ }
+
+ setState(State.RateLimited);
+
+ createErrorNotification(translate("You have made too many requests"));
+
+ timeoutRateLimit.current = setTimeout(() => {
+ setState(State.Idle);
+ timeoutRateLimit.current = undefined;
+ }, retryAfter * 1000);
+ },
+ [createErrorNotification, translate],
+ );
+
const handleSignIn = useCallback(async () => {
const passcodeStr = `${passcode}`;
@@ -50,17 +69,25 @@ const SecondFactorMethodOneTimePassword = function (props: Props) {
try {
setState(State.InProgress);
- await completeTOTPSignIn(passcodeStr);
-
- setState(State.Success);
- props.onSecondFactorSuccess();
+ const res = await completeTOTPSignIn(passcodeStr);
+
+ if (res) {
+ if (!res.limited) {
+ setState(State.Success);
+ } else {
+ handleRateLimited(res.retryAfter);
+ }
+ } else {
+ createErrorNotification(translate("The One-Time Password might be wrong"));
+ setState(State.Failure);
+ }
} catch (err) {
console.error(err);
setState(State.Failure);
}
setPasscode("");
- }, [passcode, config, props]);
+ }, [passcode, config, handleRateLimited, createErrorNotification, translate]);
useEffect(() => {
handleSignIn().catch(console.error);
diff --git a/web/src/views/Settings/TwoFactorAuthentication/OneTimePasswordRegisterDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/OneTimePasswordRegisterDialog.tsx
index bf72c93e2..c73a3e13e 100644
--- a/web/src/views/Settings/TwoFactorAuthentication/OneTimePasswordRegisterDialog.tsx
+++ b/web/src/views/Settings/TwoFactorAuthentication/OneTimePasswordRegisterDialog.tsx
@@ -41,8 +41,7 @@ import { toAlgorithmString } from "@models/TOTPConfiguration";
import { completeTOTPRegister, stopTOTPRegister } from "@services/OneTimePassword";
import { getTOTPSecret } from "@services/RegisterDevice";
import { getTOTPOptions } from "@services/UserInfoTOTPConfiguration";
-import { State } from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod";
-import OTPDial from "@views/LoginPortal/SecondFactor/OTPDial";
+import OTPDial, { State } from "@views/LoginPortal/SecondFactor/OTPDial";
const steps = ["Start", "Register", "Confirm"];