diff --git a/application/admin-client/cypress/e2e/passwordReset.cy.js b/application/admin-client/cypress/e2e/passwordReset.cy.js index d5568b98..27d37870 100644 --- a/application/admin-client/cypress/e2e/passwordReset.cy.js +++ b/application/admin-client/cypress/e2e/passwordReset.cy.js @@ -41,6 +41,14 @@ describe('Password Reset', () => { cy.contains('must not contain easily guessable').should('exist') }) + it('rejects a new password containing the users personal info', () => { + cy.intercept('POST', '/users/password/reset').as('resetRequest') + cy.visit('/update-password?token=valid-reset-token') + cy.get('input[id="password"]').type('ResetCorduroy2026') + cy.get('input[id="confirmPassword"]').type('ResetCorduroy2026{enter}') + cy.wait('@resetRequest').its('response.statusCode').should('eq', 422) + }) + it('opens password reset page and enters non-matching passwords', () => { cy.visit('/update-password?token=valid-reset-token') cy.get('input[id="password"]').type('pass') diff --git a/application/admin-client/cypress/e2e/setup.cy.js b/application/admin-client/cypress/e2e/setup.cy.js index 5927133b..169b3469 100644 --- a/application/admin-client/cypress/e2e/setup.cy.js +++ b/application/admin-client/cypress/e2e/setup.cy.js @@ -13,4 +13,15 @@ describe('Setup', () => { cy.visit('/surveys') cy.contains('Current Draft').should('exist') }) + + it('Rejects password containing the email local part', () => { + cy.task('wipe') + cy.visit('/') + cy.url().should('contain', '/setup') + cy.get('[data-cy="setup-email"]').type('tanuj@example.com') + cy.get('[data-cy="setup-password"]').type('MyTanujCorduroy26') + cy.get('[data-cy="setup-submit"]').click() + cy.contains('Invalid password').should('exist') + cy.contains('contains personal information').should('exist') + }) }) diff --git a/application/admin-client/src/pages/setup/index.tsx b/application/admin-client/src/pages/setup/index.tsx index cf7da645..f9219704 100644 --- a/application/admin-client/src/pages/setup/index.tsx +++ b/application/admin-client/src/pages/setup/index.tsx @@ -10,6 +10,7 @@ export const SetupPage = () => { register, handleSubmit, setError, + getValues, formState: { errors }, } = useForm() @@ -87,7 +88,9 @@ export const SetupPage = () => { {...register('password', { required: true, validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val, { + email: getValues('email'), + }) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` } diff --git a/application/backend/src/controllers/AuthController.ts b/application/backend/src/controllers/AuthController.ts index 92c6a699..94f60f11 100644 --- a/application/backend/src/controllers/AuthController.ts +++ b/application/backend/src/controllers/AuthController.ts @@ -83,7 +83,12 @@ export class AuthController extends Controller { public async registerUser(@Body() bodyRequest: RegisterRequest): Promise { const { password, ...userDetails } = bodyRequest - const { isValid, fields } = await checkPasswordStrength(password) + const { isValid, fields } = await checkPasswordStrength(password, { + firstName: bodyRequest.firstName, + middleName: bodyRequest.middleName, + lastName: bodyRequest.lastName, + email: bodyRequest.email, + }) if (!isValid) { throw new ValidateError(fields, 'Password does not meet strength requirements') @@ -145,7 +150,7 @@ export class AuthController extends Controller { ): Promise { const { password, email } = bodyRequest - const { isValid, fields } = await checkPasswordStrength(password) + const { isValid, fields } = await checkPasswordStrength(password, { email }) if (!isValid) { throw new ValidateError(fields, 'Password does not meet strength requirements') @@ -214,7 +219,13 @@ export class AuthController extends Controller { } // Check and hash Password - const { isValid, fields } = await checkPasswordStrength(password) + const { isValid, fields } = await checkPasswordStrength(password, { + firstName, + middleName, + lastName, + email, + dob: bodyRequest.dob, + }) if (!isValid) { throw new ValidateError(fields, 'Password does not meet strength requirements') } diff --git a/application/backend/src/controllers/UsersController.ts b/application/backend/src/controllers/UsersController.ts index 733e5598..9257b688 100644 --- a/application/backend/src/controllers/UsersController.ts +++ b/application/backend/src/controllers/UsersController.ts @@ -459,8 +459,19 @@ export class UsersController extends Controller { throw new PasswordResetTokenInvalidError('Reset token expired') } + // Fetch DOB from ParticipantProfile if this user has one (participants only) + const participantProfile = await prisma.participantProfile.findFirst({ + where: { userId: passwordResetToken.userId }, + }) + // Validate the new password against the strength requirements - const { isValid, fields } = await checkPasswordStrength(newPassword) + const { isValid, fields } = await checkPasswordStrength(newPassword, { + firstName: passwordResetToken.user.firstName, + middleName: passwordResetToken.user.middleName ?? undefined, + lastName: passwordResetToken.user.lastName, + email: passwordResetToken.user.email, + dob: participantProfile?.dob ?? undefined, + }) if (!isValid) { throw new ValidateError(fields, 'New password does not meet strength requirements') diff --git a/application/backend/tests/integration/Auth.test.ts b/application/backend/tests/integration/Auth.test.ts index 73e7ab2a..28db0d0d 100644 --- a/application/backend/tests/integration/Auth.test.ts +++ b/application/backend/tests/integration/Auth.test.ts @@ -7,7 +7,7 @@ import { RegisterResponse, } from 'common/types/api/auth' import { resetDB } from 'common/testing/TestHelpers' -import { TestUsers, TestStudies, TestInvites } from 'common/testing/constants' +import { STRONG_TEST_PASSWORD, TestUsers, TestStudies, TestInvites } from 'common/testing/constants' import { ContactMethod, ParticipantType, @@ -33,7 +33,7 @@ describe('Auth', () => { firstName: 'Test', lastName: 'Admin', email: 'test@admin.com', - password: TestUsers.ORG_ADMIN.password, // Note: using test data so it fits password policy + password: STRONG_TEST_PASSWORD, role: Role.OrganisationAdmin, } @@ -41,7 +41,7 @@ describe('Auth', () => { firstName: 'Test', lastName: 'Participant', email: 'test@participant.com', - password: TestUsers.ORG_ADMIN.password, // Note: using test data so it fits password policy + password: STRONG_TEST_PASSWORD, role: Role.Participant, } @@ -123,7 +123,7 @@ describe('Auth', () => { firstName: 'John', lastName: 'Doe', email: TestInvites.INVITE_2_PENDING.email, - password: 'johnDoesP@ssword123', + password: STRONG_TEST_PASSWORD, mobile: '+61477777777', addressLine: '123 Some Street', suburb: 'Sydney', diff --git a/application/backend/tests/integration/ResetPassword.test.ts b/application/backend/tests/integration/ResetPassword.test.ts index 032a9181..94934c8b 100644 --- a/application/backend/tests/integration/ResetPassword.test.ts +++ b/application/backend/tests/integration/ResetPassword.test.ts @@ -20,6 +20,7 @@ describe('User Password Reset', () => { // Note: using different test data pw to ensure consistency with pw requirements const newPassword = TestUsers.PARTICIPANT_COMPLETED.password const invalidPassword = 'password' + const piiPassword = 'ResetConstellation26' // contains "reset" - the seeded user's firstName beforeAll(async () => { api.run() @@ -104,6 +105,21 @@ describe('User Password Reset', () => { expect(isPasswordCorrect).toBe(true) }) + it('should reject a new password containing the user personal info', async () => { + const response = await request(app).post('/users/password/reset').send({ + token: resetToken, + newPassword: piiPassword, + }) + + expect(response.status).toBe(422) + expect(response.body.details).toHaveProperty('PersonalInfo') + + // Check the original password is still in the database + const updatedUser = await prisma.user.findUnique({ where: { id: userId } }) + const isPasswordCorrect = await verifyPassword(updatedUser!.password, originalPassword) + expect(isPasswordCorrect).toBe(true) + }) + it('should reset the password successfully', async () => { const response = await request(app).post('/users/password/reset').send({ token: resetToken, diff --git a/application/common/src/PasswordStrength.test.ts b/application/common/src/PasswordStrength.test.ts new file mode 100644 index 00000000..a89fdf6f --- /dev/null +++ b/application/common/src/PasswordStrength.test.ts @@ -0,0 +1,164 @@ +import { checkPasswordStrength } from './PasswordStrength' + +describe('checkPasswordStrength', () => { + const strongPassword = 'Constellation2026' + + describe('without context (backwards compatibility)', () => { + it('accepts a strong password with no context', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword) + expect(isValid).toBe(true) + expect(fields).toEqual({}) + }) + + it('rejects a password shorter than 14 characters', () => { + const { isValid, fields } = checkPasswordStrength('Short2026Aok') + expect(isValid).toBe(false) + expect(fields.Length).toBeDefined() + }) + + it('rejects a password without an uppercase letter', () => { + const { isValid, fields } = checkPasswordStrength('constellation2026') + expect(isValid).toBe(false) + expect(fields.Uppercase).toBeDefined() + }) + + it('rejects a password without a lowercase letter', () => { + const { isValid, fields } = checkPasswordStrength('CONSTELLATION2026') + expect(isValid).toBe(false) + expect(fields.Lowercase).toBeDefined() + }) + + it('rejects a password without a digit', () => { + const { isValid, fields } = checkPasswordStrength('ConstellationOnly') + expect(isValid).toBe(false) + expect(fields.Number).toBeDefined() + }) + + it('rejects a password containing a common base word', () => { + const { isValid, fields } = checkPasswordStrength('MyPassword2026Ok') + expect(isValid).toBe(false) + expect(fields.CommonBase).toBeDefined() + }) + }) + + describe('with context — PII rejection', () => { + it('rejects a password containing the first name', () => { + const { isValid, fields } = checkPasswordStrength('Tanuj2026StrongOne', { + firstName: 'Tanuj', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + expect(fields.PersonalInfo.message).toContain('tanuj') + }) + + it('rejects a password containing the last name', () => { + const { isValid, fields } = checkPasswordStrength('AndersonMyPas2026', { + lastName: 'Anderson', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('rejects a password containing the middle name', () => { + const { isValid, fields } = checkPasswordStrength('RobertMyStrong26', { + middleName: 'Robert', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('rejects a password containing the email local part', () => { + const { isValid, fields } = checkPasswordStrength('Tanuj2026MyStrong', { + email: 'tanuj@garvan.org.au', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + expect(fields.PersonalInfo.message).toContain('tanuj') + }) + + it('does not reject a password containing only the email domain', () => { + const { fields } = checkPasswordStrength('GarvanStaff2026Big', { + email: 'someone@garvan.org.au', + }) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('rejects a password containing the DOB year', () => { + const { isValid, fields } = checkPasswordStrength('MyStrongOne1990Ok', { + dob: '1990-05-15', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + expect(fields.PersonalInfo.message).toContain('1990') + }) + + it('matches case-insensitively', () => { + const { isValid, fields } = checkPasswordStrength('TANUJBrightStar26', { + firstName: 'Tanuj', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('accepts a strong password with valid context', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword, { + firstName: 'Elizabeth', + lastName: 'Windsor', + email: 'elizabeth@example.com', + dob: '1926-04-21', + }) + expect(isValid).toBe(true) + expect(fields).toEqual({}) + }) + + it('ignores tokens shorter than 4 characters', () => { + const { fields } = checkPasswordStrength(strongPassword, { + firstName: 'Bob', + }) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('splits multi-word names on whitespace', () => { + const { isValid, fields } = checkPasswordStrength('BergerKing2026Big', { + lastName: 'Van Der Berg', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('handles an empty context object', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword, {}) + expect(isValid).toBe(true) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('handles context with all undefined fields', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword, { + firstName: undefined, + lastName: undefined, + email: undefined, + dob: undefined, + }) + expect(isValid).toBe(true) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('returns multiple errors when password violates multiple checks', () => { + const { isValid, fields } = checkPasswordStrength('tanuj', { + firstName: 'Tanuj', + }) + expect(isValid).toBe(false) + expect(fields.Length).toBeDefined() + expect(fields.Uppercase).toBeDefined() + expect(fields.Number).toBeDefined() + expect(fields.PersonalInfo).toBeDefined() + }) + + it('names the specific matched token in the error message', () => { + const { fields } = checkPasswordStrength('Elizabeth2026Extra', { + firstName: 'Elizabeth', + }) + expect(fields.PersonalInfo.message).toContain('elizabeth') + }) + }) +}) diff --git a/application/common/src/PasswordStrength.ts b/application/common/src/PasswordStrength.ts index a105e29d..e6de4f29 100644 --- a/application/common/src/PasswordStrength.ts +++ b/application/common/src/PasswordStrength.ts @@ -8,12 +8,46 @@ export interface FieldErrors { } } +export interface PasswordContext { + email?: string + firstName?: string + middleName?: string + lastName?: string + dob?: string +} + interface PasswordStrengthResult { isValid: boolean fields: FieldErrors } -export function checkPasswordStrength(password: string): PasswordStrengthResult { +function extractPiiTokens(context: PasswordContext): string[] { + const tokens = new Set() + const addTokens = (value: string | undefined) => { + if (!value) return + value + .split(/\s+/) + .map((token) => token.trim().toLowerCase()) + .filter((token) => token.length >= 4) + .forEach((token) => tokens.add(token)) + } + addTokens(context.firstName) + addTokens(context.middleName) + addTokens(context.lastName) + if (context.email) { + addTokens(context.email.split('@')[0]) + } + if (context.dob) { + const year = context.dob.match(/\d{4}/)?.[0] + if (year) tokens.add(year) + } + return Array.from(tokens) +} + +export function checkPasswordStrength( + password: string, + context?: PasswordContext, +): PasswordStrengthResult { const fields: FieldErrors = {} const baseWordRegex = new RegExp(commonPasswordBaseWords.join('|'), 'i') @@ -25,6 +59,16 @@ export function checkPasswordStrength(password: string): PasswordStrengthResult message: `Password must not contain easily guessable words (i.e. ${commonPasswordBaseWords.join(', ')})`, } } + if (context) { + const tokens = extractPiiTokens(context) + const lowerPassword = password.toLowerCase() + const matchedToken = tokens.find((t) => lowerPassword.includes(t)) + if (matchedToken) { + fields.PersonalInfo = { + message: `Password contains personal information: "${matchedToken}"`, + } + } + } if (!/[A-Z]/.test(password)) { fields.Uppercase = { message: 'Password must contain at least one uppercase letter', diff --git a/application/common/testing/constants.ts b/application/common/testing/constants.ts index 738607da..f08ff9e0 100644 --- a/application/common/testing/constants.ts +++ b/application/common/testing/constants.ts @@ -1,5 +1,9 @@ // Constants shared for all tests +// Strong test password with no common test-user PII substrings (e.g. 'test', 'admin'). +// Use in tests that register/login with placeholder names. +export const STRONG_TEST_PASSWORD = 'Constellation2026' + // Test user credentials (for seed data and tests) export const TestUsers = { OPERATOR_ADMIN: { diff --git a/application/common/testing/seed.ts b/application/common/testing/seed.ts index 9133257c..4ea0fe02 100644 --- a/application/common/testing/seed.ts +++ b/application/common/testing/seed.ts @@ -400,8 +400,8 @@ export async function seedTests(prisma: PrismaClient) { data: { id: TestUsers.PASSWORD_RESET_USER.id, email: TestUsers.PASSWORD_RESET_USER.email, - firstName: 'Test', - lastName: 'User', + firstName: 'Reset', + lastName: 'Recipient', password: hashPassword(TestUsers.PASSWORD_RESET_USER.password), role: Role.Participant, }, diff --git a/application/user-client/cypress/e2e/registration.cy.js b/application/user-client/cypress/e2e/registration.cy.js index 579d03a0..49c43d3f 100644 --- a/application/user-client/cypress/e2e/registration.cy.js +++ b/application/user-client/cypress/e2e/registration.cy.js @@ -93,6 +93,25 @@ describe('registration', () => { cy.contains('must not contain easily guessable words').should('exist') }) + it('Input password containing personal info and get correct error message', () => { + cy.task('getInviteIdtask', { + email: TestInvites.INVITE_PENDING.email, + studyId: TestStudies.TEST_STUDY.id, + }) + .as('inviteId') + .then((inviteId) => { + cy.visit(`/register/${inviteId}`) + }) + cy.wait(500) // wait for form to be fully loaded + cy.get('[data-cy="reg-first"] input').clear() + cy.get('[data-cy="reg-first"]').type('Tanuj') + cy.get('[data-cy="reg-password"]').type('TanujCorduroy2026') + cy.get('[data-cy="reg-confirm-password"]').type('TanujCorduroy2026') + cy.get('[data-cy="reg-button"]').click() + cy.contains('Invalid password').should('exist') + cy.contains('contains personal information').should('exist') + }) + it('Attempt to register existing email (i.e. no invite) and get correct error message', () => { cy.visit('/register/not-a-real-inviteId') fillValid() diff --git a/application/user-client/src/pages/Register.tsx b/application/user-client/src/pages/Register.tsx index 787529e6..217c352a 100644 --- a/application/user-client/src/pages/Register.tsx +++ b/application/user-client/src/pages/Register.tsx @@ -62,6 +62,7 @@ export default function Register() { setError, watch, reset, + getValues, formState: { errors }, } = useForm({ defaultValues: { preferredContact: '' as ContactMethod, state: '' as StateTerritory }, @@ -240,7 +241,12 @@ export default function Register() { {...register('password', { required: 'This field is required', validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val, { + firstName: getValues('firstName'), + lastName: getValues('lastName'), + email: getValues('email'), + dob: getValues('dob'), + }) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` }