diff --git a/CHANGELOG.md b/CHANGELOG.md index 313377a..e9f904a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # CHANGELOG +## v24.19 + +### Releases + +- v24.19-alpha + +### Compatibility + +No breaking changes, tested with Seacat Auth v24.17-beta2 + +### Features + +- Advanced password validation (#43, v24.19-alpha, PLUM Sprint 240503) + + ## v23.29 - v23.29-beta diff --git a/public/locales/cs/translation.json b/public/locales/cs/translation.json index 3c1fc9a..5018b92 100644 --- a/public/locales/cs/translation.json +++ b/public/locales/cs/translation.json @@ -145,8 +145,9 @@ "Fill in your login credentials": "Vložte prosím Vaše přihlašovací údaje" }, "ChangePwdScreen": { - "Set password": "Nastavení hesla", - "Set new password here": "Zde si můžete nastavit Vaše nové heslo", + "Password change": "Změna hesla", + "Set password": "Změnit heslo", + "Set new password here": "Zde si můžete nastavit nové heslo", "Current Password": "Stávající heslo", "New Password": "Nové heslo", "Re-enter Password": "Nové heslo znovu", @@ -159,7 +160,14 @@ "Password changed": "Heslo bylo změněno", "Go back": "Zpět" }, - "Something went wrong": "Something went wrong" + "Something went wrong": "Something went wrong", + "New password must be different from your old password": "Nové heslo nesmí být stejné jako vaše stávající heslo", + "The password must meet the following criteria:": "Heslo musí splňovat tato kritéria:", + "It must consist of {{minLength}} or more characters": "Musí být alespoň {{minLength}} znaků dlouhé", + "It must contain at least {{minLowercaseCount}} lowercase characters": "Musí obsahovat alespoň {{minLowercaseCount}} malých písmen", + "It must contain at least {{minUppercaseCount}} uppercase characters": "Musí obsahovat alespoň {{minUppercaseCount}} velkých písmen", + "It must contain at least {{minDigitCount}} digits": "Musí obsahovat alespoň {{minDigitCount}} číslic", + "It must contain at least {{minSpecialCount}} special characters": "Musí obsahovat alespoň {{minSpecialCount}} speciálních znaků" }, "PhoneNumberScreen": { "Do you want to change your number?": "Opravdu chcete změnit vaše telefonní číslo?", @@ -182,7 +190,8 @@ }, "ResetPwdScreen": { "Set password": "Nastavení hesla", - "Set new password here": "Zde si můžete nastavit Vaše nové heslo", + "Set new password": "Nastavit nové heslo", + "Set a new password here": "Zde si můžete nastavit Vaše nové heslo", "Current Password": "Stávající heslo", "New Password": "Nové heslo", "Re-enter Password": "Nové heslo znovu", @@ -190,7 +199,7 @@ "Passwords do not match": "Hesla se neshodují!", "Short password": "Heslo je příliš krátké!", "Something went wrong, unable to set the password": "Něco je špatně, nepodařilo se nastavit heslo", - "Invalid password reset link, please set your password again": "Neplatný odkaz k obnovení hesla, prosím nastavte Vaše heslo znovu", + "Your password reset link has likely expired. Please request a new one.": "Váš odkaz k obnovení hesla pravděpodobně vypršel. Můžete si nechat zaslat odkaz nový.", "CompletedResetPwdCard": { "Password set": "Heslo bylo nastaveno", "Continue": "Pokračovat" diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 9a3afca..e6bd659 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -145,6 +145,7 @@ "Fill in your login credentials": "Please fill in your login credentials" }, "ChangePwdScreen": { + "Password change": "Password change", "Set password": "Set password", "Set new password here": "You can set your new password here", "Current Password": "Current password", @@ -159,7 +160,14 @@ "Password changed": "Password has been changed", "Go back": "Go back" }, - "Something went wrong": "Something went wrong" + "Something went wrong": "Something went wrong", + "New password must be different from your old password": "New password must be different from your old password", + "The password must meet the following criteria:": "The password must meet the following criteria:", + "It must consist of {{minLength}} or more characters": "It must consist of {{minLength}} or more characters", + "It must contain at least {{minLowercaseCount}} lowercase characters": "It must contain at least {{minLowercaseCount}} lowercase characters", + "It must contain at least {{minUppercaseCount}} uppercase characters": "It must contain at least {{minUppercaseCount}} uppercase characters", + "It must contain at least {{minDigitCount}} digits": "It must contain at least {{minDigitCount}} digits", + "It must contain at least {{minSpecialCount}} special characters": "It must contain at least {{minSpecialCount}} special characters" }, "PhoneNumberScreen": { "Do you want to change your number?": "Do you want to change your number?", @@ -182,7 +190,8 @@ }, "ResetPwdScreen": { "Set password": "Set password", - "Set new password here": "You can set your new password here", + "Set new password": "Set new password", + "Set a new password here": "You can set your new password here", "Current Password": "Current password", "New Password": "New password", "Re-enter Password": "Re-enter the new password", @@ -190,7 +199,7 @@ "Passwords do not match": "Passwords do not match!", "Short password": "Password is too short!", "Something went wrong, unable to set the password": "Something went wrong, unable to set the password", - "Invalid password reset link, please set your password again": "Invalid password reset link, please set your password again", + "Your password reset link has likely expired. Please request a new one.": "Your password reset link has likely expired. Please request a new one.", "CompletedResetPwdCard": { "Password set": "Password has been set", "Continue": "Continue" diff --git a/src/modules/auth/passwd/ChangePwdScreen.js b/src/modules/auth/passwd/ChangePwdScreen.js index b893e58..959f23d 100644 --- a/src/modules/auth/passwd/ChangePwdScreen.js +++ b/src/modules/auth/passwd/ChangePwdScreen.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; -import { Link, useHistory, useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import { Container, Row, Col, @@ -11,6 +11,14 @@ import { import { factorChaining } from "../utils/factorChaining"; import generatePenrose from '../utils/generatePenrose'; +import { + validatePasswordLength, + validatePasswordLowercaseCount, + validatePasswordUppercaseCount, + validatePasswordDigitCount, + validatePasswordSpecialCount, + PasswordCriteriaFeedback, +} from '../utils/passwordValidation'; function ChangePwdScreen(props) { @@ -31,8 +39,9 @@ function ChangePwdScreen(props) { export default ChangePwdScreen; function ChangePwdCard(props) { - const { t, i18n } = useTranslation(); - const { handleSubmit, register, getValues, formState: { errors, isSubmitting } } = useForm(); + const { t } = useTranslation(); + const SeaCatAuthAPI = props.app.axiosCreate('seacat-auth'); + const { handleSubmit, register, getValues, watch, formState: { errors, isSubmitting } } = useForm(); let history = useHistory(); @@ -40,41 +49,73 @@ function ChangePwdCard(props) { let redirect_uri = params.get("redirect_uri"); const [ completed, setCompleted ] = useState(false); + const [ passwordCriteria, setPasswordCriteria ] = useState({ + minLength: 10, + }); + + useEffect(() => { + loadPasswordCriteria(); + }, []); + + const loadPasswordCriteria = async () => { + try { + const response = await SeaCatAuthAPI.get('/public/password/policy'); + setPasswordCriteria({ + minLength: response.data?.min_length, + minLowercaseCount: response.data?.min_lowercase_count, + minUppercaseCount: response.data?.min_uppercase_count, + minDigitCount: response.data?.min_digit_count, + minSpecialCount: response.data?.min_special_count, + }); + } catch (e) { + if (e?.response?.status == 404) { + // Most likely older service version which does not have this endpoint + console.error(e); + } else { + props.app.addAlertFromException(e, t('ChangePwdScreen|Failed to load password criteria')); + } + } + }; + + // Password is watched for immediate feedback to the user + const watchedNewPassword = watch('newpassword', ''); + const validateNewPassword = (value) => ({ + minLength: validatePasswordLength(value, passwordCriteria?.minLength), + minLowercaseCount: validatePasswordLowercaseCount(value, passwordCriteria?.minLowercaseCount), + minUppercaseCount: validatePasswordUppercaseCount(value, passwordCriteria?.minUppercaseCount), + minDigitCount: validatePasswordDigitCount(value, passwordCriteria?.minDigitCount), + minSpecialCount: validatePasswordSpecialCount(value, passwordCriteria?.minSpecialCount), + }); const regOldpwd = register("oldpassword"); - const regNewpwd = register("newpassword",{ + const regNewpwd = register("newpassword", { validate: { - shortInput: value => (getValues().newpassword.length >= 4)|| t("ChangePwdScreen|Short password"), + passwordCriteria: (value) => (Object.values(validateNewPassword(value)).every(Boolean) + || t('ChangePwdScreen|Password does not meet security requirements')), + dontReuseOldPassword: (value) => (value !== getValues('oldpassword')) + || t('ChangePwdScreen|New password must be different from your old password'), } }); - const regNewpwd2 = register("newpassword2",{ + const regNewpwd2 = register("newpassword2", { validate: { passEqual: value => (value === getValues().newpassword) || t("ChangePwdScreen|Passwords do not match"), } }); const onSubmit = async (values) => { - let SeaCatAuthAPI = props.app.axiosCreate('seacat-auth'); - let response; - try { - response = await SeaCatAuthAPI.put("/public/password-change", values) + const response = await SeaCatAuthAPI.put('/public/password-change', values); + + if (response.data.result !== 'OK') { + throw new Error(t('ChangePwdScreen|Unexpected server response')); + } } catch (e) { - props.app.addAlert("danger", `${t("ChangePwdScreen|Something went wrong")}. ${e?.response?.data?.message}`, 30); - return; - } + if (e?.response?.status == 401 || e?.response?.data?.result == 'UNAUTHORIZED') { + props.app.addAlert("danger", t('ChangePwdScreen|The current password is incorrect'), 30); + } else { + props.app.addAlert("danger", `${t("ResetPwdScreen|Password change failed")}. ${e?.response?.data?.message}`, 30); + } - if (response.data.result == 'FAILED') { - props.app.addAlert( - "danger", - t("ChangePwdScreen|Something went wrong"), 30 - ); - return; - } else if (response.data.result == 'UNAUTHORIZED') { - props.app.addAlert( - "danger", - t("ChangePwdScreen|The current password is incorrect"), 30 - ); return; } @@ -112,7 +153,7 @@ function ChangePwdCard(props) {
- {t('ChangePwdScreen|Set password')} + {t('ChangePwdScreen|Password change')} {t('ChangePwdScreen|Set new password here')} @@ -146,17 +187,25 @@ function ChangePwdCard(props) { - {errors.newpassword && {errors.newpassword.message}} + {errors?.newpassword?.type !== 'passwordCriteria' + && {errors?.newpassword?.message} + } + @@ -179,7 +228,7 @@ function ChangePwdCard(props) { {errors.newpassword2 ? {errors.newpassword2.message} : - + {t('ChangePwdScreen|Enter new password a second time to verify it')} } diff --git a/src/modules/auth/passwd/ResetPwdScreen.js b/src/modules/auth/passwd/ResetPwdScreen.js index a7db109..7e4d6c1 100644 --- a/src/modules/auth/passwd/ResetPwdScreen.js +++ b/src/modules/auth/passwd/ResetPwdScreen.js @@ -1,14 +1,22 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; -import { Link, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import { Container, Row, Col, - Card, CardHeader, CardTitle, CardSubtitle, CardBody, CardFooter, + Card, CardHeader, CardTitle, CardSubtitle, CardBody, Form, FormGroup, FormText, Label, Input, Button, FormFeedback } from 'reactstrap'; import generatePenrose from '../utils/generatePenrose'; +import { + validatePasswordLength, + validatePasswordLowercaseCount, + validatePasswordUppercaseCount, + validatePasswordDigitCount, + validatePasswordSpecialCount, + PasswordCriteriaFeedback, +} from '../utils/passwordValidation'; function ResetPwdScreen(props) { @@ -27,14 +35,52 @@ function ResetPwdScreen(props) { export default ResetPwdScreen; function ResetPwdCard(props) { - const { t, i18n } = useTranslation(); - const { handleSubmit, register, getValues, formState: { errors, isSubmitting } } = useForm(); + const { t } = useTranslation(); + const SeaCatAuthAPI = props.app.axiosCreate('seacat-auth'); + const { handleSubmit, register, getValues, watch, formState: { errors, isSubmitting } } = useForm(); generatePenrose(); - - let history = useHistory(); + + const history = useHistory(); const [ completed, setCompleted ] = useState(false); + const [ passwordCriteria, setPasswordCriteria ] = useState({ + minLength: 10, + }); + + useEffect(() => { + loadPasswordCriteria(); + }, []); + + const loadPasswordCriteria = async () => { + try { + const response = await SeaCatAuthAPI.get('/public/password/policy'); + setPasswordCriteria({ + minLength: response.data?.min_length, + minLowercaseCount: response.data?.min_lowercase_count, + minUppercaseCount: response.data?.min_uppercase_count, + minDigitCount: response.data?.min_digit_count, + minSpecialCount: response.data?.min_special_count, + }); + } catch (e) { + if (e?.response?.status == 404) { + // Most likely older service version which does not have this endpoint + console.error(e); + } else { + props.app.addAlertFromException(e, t('ChangePwdScreen|Failed to load password criteria')); + } + } + }; + + // Password is watched for immediate feedback to the user + const watchedNewPassword = watch('newpassword', ''); + const validateNewPassword = (value) => ({ + minLength: validatePasswordLength(value, passwordCriteria?.minLength), + minLowercaseCount: validatePasswordLowercaseCount(value, passwordCriteria?.minLowercaseCount), + minUppercaseCount: validatePasswordUppercaseCount(value, passwordCriteria?.minUppercaseCount), + minDigitCount: validatePasswordDigitCount(value, passwordCriteria?.minDigitCount), + minSpecialCount: validatePasswordSpecialCount(value, passwordCriteria?.minSpecialCount), + }); const isButtonRemoved = props.app.Config.get('password_change')?.remove_btn; @@ -43,12 +89,13 @@ function ResetPwdCard(props) { let params = new URLSearchParams(qs); let resetPasswordCode = params.get("pwd_token"); - const regNewpwd = register("newpassword",{ + const regNewpwd = register("newpassword", { validate: { - shortInput: value => (getValues().newpassword.length >= 4) || t("ResetPwdScreen|Short password"), + passwordCriteria: (value) => (Object.values(validateNewPassword(value)).every(Boolean) + || t('ChangePwdScreen|Password does not meet security requirements')), } }); - const regNewpwd2 = register("newpassword2",{ + const regNewpwd2 = register("newpassword2", { validate: { passEqual: value => (value === getValues().newpassword) || t("ResetPwdScreen|Passwords do not match"), } @@ -56,22 +103,22 @@ function ResetPwdCard(props) { const onSubmit = async (values) => { - let SeaCatAuthAPI = props.app.axiosCreate('seacat-auth'); let response; values.pwd_token = resetPasswordCode; try { response = await SeaCatAuthAPI.put("/public/password-reset", values); - if (response.data.result === 'INVALID-CODE') { - props.app.addAlert("danger", t("ResetPwdScreen|Invalid password reset link, please set your password again"), 30); - onRedirect("/cant-login", true); - return; - } if (response.data.result !== 'OK') { throw new Error(t("ResetPwdScreen|Something went wrong, unable to set the password")); } } catch (e) { - props.app.addAlert("danger", `${t("ResetPwdScreen|Something went wrong, unable to set the password")}. ${e?.response?.data?.message}`, 30); + if (e?.response?.status == 401 || e?.response?.data?.result == 'UNAUTHORIZED') { + props.app.addAlert("danger", t('ResetPwdScreen|Your password reset link has likely expired. Please request a new one.', 30)); + onRedirect("/cant-login", true); + } else { + props.app.addAlert("danger", `${t("ResetPwdScreen|Password change failed")}. ${e?.response?.data?.message}`, 30); + } + return; } setCompleted(true); @@ -155,54 +202,55 @@ function ResetPwdCard(props) {
{t('ResetPwdScreen|Set password')} - {t('ResetPwdScreen|Set new password here')} + {t('ResetPwdScreen|Set a new password here')}
- -
- -
+ + - {errors.newpassword && {errors.newpassword.message}} + {errors?.newpassword?.type !== 'passwordCriteria' + && {errors?.newpassword?.message} + } + - -
- -
+ + - - {errors.newpassword2 ? - {errors.newpassword2.message} - : - + {errors?.newpassword2 + ? {errors?.newpassword2.message} + : {t('ResetPwdScreen|Enter new password a second time to verify it')} } @@ -216,7 +264,7 @@ function ResetPwdCard(props) { type="submit" disabled={isSubmitting} > - {t("ResetPwdScreen|Set password")} + {t("ResetPwdScreen|Set new password")}
diff --git a/src/modules/auth/utils/passwordValidation.js b/src/modules/auth/utils/passwordValidation.js new file mode 100644 index 0000000..d52f59c --- /dev/null +++ b/src/modules/auth/utils/passwordValidation.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FormText } from 'reactstrap'; + +export function validatePasswordLength(password, minLength) { + // Check if the password is long enough + return (!minLength) + || (password.length >= minLength); +} + +export function validatePasswordLowercaseCount(password, minLowercaseCount) { + // Check if the password contains enough lowercase characters + return (!minLowercaseCount) + || ((password.match(/[a-z]/g) || []).length >= minLowercaseCount); +} + +export function validatePasswordUppercaseCount(password, minUppercaseCount) { + // Check if the password contains enough uppercase characters + return (!minUppercaseCount) + || ((password.match(/[A-Z]/g) || []).length >= minUppercaseCount); +} + +export function validatePasswordDigitCount(password, minDigitCount) { + // Check if the password contains enough digits + return (!minDigitCount) + || ((password.match(/[0-9]/g) || []).length >= minDigitCount); +} + +export function validatePasswordSpecialCount(password, minSpecialCount) { + // Check if the password contains enough special characters + return (!minSpecialCount) + || ((password.match(/[^a-zA-Z0-9]/g) || []).length >= minSpecialCount); +} + +export function PasswordCriteriaFeedback({ passwordCriteria, validatePassword, watchedPassword, passwordErrors }) { + const { t } = useTranslation(); + const validatedNewPassword = validatePassword(watchedPassword); + const invalidColor = (passwordErrors?.type == 'passwordCriteria') ? 'text-danger' : 'text-muted'; + + return ( + + {/* + Every password requirement has the default (muted) color until fulfilled or form is submitted. + Once fulfilled, it **immediately** turns green. + Once the form is submitted, all the unmet requirements turn red. + */} +
+ {t('ChangePwdScreen|The password must meet the following criteria:')} +
+ {Boolean(passwordCriteria.minLength) + &&
+ + {t('ChangePwdScreen|It must consist of {{minLength}} or more characters', passwordCriteria)} +
+ } + {Boolean(passwordCriteria.minLowercaseCount) + &&
+ + {t('ChangePwdScreen|It must contain at least {{minLowercaseCount}} lowercase characters', passwordCriteria)} +
+ } + {Boolean(passwordCriteria.minUppercaseCount) + &&
+ + {t('ChangePwdScreen|It must contain at least {{minUppercaseCount}} uppercase characters', passwordCriteria)} +
+ } + {Boolean(passwordCriteria.minDigitCount) + &&
+ + {t('ChangePwdScreen|It must contain at least {{minDigitCount}} digits', passwordCriteria)} +
+ } + {Boolean(passwordCriteria.minSpecialCount) + &&
+ + {t('ChangePwdScreen|It must contain at least {{minSpecialCount}} special characters', passwordCriteria)} +
+ } +
+ ); +}