refactor(handlers): ppolicy (#3103)

Add tests and makes the password policy a provider so the configuration can be loaded to memory on startup.
pull/2828/head^2
James Elliott 2022-04-03 21:58:27 +10:00 committed by GitHub
parent 0f6ca55016
commit 9e05066097
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 266 additions and 114 deletions

View File

@ -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

View File

@ -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

View File

@ -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
```
@ -51,6 +51,8 @@ Enables standard password policy.
<div markdown="1">
type: integer
{: .label .label-config .label-purple }
default: 8
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
@ -61,6 +63,8 @@ Determines the minimum allowed password length.
<div markdown="1">
type: integer
{: .label .label-config .label-purple }
default: 0
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>

View File

@ -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

View File

@ -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,7 +13,7 @@ type PasswordPolicyStandardParams struct {
// PasswordPolicyZxcvbnParams represents the configuration related to zxcvbn parameters of password policy.
type PasswordPolicyZxcvbnParams struct {
Enabled bool
Enabled bool `koanf:"enabled"`
MinScore int `koanf:"min_score"`
}
@ -29,12 +29,9 @@ var DefaultPasswordPolicyConfiguration = PasswordPolicyConfiguration{
Enabled: false,
MinLength: 8,
MaxLength: 0,
RequireUppercase: true,
RequireLowercase: true,
RequireNumber: true,
RequireSpecial: true,
},
Zxcvbn: PasswordPolicyZxcvbnParams{
Enabled: false,
MinScore: 0,
},
}

View File

@ -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)
}
}

View File

@ -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.

View File

@ -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)))

View File

@ -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",

View File

@ -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}

View File

@ -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(<PasswordMeter value={""} minLength={4} />);
render(
<PasswordMeter
value={""}
policy={{
max_length: 0,
min_length: 4,
min_score: 0,
require_lowercase: false,
require_number: false,
require_special: false,
require_uppercase: false,
mode: PasswordPolicyMode.Standard,
}}
/>,
);
});
it("renders adjusted height without crashing", () => {
render(<PasswordMeter value={"Passw0rd!"} minLength={4} />);
render(
<PasswordMeter
value={"Passw0rd!"}
policy={{
max_length: 0,
min_length: 4,
min_score: 0,
require_lowercase: false,
require_number: false,
require_special: false,
require_uppercase: false,
mode: PasswordPolicyMode.Standard,
}}
/>,
);
});

View File

@ -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 <span></span>;
return (
<div
style={{
width: "100%",
}}
>
<div style={{ width: "100%" }}>
<div
title={feedback}
className={classnames(style.progressBar)}
@ -126,7 +115,7 @@ const PasswordMeter = function (props: Props) {
width: `${(passwordScore + 1) * (100 / maxScores)}%`,
backgroundColor: progressColor[passwordScore],
}}
></div>
/>
</div>
);
};

View File

@ -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;
}

View File

@ -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";

View File

@ -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<PasswordPolicyConfiguration> {
const config = await Get<PasswordPolicyConfigurationPayload>(PasswordPolicyConfigurationPath);
return { ...config, mode: toEnum(config.mode) };
}

View File

@ -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<PasswordPolicyConfiguration>({
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 () {
),
}}
/>
<PasswordMeter
value={password1}
mode={pPolicyMode}
minLength={pPolicyMinLength}
maxLength={pPolicyMaxLength}
requireLowerCase={pPolicyRequireLowerCase}
requireUpperCase={pPolicyRequireUpperCase}
requireNumber={pPolicyRequireNumber}
requireSpecial={pPolicyRequireSpecial}
></PasswordMeter>
{pPolicy.mode === PasswordPolicyMode.Disabled ? null : (
<PasswordMeter value={password1} policy={pPolicy} />
)}
</Grid>
<Grid item xs={12}>
<FixedTextField