]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22931 Add password complexity when creating users as admin
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Tue, 3 Sep 2024 13:18:25 +0000 (15:18 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 11 Sep 2024 20:03:48 +0000 (20:03 +0000)
Removing zxcvbn

Confirm Password

Fix CI issues

Fix SQ analysis issue

server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
server/sonar-web/src/main/js/components/common/EmailInput.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 97c1404bf7c6cda3f8a5fcbc17c98d0288e85ff1..58dd60c1e498514816d12635f7d6f7af37b62ce3 100644 (file)
@@ -138,6 +138,7 @@ const ui = {
   userNameInput: byRole('textbox', { name: /name/ }),
   emailInput: byRole('textbox', { name: /email/ }),
   passwordInput: byLabelText(/password/),
+  confirmPasswordInput: byLabelText(/confirm_password/),
   dialogSCMInputs: byRole('textbox', { name: /users.create_user.scm_account/ }),
   dialogSCMInput: (value?: string) =>
     byRole('textbox', { name: `users.create_user.scm_account_${value ? `x.${value}` : 'new'}` }),
@@ -264,7 +265,8 @@ describe('in non managed mode', () => {
 
     await user.type(ui.loginInput.get(), 'Login');
     await user.type(ui.userNameInput.get(), 'Jack');
-    await user.type(ui.passwordInput.get(), 'Password');
+    await user.type(ui.passwordInput.getAll()[0], 'P@ssword12345');
+    await user.type(ui.passwordInput.getAll()[1], 'P@ssword12345');
     // Add SCM account
     expect(ui.dialogSCMInputs.queryAll()).toHaveLength(0);
     await user.click(ui.scmAddButton.get());
@@ -736,7 +738,7 @@ it('accessibility', async () => {
   // user creation dialog should be accessible
   await user.click(await ui.createUserButton.find());
   expect(await ui.dialogCreateUser.find()).toBeInTheDocument();
-  await expect(ui.dialogCreateUser.get()).toHaveNoA11yViolations();
+  await expect(await ui.dialogCreateUser.find()).toHaveNoA11yViolations();
 
   await user.click(ui.cancelButton.get());
 
index 408bf512da04ad635b7b5c16f9f3613a5e1fd7c2..ae546371e94eca3509594319c7de930f7c170154 100644 (file)
@@ -30,6 +30,7 @@ import {
   addGlobalErrorMessage,
 } from 'design-system';
 import * as React from 'react';
+import UserPasswordInput from '../../../components/common/UserPasswordInput';
 import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { parseErrorResponse } from '../../../helpers/request';
@@ -55,7 +56,10 @@ export default function UserForm(props: Props) {
   const [email, setEmail] = React.useState<string>(user?.email ?? '');
   const [login, setLogin] = React.useState<string>(user?.login ?? '');
   const [name, setName] = React.useState<string>(user?.name ?? '');
-  const [password, setPassword] = React.useState<string>('');
+  const [password, setPassword] = React.useState<{ isValid: boolean; value: string }>({
+    value: '',
+    isValid: false,
+  });
   const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts ?? []);
   const [error, setError] = React.useState<string | undefined>(undefined);
 
@@ -88,7 +92,7 @@ export default function UserForm(props: Props) {
         email: email || undefined,
         login,
         name,
-        password,
+        password: password.value,
         scmAccounts,
       },
       { onSuccess: props.onClose, onError: handleError },
@@ -215,17 +219,10 @@ export default function UserForm(props: Props) {
           </FormField>
 
           {!user && (
-            <FormField required label={translate('password')} htmlFor="create-user-password">
-              <InputField
-                autoComplete="off"
-                size="full"
-                id="create-user-password"
-                name="password"
-                onChange={(e) => setPassword(e.currentTarget.value)}
-                type="password"
-                value={password}
-              />
-            </FormField>
+            <UserPasswordInput
+              value={password.value}
+              onChange={(password) => setPassword(password)}
+            />
           )}
 
           <FormField
diff --git a/server/sonar-web/src/main/js/components/common/EmailInput.tsx b/server/sonar-web/src/main/js/components/common/EmailInput.tsx
new file mode 100644 (file)
index 0000000..ff27ace
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { InputField, TextError } from 'design-system';
+import * as React from 'react';
+import isEmail from 'validator/lib/isEmail';
+import { translate } from '../../helpers/l10n';
+import FocusOutHandler from '../controls/FocusOutHandler';
+
+export interface Props {
+  id: string;
+  onChange: (email: { isValid: boolean; value: string }) => void;
+  value: string;
+}
+
+export default function EmailIput(props: Readonly<Props>) {
+  const { id, value, onChange } = props;
+
+  const [isEmailValid, setIsEmailValid] = React.useState(true);
+
+  return (
+    <FocusOutHandler onFocusOut={() => setIsEmailValid(isEmail(value))}>
+      <InputField
+        id={id}
+        size="full"
+        onChange={({ currentTarget }) => {
+          onChange({ value: currentTarget.value, isValid: isEmail(currentTarget.value) });
+        }}
+        type="email"
+        value={value}
+      />
+      {isEmailValid === false && (
+        <TextError className="sw-mt-2" text={translate('user.email.invalid')} />
+      )}
+    </FocusOutHandler>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx b/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx
new file mode 100644 (file)
index 0000000..8f43ca1
--- /dev/null
@@ -0,0 +1,185 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { IconCheck, IconX } from '@sonarsource/echoes-react';
+import {
+  FlagErrorIcon,
+  FlagSuccessIcon,
+  FormField,
+  InputField,
+  LightLabel,
+  TextError,
+  themeColor,
+} from 'design-system';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import FocusOutHandler from '../controls/FocusOutHandler';
+
+const MIN_PASSWORD_LENGTH = 12;
+
+export interface Props {
+  onChange: (password: { isValid: boolean; value: string }) => void;
+  value: string;
+}
+
+export default function UserPasswordInput(props: Readonly<Props>) {
+  const { value, onChange } = props;
+
+  const [isFocused, setIsFocused] = React.useState(false);
+  const [confirmValue, setConfirmValue] = React.useState('');
+
+  const isInvalid = !isFocused && value !== '' && !isPasswordValid(value);
+  const isValid = !isFocused && isPasswordValid(value);
+  const passwordMatch = isPasswordConfirmed(value, confirmValue);
+  const passwordDontMatch = value !== confirmValue && confirmValue !== '';
+
+  return (
+    <>
+      <FocusOutHandler className="sw-flex sw-items-center" onFocusOut={() => setIsFocused(false)}>
+        <FormField required label={translate('password')} htmlFor="create-password">
+          <div className="sw-flex sw-items-center">
+            <InputField
+              isInvalid={isInvalid}
+              isValid={isValid}
+              onFocus={() => setIsFocused(true)}
+              id="create-password"
+              size="full"
+              onChange={({ currentTarget }) => {
+                onChange({
+                  value: currentTarget.value,
+                  isValid:
+                    isPasswordValid(currentTarget.value) &&
+                    isPasswordConfirmed(currentTarget.value, confirmValue),
+                });
+              }}
+              type="password"
+              value={value}
+            />
+            {isInvalid && <FlagErrorIcon className="sw-ml-2" />}
+            {isValid && <FlagSuccessIcon className="sw-ml-2" />}
+          </div>
+        </FormField>
+      </FocusOutHandler>
+      {isInvalid && <TextError className="sw-mt-2" text={translate('user.password.invalid')} />}
+      {isFocused && <PasswordConstraint value={value} />}
+
+      <FormField
+        className="sw-mt-4"
+        required
+        label={translate('confirm_password')}
+        htmlFor="confirm-password"
+      >
+        <div className="sw-flex sw-items-center">
+          <InputField
+            isInvalid={passwordDontMatch}
+            isValid={passwordMatch}
+            onFocus={() => setIsFocused(true)}
+            id="confirm-password"
+            size="full"
+            onChange={({ currentTarget }) => {
+              setConfirmValue(currentTarget.value);
+              onChange({
+                value,
+                isValid:
+                  isPasswordValid(currentTarget.value) &&
+                  isPasswordConfirmed(value, currentTarget.value),
+              });
+            }}
+            type="password"
+            value={confirmValue}
+          />
+          {passwordDontMatch && <FlagErrorIcon className="sw-ml-2" />}
+          {passwordMatch && <FlagSuccessIcon className="sw-ml-2" />}
+        </div>
+        {passwordDontMatch && (
+          <TextError className="sw-mt-2" text={translate('user.password.do_not_match')} />
+        )}
+      </FormField>
+    </>
+  );
+}
+
+function PasswordConstraint({ value }: Readonly<{ value: string }>) {
+  return (
+    <div className="sw-mt-2">
+      <LightLabel>{translate('user.password.conditions')}</LightLabel>
+      <ul className="sw-list-none sw-p-0 sw-mt-1">
+        <Condition
+          condition={contains12Characters(value)}
+          label={translate('user.password.condition.12_characters')}
+        />
+        <Condition
+          condition={containsUppercase(value)}
+          label={translate('user.password.condition.1_upper_case')}
+        />
+        <Condition
+          condition={containsLowercase(value)}
+          label={translate('user.password.condition.1_lower_case')}
+        />
+        <Condition
+          condition={containsDigit(value)}
+          label={translate('user.password.condition.1_number')}
+        />
+        <Condition
+          condition={containsSpecialCharacter(value)}
+          label={translate('user.password.condition.1_special_character')}
+        />
+      </ul>
+    </div>
+  );
+}
+
+function Condition({ condition, label }: Readonly<{ condition: boolean; label: string }>) {
+  return (
+    <li className="sw-mb-1">
+      {condition ? (
+        <SuccessLabel>
+          <IconCheck />
+          {label}
+        </SuccessLabel>
+      ) : (
+        <LightLabel>
+          <IconX />
+          {label}
+        </LightLabel>
+      )}
+    </li>
+  );
+}
+
+const contains12Characters = (password: string) => password.length >= MIN_PASSWORD_LENGTH;
+const containsUppercase = (password: string) => /[A-Z]/.test(password);
+const containsLowercase = (password: string) => /[a-z]/.test(password);
+const containsDigit = (password: string) => /\d/.test(password);
+const containsSpecialCharacter = (password: string) => /[^a-zA-Z0-9]/.test(password);
+
+const isPasswordValid = (password: string) =>
+  contains12Characters(password) &&
+  containsUppercase(password) &&
+  containsLowercase(password) &&
+  containsDigit(password) &&
+  containsSpecialCharacter(password);
+
+const isPasswordConfirmed = (password: string, confirm: string) =>
+  password === confirm && password !== '';
+
+const SuccessLabel = styled(LightLabel)`
+  color: ${themeColor('textSuccess')};
+`;
index 13095e0a3937e6c538e8818ef50be86582fb66fe..052e2a40627983ffbaa867ca97f3ea1551877e63 100644 (file)
@@ -172,6 +172,7 @@ order=Order
 owner=Owner
 parameters=Parameters
 password=Password
+confirm_password=Confirm Password
 path=Path
 permalink=Permanent Link
 plugin=Plugin
@@ -2799,6 +2800,14 @@ alert.dismiss=Dismiss this message
 #
 #------------------------------------------------------------------------------
 user.password_doesnt_match_confirmation=Password doesn't match confirmation.
+user.password.conditions=Your password must include at least:
+user.password.condition.12_characters=At least 12 characters
+user.password.condition.1_upper_case=At least one uppercase letter
+user.password.condition.1_lower_case=At least one lowercase letter
+user.password.condition.1_number=At least one digit
+user.password.condition.1_special_character=At least one special character
+user.password.invalid=Your password must meet all the requirements.
+user.password.do_not_match=Passwords do not match.
 user.old_password_incorrect=Old password is incorrect
 user.new_password_same_as_old=New password must be different from old password
 user.login_or_email_used_as_scm_account=Login and email are automatically considered as SCM accounts