Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions application/admin-client/cypress/e2e/passwordReset.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
11 changes: 11 additions & 0 deletions application/admin-client/cypress/e2e/setup.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
5 changes: 4 additions & 1 deletion application/admin-client/src/pages/setup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const SetupPage = () => {
register,
handleSubmit,
setError,
getValues,
formState: { errors },
} = useForm()

Expand Down Expand Up @@ -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)}`
}
Expand Down
17 changes: 14 additions & 3 deletions application/backend/src/controllers/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ export class AuthController extends Controller {
public async registerUser(@Body() bodyRequest: RegisterRequest): Promise<RegisterResponse> {
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')
Expand Down Expand Up @@ -145,7 +150,7 @@ export class AuthController extends Controller {
): Promise<RegisterResponse> {
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')
Expand Down Expand Up @@ -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')
}
Expand Down
13 changes: 12 additions & 1 deletion application/backend/src/controllers/UsersController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 4 additions & 4 deletions application/backend/tests/integration/Auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,15 +33,15 @@ 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,
}

const testParticipant: RegisterRequest = {
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,
}

Expand Down Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions application/backend/tests/integration/ResetPassword.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
164 changes: 164 additions & 0 deletions application/common/src/PasswordStrength.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading
Loading