diff --git a/api/openapi.yml b/api/openapi.yml index 35136362d..10ec36b9d 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -44,6 +44,20 @@ paths: description: Forbidden security: - authelia_auth: [] + /api/configuration/password-policy: + get: + tags: + - State + summary: Password Policy Configuration + description: > + The password policy configuration endpoint provides a password policy for resetting passwords. + responses: + "200": + description: Successful Operation + content: + application/json: + schema: + $ref: '#/components/schemas/handlers.configuration.PasswordPolicyConfigurationBody' /api/health: get: tags: @@ -694,6 +708,43 @@ components: - "webauthn" - "mobile_push" example: [totp, webauthn, mobile_push] + handlers.configuration.PasswordPolicyConfigurationBody: + type: object + properties: + status: + type: string + example: OK + data: + type: object + properties: + mode: + type: string + description: The password policy mode. + enum: + - "disabled" + - "standard" + - "zxcvbn" + min_length: + type: integer + description: The minimum password length when using the standard mode. + max_length: + type: integer + description: The maximum password length when using the standard mode. + min_score: + type: integer + description: The minimum password score when using the zxcvbn mode. + require_uppercase: + type: boolean + description: If uppercase characters are required when using the standard mode. + require_lowercase: + type: boolean + description: If uppercase characters are required when using the standard mode. + require_number: + type: boolean + description: If numeric characters are required when using the standard mode. + require_special: + type: boolean + description: If special characters are required when using the standard mode. handlers.DuoDeviceBody: required: - device diff --git a/config.template.yml b/config.template.yml index 04afa858d..1292632ae 100644 --- a/config.template.yml +++ b/config.template.yml @@ -329,16 +329,29 @@ password_policy: ## The standard policy allows you to tune individual settings manually. standard: enabled: false + + ## Require a minimum length for passwords. min_length: 8 + + ## Require a maximum length for passwords. max_length: 0 + + ## Require uppercase characters. require_uppercase: true + + ## Require lowercase characters. require_lowercase: true + + ## Require numeric characters. require_number: true + + ## Require special characters. require_special: true ## zxcvbn is a well known and used password strength algorithm. It does not have tunable settings. zxcvbn: enabled: false + min_score: 0 ## ## Access Control Configuration diff --git a/docs/configuration/password_policy.md b/docs/configuration/password_policy.md index d05b3eb4e..0661d7222 100644 --- a/docs/configuration/password_policy.md +++ b/docs/configuration/password_policy.md @@ -17,10 +17,10 @@ password_policy: enabled: false min_length: 8 max_length: 0 - require_uppercase: true - require_lowercase: true - require_number: true - require_special: true + require_uppercase: false + require_lowercase: false + require_number: false + require_special: false zxcvbn: enabled: false ``` @@ -30,7 +30,7 @@ password_policy: ### standard
type: list -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
@@ -40,7 +40,7 @@ This section allows you to enable standard security policies. #### enabled
type: bool -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
@@ -50,7 +50,9 @@ Enables standard password policy. #### min_length
type: integer -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } +default: 8 +{: .label .label-config .label-blue } required: no {: .label .label-config .label-green }
@@ -60,7 +62,9 @@ Determines the minimum allowed password length. #### max_length
type: integer -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } +default: 0 +{: .label .label-config .label-blue } required: no {: .label .label-config .label-green }
@@ -70,7 +74,7 @@ Determines the maximum allowed password length. #### require_uppercase
type: bool -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
@@ -80,7 +84,7 @@ Indicates that at least one UPPERCASE letter must be provided as part of the pas #### require_lowercase
type: bool -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
@@ -90,7 +94,7 @@ Indicates that at least one lowercase letter must be provided as part of the pas #### require_number
type: bool -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
@@ -100,7 +104,7 @@ Indicates that at least one number must be provided as part of the password. #### require_special
type: bool -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
@@ -117,7 +121,7 @@ password is. #### enabled
type: bool -{: .label .label-config .label-purple } +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 04afa858d..1292632ae 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -329,16 +329,29 @@ password_policy: ## The standard policy allows you to tune individual settings manually. standard: enabled: false + + ## Require a minimum length for passwords. min_length: 8 + + ## Require a maximum length for passwords. max_length: 0 + + ## Require uppercase characters. require_uppercase: true + + ## Require lowercase characters. require_lowercase: true + + ## Require numeric characters. require_number: true + + ## Require special characters. require_special: true ## zxcvbn is a well known and used password strength algorithm. It does not have tunable settings. zxcvbn: enabled: false + min_score: 0 ## ## Access Control Configuration diff --git a/internal/configuration/schema/password_policy.go b/internal/configuration/schema/password_policy.go index 9c24815e3..a87627f0f 100644 --- a/internal/configuration/schema/password_policy.go +++ b/internal/configuration/schema/password_policy.go @@ -2,7 +2,7 @@ package schema // PasswordPolicyStandardParams represents the configuration related to standard parameters of password policy. type PasswordPolicyStandardParams struct { - Enabled bool + Enabled bool `koanf:"enabled"` MinLength int `koanf:"min_length"` MaxLength int `koanf:"max_length"` RequireUppercase bool `koanf:"require_uppercase"` @@ -13,8 +13,8 @@ type PasswordPolicyStandardParams struct { // PasswordPolicyZxcvbnParams represents the configuration related to zxcvbn parameters of password policy. type PasswordPolicyZxcvbnParams struct { - Enabled bool - MinScore int `koanf:"min_score"` + Enabled bool `koanf:"enabled"` + MinScore int `koanf:"min_score"` } // PasswordPolicyConfiguration represents the configuration related to password policy. @@ -26,15 +26,12 @@ type PasswordPolicyConfiguration struct { // DefaultPasswordPolicyConfiguration is the default password policy configuration. var DefaultPasswordPolicyConfiguration = PasswordPolicyConfiguration{ Standard: PasswordPolicyStandardParams{ - Enabled: false, - MinLength: 8, - MaxLength: 0, - RequireUppercase: true, - RequireLowercase: true, - RequireNumber: true, - RequireSpecial: true, + Enabled: false, + MinLength: 8, + MaxLength: 0, }, Zxcvbn: PasswordPolicyZxcvbnParams{ - Enabled: false, + Enabled: false, + MinScore: 0, }, } diff --git a/internal/handlers/handler_configuration_password_policy.go b/internal/handlers/handler_configuration_password_policy.go new file mode 100644 index 000000000..1840a2ad5 --- /dev/null +++ b/internal/handlers/handler_configuration_password_policy.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "github.com/authelia/authelia/v4/internal/middlewares" +) + +// PasswordPolicyConfigurationGet get the password policy configuration. +func PasswordPolicyConfigurationGet(ctx *middlewares.AutheliaCtx) { + policyResponse := PassworPolicyBody{ + Mode: "disabled", + } + + if ctx.Configuration.PasswordPolicy.Standard.Enabled { + policyResponse.Mode = "standard" + policyResponse.MinLength = ctx.Configuration.PasswordPolicy.Standard.MinLength + policyResponse.MaxLength = ctx.Configuration.PasswordPolicy.Standard.MaxLength + policyResponse.RequireLowercase = ctx.Configuration.PasswordPolicy.Standard.RequireLowercase + policyResponse.RequireUppercase = ctx.Configuration.PasswordPolicy.Standard.RequireUppercase + policyResponse.RequireNumber = ctx.Configuration.PasswordPolicy.Standard.RequireNumber + policyResponse.RequireSpecial = ctx.Configuration.PasswordPolicy.Standard.RequireSpecial + } else if ctx.Configuration.PasswordPolicy.Zxcvbn.Enabled { + policyResponse.Mode = "zxcvbn" + policyResponse.MinScore = ctx.Configuration.PasswordPolicy.Zxcvbn.MinScore + } + + var err error + + if err = ctx.SetJSONBody(policyResponse); err != nil { + ctx.Logger.Errorf("Unable to send password Policy: %s", err) + } +} diff --git a/internal/handlers/handler_reset_password_step1.go b/internal/handlers/handler_reset_password_step1.go index b7cc9a352..75cff5f0e 100644 --- a/internal/handlers/handler_reset_password_step1.go +++ b/internal/handlers/handler_reset_password_step1.go @@ -53,28 +53,7 @@ func resetPasswordIdentityFinish(ctx *middlewares.AutheliaCtx, username string) ctx.Logger.Errorf("Unable to clear password reset flag in session for user %s: %s", userSession.Username, err) } - mode := "" - if ctx.Configuration.PasswordPolicy.Standard.Enabled { - mode = "standard" - } else if ctx.Configuration.PasswordPolicy.Zxcvbn.Enabled { - mode = "zxcvbn" - } - - policyResponse := PassworPolicyBody{ - Mode: mode, - MinLength: ctx.Configuration.PasswordPolicy.Standard.MinLength, - MaxLength: ctx.Configuration.PasswordPolicy.Standard.MaxLength, - RequireLowercase: ctx.Configuration.PasswordPolicy.Standard.RequireLowercase, - RequireUppercase: ctx.Configuration.PasswordPolicy.Standard.RequireUppercase, - RequireNumber: ctx.Configuration.PasswordPolicy.Standard.RequireNumber, - RequireSpecial: ctx.Configuration.PasswordPolicy.Standard.RequireSpecial, - MinScore: ctx.Configuration.PasswordPolicy.Zxcvbn.MinScore, - } - - err = ctx.SetJSONBody(policyResponse) - if err != nil { - ctx.Logger.Errorf("Unable to send password Policy: %s", err) - } + ctx.ReplyOK() } // ResetPasswordIdentityFinish the handler for finishing the identity validation. diff --git a/internal/server/server.go b/internal/server/server.go index 204804d14..f5195322c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -65,6 +65,8 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr r.GET("/api/configuration", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.ConfigurationGet))) + r.GET("/api/configuration/password-policy", autheliaMiddleware(handlers.PasswordPolicyConfigurationGet)) + r.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) r.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) diff --git a/web/package.json b/web/package.json index fb26e9b9f..516020586 100644 --- a/web/package.json +++ b/web/package.json @@ -133,6 +133,7 @@ "@types/qrcode.react": "1.0.2", "@types/react": "17.0.43", "@types/react-dom": "17.0.14", + "@types/zxcvbn": "4.4.1", "@typescript-eslint/eslint-plugin": "5.17.0", "@typescript-eslint/parser": "5.17.0", "@vitejs/plugin-react": "1.3.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2b6322fb2..2f2f9dd42 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -16,6 +16,7 @@ specifiers: '@types/qrcode.react': 1.0.2 '@types/react': 17.0.43 '@types/react-dom': 17.0.14 + '@types/zxcvbn': 4.4.1 '@typescript-eslint/eslint-plugin': 5.17.0 '@typescript-eslint/parser': 5.17.0 '@vitejs/plugin-react': 1.3.0 @@ -91,6 +92,7 @@ devDependencies: '@types/qrcode.react': 1.0.2 '@types/react': 17.0.43 '@types/react-dom': 17.0.14 + '@types/zxcvbn': 4.4.1 '@typescript-eslint/eslint-plugin': 5.17.0_4ad50a0fa85b91f236c35644695e4e45 '@typescript-eslint/parser': 5.17.0_typescript@4.6.3 '@vitejs/plugin-react': 1.3.0 @@ -2517,6 +2519,10 @@ packages: '@types/yargs-parser': 21.0.0 dev: true + /@types/zxcvbn/4.4.1: + resolution: {integrity: sha512-3NoqvZC2W5gAC5DZbTpCeJ251vGQmgcWIHQJGq2J240HY6ErQ9aWKkwfoKJlHLx+A83WPNTZ9+3cd2ILxbvr1w==} + dev: true + /@typescript-eslint/eslint-plugin/5.17.0_4ad50a0fa85b91f236c35644695e4e45: resolution: {integrity: sha512-qVstvQilEd89HJk3qcbKt/zZrfBZ+9h2ynpAGlWjWiizA7m/MtLT9RoX6gjtpE500vfIg8jogAkDzdCxbsFASQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} diff --git a/web/src/components/PasswordMeter.test.tsx b/web/src/components/PasswordMeter.test.tsx index e3e3bcba6..790ddd6f3 100644 --- a/web/src/components/PasswordMeter.test.tsx +++ b/web/src/components/PasswordMeter.test.tsx @@ -3,11 +3,40 @@ import React from "react"; import { render } from "@testing-library/react"; import PasswordMeter from "@components/PasswordMeter"; +import { PasswordPolicyMode } from "@models/PasswordPolicy"; it("renders without crashing", () => { - render(); + render( + , + ); }); it("renders adjusted height without crashing", () => { - render(); + render( + , + ); }); diff --git a/web/src/components/PasswordMeter.tsx b/web/src/components/PasswordMeter.tsx index 359df648c..71b5fb740 100644 --- a/web/src/components/PasswordMeter.tsx +++ b/web/src/components/PasswordMeter.tsx @@ -1,24 +1,15 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { makeStyles } from "@material-ui/core"; import classnames from "classnames"; import { useTranslation } from "react-i18next"; import zxcvbn from "zxcvbn"; +import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy"; + export interface Props { value: string; - /** - * mode password meter mode - * classic: classic mode (checks lowercase, uppercase, specials and numbers) - * zxcvbn: uses zxcvbn package to get the password strength - **/ - mode: string; - minLength: number; - maxLength: number; - requireLowerCase: boolean; - requireUpperCase: boolean; - requireNumber: boolean; - requireSpecial: boolean; + policy: PasswordPolicyConfiguration; } const PasswordMeter = function (props: Props) { @@ -39,17 +30,21 @@ const PasswordMeter = function (props: Props) { useEffect(() => { const password = props.value; - if (props.mode === "standard") { + if (props.policy.mode === PasswordPolicyMode.Standard) { //use mode mode setMaxScores(4); - if (password.length < props.minLength) { + if (password.length < props.policy.min_length) { setPasswordScore(0); - setFeedback(translate("Must be at least {{len}} characters in length", { len: props.minLength })); + setFeedback( + translate("Must be at least {{len}} characters in length", { len: props.policy.min_length }), + ); return; } - if (password.length > props.maxLength) { + if (props.policy.max_length !== 0 && password.length > props.policy.max_length) { setPasswordScore(0); - setFeedback(translate("Must not be more than {{len}} characters in length", { len: props.maxLength })); + setFeedback( + translate("Must not be more than {{len}} characters in length", { len: props.policy.max_length }), + ); return; } setFeedback(""); @@ -57,7 +52,7 @@ const PasswordMeter = function (props: Props) { let required = 0; let hits = 0; let warning = ""; - if (props.requireLowerCase) { + if (props.policy.require_lowercase) { required++; const hasLowercase = /[a-z]/.test(password); if (hasLowercase) { @@ -67,7 +62,7 @@ const PasswordMeter = function (props: Props) { } } - if (props.requireUpperCase) { + if (props.policy.require_uppercase) { required++; const hasUppercase = /[A-Z]/.test(password); if (hasUppercase) { @@ -77,7 +72,7 @@ const PasswordMeter = function (props: Props) { } } - if (props.requireNumber) { + if (props.policy.require_number) { required++; const hasNumber = /[0-9]/.test(password); if (hasNumber) { @@ -87,7 +82,7 @@ const PasswordMeter = function (props: Props) { } } - if (props.requireSpecial) { + if (props.policy.require_special) { required++; const hasSpecial = /[^0-9\w]/i.test(password); if (hasSpecial) { @@ -102,7 +97,7 @@ const PasswordMeter = function (props: Props) { setFeedback(translate("The password does not meet the password policy") + ":\n" + warning); } setPasswordScore(score); - } else if (props.mode === "zxcvbn") { + } else if (props.policy.mode === PasswordPolicyMode.ZXCVBN) { //use zxcvbn mode setMaxScores(5); const { score, feedback } = zxcvbn(password); @@ -111,14 +106,8 @@ const PasswordMeter = function (props: Props) { } }, [props, translate]); - if (props.mode === "" || props.mode === "none") return ; - return ( -
+
+ />
); }; diff --git a/web/src/models/PasswordPolicy.ts b/web/src/models/PasswordPolicy.ts new file mode 100644 index 000000000..32bec62f3 --- /dev/null +++ b/web/src/models/PasswordPolicy.ts @@ -0,0 +1,16 @@ +export enum PasswordPolicyMode { + Disabled = 0, + Standard = 1, + ZXCVBN = 2, +} + +export interface PasswordPolicyConfiguration { + mode: PasswordPolicyMode; + min_length: number; + max_length: number; + min_score: number; + require_uppercase: boolean; + require_lowercase: boolean; + require_number: boolean; + require_special: boolean; +} diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 938132d77..03bb4e1ce 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -25,6 +25,7 @@ export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp"; export const InitiateResetPasswordPath = basePath + "/api/reset-password/identity/start"; export const CompleteResetPasswordPath = basePath + "/api/reset-password/identity/finish"; + // Do the password reset during completion. export const ResetPasswordPath = basePath + "/api/reset-password"; export const ChecksSafeRedirectionPath = basePath + "/api/checks/safe-redirection"; @@ -36,6 +37,7 @@ export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method"; export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp"; export const ConfigurationPath = basePath + "/api/configuration"; +export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy"; export interface ErrorResponse { status: "KO"; diff --git a/web/src/services/PasswordPolicyConfiguration.ts b/web/src/services/PasswordPolicyConfiguration.ts new file mode 100644 index 000000000..6b979c74d --- /dev/null +++ b/web/src/services/PasswordPolicyConfiguration.ts @@ -0,0 +1,33 @@ +import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy"; +import { PasswordPolicyConfigurationPath } from "@services/Api"; +import { Get } from "@services/Client"; + +interface PasswordPolicyConfigurationPayload { + mode: ModePasswordPolicy; + min_length: number; + max_length: number; + min_score: number; + require_uppercase: boolean; + require_lowercase: boolean; + require_number: boolean; + require_special: boolean; +} + +export type ModePasswordPolicy = "disabled" | "standard" | "zxcvbn"; + +export function toEnum(method: ModePasswordPolicy): PasswordPolicyMode { + switch (method) { + case "disabled": + return PasswordPolicyMode.Disabled; + case "standard": + return PasswordPolicyMode.Standard; + case "zxcvbn": + return PasswordPolicyMode.ZXCVBN; + } +} + +export async function getPasswordPolicyConfiguration(): Promise { + const config = await Get(PasswordPolicyConfigurationPath); + + return { ...config, mode: toEnum(config.mode) }; +} diff --git a/web/src/views/ResetPassword/ResetPasswordStep2.tsx b/web/src/views/ResetPassword/ResetPasswordStep2.tsx index 12c8cce56..b308bc9d3 100644 --- a/web/src/views/ResetPassword/ResetPasswordStep2.tsx +++ b/web/src/views/ResetPassword/ResetPasswordStep2.tsx @@ -1,6 +1,6 @@ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; -import { Grid, Button, makeStyles, InputAdornment, IconButton } from "@material-ui/core"; +import { Button, Grid, IconButton, InputAdornment, makeStyles } from "@material-ui/core"; import { Visibility, VisibilityOff } from "@material-ui/icons"; import classnames from "classnames"; import { useTranslation } from "react-i18next"; @@ -11,6 +11,8 @@ import PasswordMeter from "@components/PasswordMeter"; import { IndexRoute } from "@constants/Routes"; import { useNotifications } from "@hooks/NotificationsContext"; import LoginLayout from "@layouts/LoginLayout"; +import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy"; +import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration"; import { completeResetPasswordProcess, resetPassword } from "@services/ResetPassword"; import { extractIdentityToken } from "@utils/IdentityToken"; @@ -26,13 +28,17 @@ const ResetPasswordStep2 = function () { const { t: translate } = useTranslation("Portal"); const navigate = useNavigate(); const [showPassword, setShowPassword] = useState(false); - const [pPolicyMode, setPPolicyMode] = useState("none"); - const [pPolicyMinLength, setPPolicyMinLength] = useState(0); - const [pPolicyMaxLength, setPPolicyMaxLength] = useState(0); - const [pPolicyRequireUpperCase, setPPolicyRequireUpperCase] = useState(false); - const [pPolicyRequireLowerCase, setPPolicyRequireLowerCase] = useState(false); - const [pPolicyRequireNumber, setPPolicyRequireNumber] = useState(false); - const [pPolicyRequireSpecial, setPPolicyRequireSpecial] = useState(false); + + const [pPolicy, setPPolicy] = useState({ + max_length: 0, + min_length: 8, + min_score: 0, + require_lowercase: false, + require_number: false, + require_special: false, + require_uppercase: false, + mode: PasswordPolicyMode.Disabled, + }); // Get the token from the query param to give it back to the API when requesting // the secret for OTP. @@ -47,22 +53,9 @@ const ResetPasswordStep2 = function () { try { setFormDisabled(true); - const { - mode, - min_length, - max_length, - require_uppercase, - require_lowercase, - require_number, - require_special, - } = await completeResetPasswordProcess(processToken); - setPPolicyMode(mode); - setPPolicyMinLength(min_length); - setPPolicyMaxLength(max_length); - setPPolicyRequireLowerCase(require_lowercase); - setPPolicyRequireUpperCase(require_uppercase); - setPPolicyRequireNumber(require_number); - setPPolicyRequireSpecial(require_special); + await completeResetPasswordProcess(processToken); + const policy = await getPasswordPolicyConfiguration(); + setPPolicy(policy); setFormDisabled(false); } catch (err) { console.error(err); @@ -144,16 +137,9 @@ const ResetPasswordStep2 = function () { ), }} /> - + {pPolicy.mode === PasswordPolicyMode.Disabled ? null : ( + + )}