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 && (
+
+ )}