From 8e827afb8c65055d412196d05806a565de646b96 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Thu, 7 Mar 2024 01:56:48 -0600 Subject: [PATCH] Password Policy: Validate strong password upon update (#83959) * add drawer for auth settings * add StrongPasswordField component * Add style to different behaviours * update style for component * add componenet to ChangePasswordForm * pass the event handlers to the child component * add style for label container * expose strong password policy config option to front end * enforce password validation with config option --- packages/grafana-data/src/types/config.ts | 1 + pkg/api/dtos/frontend_settings.go | 3 +- pkg/api/frontendsettings.go | 27 ++-- .../ValidationLabels/ValidationLabels.tsx | 123 ++++++++++++++++++ .../features/profile/ChangePasswordForm.tsx | 24 +++- public/locales/en-US/grafana.json | 3 +- public/locales/pseudo-LOCALE/grafana.json | 3 +- 7 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 public/app/core/components/ValidationLabels/ValidationLabels.tsx diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index d5a2c259bae..2a8b2ea205f 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -264,4 +264,5 @@ export interface AuthSettings { GenericOAuthSkipOrgRoleSync?: boolean; disableLogin?: boolean; + basicAuthStrongPasswordPolicy?: boolean; } diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index b739c4e5adf..a6972407c44 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -30,7 +30,8 @@ type FrontendSettingsAuthDTO struct { // Deprecated: this is no longer used and will be removed in Grafana 11 OktaSkipOrgRoleSync bool `json:"OktaSkipOrgRoleSync"` - DisableLogin bool `json:"disableLogin"` + DisableLogin bool `json:"disableLogin"` + BasicAuthStrongPasswordPolicy bool `json:"basicAuthStrongPasswordPolicy"` } type FrontendSettingsBuildInfoDTO struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 1a12e90f6e6..35c43cd2507 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -322,19 +322,20 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro oauthProviders := hs.SocialService.GetOAuthInfoProviders() frontendSettings.Auth = dtos.FrontendSettingsAuthDTO{ - AuthProxyEnableLoginToken: hs.Cfg.AuthProxy.EnableLoginToken, - OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync, - SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync, - LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync, - JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync, - GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]), - GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]), - GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]), - AzureADSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.AzureADProviderName]), - GithubSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitHubProviderName]), - GitLabSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitlabProviderName]), - OktaSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.OktaProviderName]), - DisableLogin: hs.Cfg.DisableLogin, + AuthProxyEnableLoginToken: hs.Cfg.AuthProxy.EnableLoginToken, + OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync, + SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync, + LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync, + JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync, + GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]), + GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]), + GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]), + AzureADSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.AzureADProviderName]), + GithubSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitHubProviderName]), + GitLabSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GitlabProviderName]), + OktaSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.OktaProviderName]), + DisableLogin: hs.Cfg.DisableLogin, + BasicAuthStrongPasswordPolicy: hs.Cfg.BasicAuthStrongPasswordPolicy, } if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() { diff --git a/public/app/core/components/ValidationLabels/ValidationLabels.tsx b/public/app/core/components/ValidationLabels/ValidationLabels.tsx new file mode 100644 index 00000000000..20f50b36708 --- /dev/null +++ b/public/app/core/components/ValidationLabels/ValidationLabels.tsx @@ -0,0 +1,123 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, Icon, Text, useStyles2 } from '@grafana/ui'; +import config from 'app/core/config'; +import { t } from 'app/core/internationalization'; + +interface StrongPasswordValidation { + message: string; + validation: (value: string) => boolean; +} + +export interface ValidationLabelsProps { + strongPasswordValidations: StrongPasswordValidation[]; + password: string; + pristine: boolean; +} + +export interface ValidationLabelProps { + strongPasswordValidation: StrongPasswordValidation; + password: string; + pristine: boolean; +} + +export const strongPasswordValidations: StrongPasswordValidation[] = [ + { + message: 'At least 12 characters', + validation: (value: string) => value.length >= 12, + }, + { + message: 'One uppercase letter', + validation: (value: string) => /[A-Z]+/.test(value), + }, + { + message: 'One lowercase letter', + validation: (value: string) => /[a-z]+/.test(value), + }, + { + message: 'One number', + validation: (value: string) => /[0-9]+/.test(value), + }, + { + message: 'One symbol', + validation: (value: string) => /[\W]/.test(value), + }, +]; + +export const strongPasswordValidationRegister = (value: string) => { + return ( + !config.auth.basicAuthStrongPasswordPolicy || + strongPasswordValidations.every((validation) => validation.validation(value)) || + t( + 'profile.change-password.strong-password-validation-register', + 'Password does not comply with the strong password policy' + ) + ); +}; + +export const ValidationLabels = ({ strongPasswordValidations, password, pristine }: ValidationLabelsProps) => { + return ( + + {strongPasswordValidations.map((validation) => ( + + ))} + + ); +}; + +export const ValidationLabel = ({ strongPasswordValidation, password, pristine }: ValidationLabelProps) => { + const styles = useStyles2(getStyles); + + const { basicAuthStrongPasswordPolicy } = config.auth; + if (!basicAuthStrongPasswordPolicy) { + return null; + } + + const { message, validation } = strongPasswordValidation; + const result = password.length > 0 && validation(password); + + const iconName = result || pristine ? 'check' : 'exclamation-triangle'; + const textColor = result ? 'secondary' : pristine ? 'primary' : 'error'; + + let iconClassName = undefined; + if (result) { + iconClassName = styles.icon.valid; + } else if (pristine) { + iconClassName = styles.icon.pending; + } else { + iconClassName = styles.icon.error; + } + + return ( + + + {message} + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + icon: { + style: css({ + marginRight: theme.spacing(1), + }), + valid: css({ + color: theme.colors.success.text, + }), + pending: css({ + color: theme.colors.secondary.text, + }), + error: css({ + color: theme.colors.error.text, + }), + }, + }; +}; diff --git a/public/app/features/profile/ChangePasswordForm.tsx b/public/app/features/profile/ChangePasswordForm.tsx index 7f63b646ddb..a6321d73271 100644 --- a/public/app/features/profile/ChangePasswordForm.tsx +++ b/public/app/features/profile/ChangePasswordForm.tsx @@ -1,7 +1,12 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { useState } from 'react'; import { Button, Field, Form, HorizontalGroup, LinkButton } from '@grafana/ui'; +import { + ValidationLabels, + strongPasswordValidations, + strongPasswordValidationRegister, +} from 'app/core/components/ValidationLabels/ValidationLabels'; import config from 'app/core/config'; import { t, Trans } from 'app/core/internationalization'; import { UserDTO } from 'app/types'; @@ -17,6 +22,10 @@ export interface Props { } export const ChangePasswordForm = ({ user, onChangePassword, isSaving }: Props) => { + const [displayValidationLabels, setDisplayValidationLabels] = useState(false); + const [pristine, setPristine] = useState(true); + const [newPassword, setNewPassword] = useState(''); + const { disableLoginForm } = config; const authSource = user.authLabels?.length && user.authLabels[0]; @@ -69,9 +78,14 @@ export const ChangePasswordForm = ({ user, onChangePassword, isSaving }: Props) setDisplayValidationLabels(true)} + value={newPassword} {...register('newPassword', { + onBlur: () => setPristine(false), + onChange: (e) => setNewPassword(e.target.value), required: t('profile.change-password.new-password-required', 'New password is required'), validate: { + strongPasswordValidationRegister, confirm: (v) => v === getValues().confirmNew || t('profile.change-password.passwords-must-match', 'Passwords must match'), @@ -85,7 +99,13 @@ export const ChangePasswordForm = ({ user, onChangePassword, isSaving }: Props) })} /> - + {displayValidationLabels && ( + + )}