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
pull/84057/head
linoman 1 year ago committed by GitHub
parent 7bc8b27c33
commit 8e827afb8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      packages/grafana-data/src/types/config.ts
  2. 3
      pkg/api/dtos/frontend_settings.go
  3. 27
      pkg/api/frontendsettings.go
  4. 123
      public/app/core/components/ValidationLabels/ValidationLabels.tsx
  5. 24
      public/app/features/profile/ChangePasswordForm.tsx
  6. 3
      public/locales/en-US/grafana.json
  7. 3
      public/locales/pseudo-LOCALE/grafana.json

@ -264,4 +264,5 @@ export interface AuthSettings {
GenericOAuthSkipOrgRoleSync?: boolean;
disableLogin?: boolean;
basicAuthStrongPasswordPolicy?: boolean;
}

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

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

@ -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 (
<Box marginBottom={2}>
{strongPasswordValidations.map((validation) => (
<ValidationLabel
key={validation.message}
strongPasswordValidation={validation}
password={password}
pristine={pristine}
/>
))}
</Box>
);
};
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 (
<Box key={message} display={'flex'} alignItems={'center'} marginTop={1}>
<Icon className={cx(styles.icon.style, iconClassName)} name={iconName} />
<Text color={textColor}>{message}</Text>
</Box>
);
};
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,
}),
},
};
};

@ -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)
<PasswordField
id="new-password"
autoComplete="new-password"
onFocus={() => 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)
})}
/>
</Field>
{displayValidationLabels && (
<ValidationLabels
pristine={pristine}
password={newPassword}
strongPasswordValidations={strongPasswordValidations}
/>
)}
<Field
label={t('profile.change-password.confirm-password-label', 'Confirm password')}
invalid={!!errors.confirmNew}

@ -1208,7 +1208,8 @@
"new-password-same-as-old": "New password can't be the same as the old one.",
"old-password-label": "Old password",
"old-password-required": "Old password is required",
"passwords-must-match": "Passwords must match"
"passwords-must-match": "Passwords must match",
"strong-password-validation-register": "Password does not comply with the strong password policy"
}
},
"public-dashboard": {

@ -1208,7 +1208,8 @@
"new-password-same-as-old": "Ńęŵ päşşŵőřđ čäʼn'ŧ þę ŧĥę şämę äş ŧĥę őľđ őʼnę.",
"old-password-label": "Øľđ päşşŵőřđ",
"old-password-required": "Øľđ päşşŵőřđ įş řęqūįřęđ",
"passwords-must-match": "Päşşŵőřđş mūşŧ mäŧčĥ"
"passwords-must-match": "Päşşŵőřđş mūşŧ mäŧčĥ",
"strong-password-validation-register": "Päşşŵőřđ đőęş ʼnőŧ čőmpľy ŵįŧĥ ŧĥę şŧřőʼnģ päşşŵőřđ pőľįčy"
}
},
"public-dashboard": {

Loading…
Cancel
Save