Skip to content

Commit

Permalink
Add user-friendly password validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Gekkio committed Jan 16, 2025
1 parent 359ff63 commit 53da94b
Show file tree
Hide file tree
Showing 30 changed files with 760 additions and 51 deletions.
13 changes: 13 additions & 0 deletions frontend/src/citizen-frontend/generated/api-clients/pis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EmailVerificationRequest } from 'lib-common/generated/api-types/pis'
import { EmailVerificationStatusResponse } from 'lib-common/generated/api-types/pis'
import { JsonCompatible } from 'lib-common/json'
import { JsonOf } from 'lib-common/json'
import { PasswordConstraints } from 'lib-common/generated/api-types/shared'
import { PersonalDataUpdate } from 'lib-common/generated/api-types/pis'
import { UpdateWeakLoginCredentialsRequest } from 'lib-common/generated/api-types/pis'
import { client } from '../../api-client'
Expand Down Expand Up @@ -40,6 +41,18 @@ export async function getNotificationSettings(): Promise<EmailMessageType[]> {
}


/**
* Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.getPasswordConstraints
*/
export async function getPasswordConstraints(): Promise<PasswordConstraints> {
const { data: json } = await client.request<JsonOf<PasswordConstraints>>({
url: uri`/citizen/personal-data/password-constraints`.toString(),
method: 'GET'
})
return json
}


/**
* Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.sendEmailVerificationCode
*/
Expand Down
123 changes: 95 additions & 28 deletions frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import React, { useCallback } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import styled from 'styled-components'

import ModalAccessibilityWrapper from 'citizen-frontend/ModalAccessibilityWrapper'
import { Failure } from 'lib-common/api'
import { string } from 'lib-common/form/fields'
import { object, validated } from 'lib-common/form/form'
import { object, required, validated } from 'lib-common/form/form'
import { useBoolean, useForm, useFormFields } from 'lib-common/form/hooks'
import { EmailVerificationStatusResponse } from 'lib-common/generated/api-types/pis'
import { PasswordConstraints } from 'lib-common/generated/api-types/shared'
import { isPasswordStructureValid } from 'lib-common/password'
import { Button } from 'lib-components/atoms/buttons/Button'
import { InputFieldF } from 'lib-components/atoms/form/InputField'
import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers'
Expand All @@ -23,7 +26,7 @@ import BaseModal, {
ModalButtons
} from 'lib-components/molecules/modals/BaseModal'
import { MutateFormModal } from 'lib-components/molecules/modals/FormModal'
import { H2, Label } from 'lib-components/typography'
import { H2, Label, LabelLike } from 'lib-components/typography'
import { Gap } from 'lib-components/white-space'
import { featureFlags } from 'lib-customizations/citizen'
import { faCheck, faLockAlt } from 'lib-icons'
Expand All @@ -39,12 +42,14 @@ export interface Props {
user: User
reloadUser: () => void
emailVerificationStatus: EmailVerificationStatusResponse
passwordConstraints: PasswordConstraints
}

export default React.memo(function LoginDetailsSection({
user,
reloadUser,
emailVerificationStatus
emailVerificationStatus,
passwordConstraints
}: Props) {
const i18n = useTranslation()
const t = i18n.personalDetails.loginDetailsSection
Expand Down Expand Up @@ -156,6 +161,7 @@ export default React.memo(function LoginDetailsSection({
<>
{modalOpen && (
<WeakCredentialsFormModal
passwordConstraints={passwordConstraints}
hasCredentials={!!user.weakLoginUsername}
username={
user.weakLoginUsername ??
Expand Down Expand Up @@ -197,38 +203,23 @@ export default React.memo(function LoginDetailsSection({
)
})

const minLength = 8
const maxLength = 128

const passwordForm = validated(
object({
password: string(),
confirmPassword: string()
}),
(form) => {
if (
form.password.length === 0 ||
form.password !== form.confirmPassword ||
form.password.length < minLength ||
form.password.length > maxLength
) {
return 'required'
}
return undefined
}
)

const UsernameField = styled.input`
cursor: auto;
border: none;
`

const ConstraintsList = styled.ul`
margin: 0;
`

const WeakCredentialsFormModal = React.memo(function WeakCredentialsFormModal({
passwordConstraints,
hasCredentials,
username,
onSuccess,
onCancel
}: {
passwordConstraints: PasswordConstraints
hasCredentials: boolean
username: string
onSuccess: () => void
Expand All @@ -237,13 +228,55 @@ const WeakCredentialsFormModal = React.memo(function WeakCredentialsFormModal({
const i18n = useTranslation()
const t = i18n.personalDetails.loginDetailsSection

const passwordForm = useMemo(
() =>
validated(
object({
password: validated(required(string()), (password) =>
isPasswordStructureValid(passwordConstraints, password)
? undefined
: 'format'
),
confirmPassword: required(string())
}),
(form) => {
if (form.password !== form.confirmPassword) {
return {
confirmPassword: 'passwordMismatch'
}
}
return undefined
}
),
[passwordConstraints]
)

const form = useForm(
passwordForm,
() => ({ password: '', confirmPassword: '' }),
i18n.validationErrors
{
passwordMismatch: t.passwordMismatch,
...i18n.validationErrors
}
)
const { password, confirmPassword } = useFormFields(form)
const pattern = `.{${minLength},${maxLength}}`
const pattern = `.{${passwordConstraints.minLength},${passwordConstraints.maxLength}}`

const [isUnacceptable, setUnacceptable] = useState<boolean>(false)

const onFailure = useCallback(
(failure: Failure<unknown>) => {
setUnacceptable(failure.errorCode === 'PASSWORD_UNACCEPTABLE')
},
[setUnacceptable]
)

// clear error when password is updated
useEffect(
() => setUnacceptable(false),
[form.state.password, setUnacceptable]
)

return (
<MutateFormModal
data-qa="weak-credentials-modal"
Expand All @@ -260,8 +293,9 @@ const WeakCredentialsFormModal = React.memo(function WeakCredentialsFormModal({
}
})}
rejectAction={onCancel}
onFailure={onFailure}
onSuccess={onSuccess}
resolveDisabled={!form.isValid()}
resolveDisabled={!form.isValid() || isUnacceptable}
>
<form onClick={(e) => e.preventDefault()}>
<FixedSpaceColumn spacing="xs">
Expand Down Expand Up @@ -300,6 +334,39 @@ const WeakCredentialsFormModal = React.memo(function WeakCredentialsFormModal({
hideErrorsBeforeTouched={true}
pattern={pattern}
/>
<Gap size="xs" />
<LabelLike>{`${t.passwordConstraints.label}:`}</LabelLike>
<ConstraintsList>
<li>
{t.passwordConstraints.length(
passwordConstraints.minLength,
passwordConstraints.maxLength
)}
</li>
{passwordConstraints.minLowers > 0 && (
<li>
{t.passwordConstraints.minLowers(passwordConstraints.minLowers)}
</li>
)}
{passwordConstraints.minUppers > 0 && (
<li>
{t.passwordConstraints.minUppers(passwordConstraints.minUppers)}
</li>
)}
{passwordConstraints.minDigits > 0 && (
<li>
{t.passwordConstraints.minDigits(passwordConstraints.minDigits)}
</li>
)}
{passwordConstraints.minSymbols > 0 && (
<li>
{t.passwordConstraints.minSymbols(
passwordConstraints.minSymbols
)}
</li>
)}
</ConstraintsList>
{isUnacceptable && <AlertBox message={t.unacceptablePassword} />}
</FixedSpaceColumn>
</form>
</MutateFormModal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import NotificationSettingsSection from './NotificationSettingsSection'
import PersonalDetailsSection from './PersonalDetailsSection'
import {
emailVerificationStatusQuery,
notificationSettingsQuery
notificationSettingsQuery,
passwordConstraintsQuery
} from './queries'

export default React.memo(function PersonalDetails() {
Expand All @@ -34,6 +35,7 @@ export default React.memo(function PersonalDetails() {
const notificationSettings = useQueryResult(notificationSettingsQuery())
const notificationSettingsSection = useRef<HTMLDivElement>(null)
const emailVerificationStatus = useQueryResult(emailVerificationStatusQuery())
const passwordConstraints = useQueryResult(passwordConstraintsQuery())

useEffect(() => {
if (
Expand Down Expand Up @@ -78,15 +80,16 @@ export default React.memo(function PersonalDetails() {
</>
))}
{renderResult(
combine(user, emailVerificationStatus),
([user, emailVerificationStatus]) =>
combine(user, emailVerificationStatus, passwordConstraints),
([user, emailVerificationStatus, passwordConstraints]) =>
user ? (
<>
{(!!user.keycloakEmail || featureFlags.weakLogin) && (
<>
<HorizontalLine />
<LoginDetailsSection
user={user}
passwordConstraints={passwordConstraints}
emailVerificationStatus={emailVerificationStatus}
reloadUser={refreshAuthStatus}
/>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/citizen-frontend/personal-details/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Queries } from 'lib-common/query'
import {
getEmailVerificationStatus,
getNotificationSettings,
getPasswordConstraints,
sendEmailVerificationCode,
updateNotificationSettings,
updatePersonalData,
Expand Down Expand Up @@ -41,3 +42,5 @@ export const sendEmailVerificationCodeMutation = q.mutation(
export const verifyEmailMutation = q.mutation(verifyEmail, [
emailVerificationStatusQuery
])

export const passwordConstraintsQuery = q.query(getPasswordConstraints)
12 changes: 12 additions & 0 deletions frontend/src/lib-common/generated/api-types/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,18 @@ export type ParentshipId = Id<'Parentship'>

export type PartnershipId = Id<'Partnership'>

/**
* Generated from fi.espoo.evaka.shared.auth.PasswordConstraints
*/
export interface PasswordConstraints {
maxLength: number
minDigits: number
minLength: number
minLowers: number
minSymbols: number
minUppers: number
}

export type PaymentId = Id<'Payment'>

export type PedagogicalDocumentId = Id<'PedagogicalDocument'>
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/lib-common/password.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2017-2025 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import { PasswordConstraints } from './generated/api-types/shared'
import { isPasswordStructureValid } from './password'

describe('isPasswordStructureValid', () => {
const unconstrained: PasswordConstraints = {
minLength: 1,
maxLength: 128,
minDigits: 0,
minLowers: 0,
minSymbols: 0,
minUppers: 0
}
it('checks minLength correctly', () => {
const constraints = { ...unconstrained, minLength: 4 }
expect(isPasswordStructureValid(constraints, '123')).toBeFalsy()
expect(isPasswordStructureValid(constraints, '1234')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '12345')).toBeTruthy()
})
it('checks maxLength correctly', () => {
const constraints = { ...unconstrained, maxLength: 4 }
expect(isPasswordStructureValid(constraints, '12345')).toBeFalsy()
expect(isPasswordStructureValid(constraints, '1234')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '123')).toBeTruthy()
})
it('checks minLowers correctly', () => {
const constraints = { ...unconstrained, minLowers: 1 }
expect(isPasswordStructureValid(constraints, '1_2')).toBeFalsy()
expect(isPasswordStructureValid(constraints, '1A2')).toBeFalsy()
expect(isPasswordStructureValid(constraints, '1a2')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '1ab')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '1ä2')).toBeTruthy()
})
it('checks minUppers correctly', () => {
const constraints = { ...unconstrained, minUppers: 1 }
expect(isPasswordStructureValid(constraints, '1_2')).toBeFalsy()
expect(isPasswordStructureValid(constraints, '1a2')).toBeFalsy()
expect(isPasswordStructureValid(constraints, '1A2')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '1AB')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '1Ä2')).toBeTruthy()
})
it('checks minDigits correctly', () => {
const constraints = { ...unconstrained, minDigits: 1 }
expect(isPasswordStructureValid(constraints, 'abc')).toBeFalsy()
expect(isPasswordStructureValid(constraints, 'a1c')).toBeTruthy()
expect(isPasswordStructureValid(constraints, 'a12')).toBeTruthy()
})
it('checks minSymbols correctly', () => {
const constraints = { ...unconstrained, minSymbols: 1 }
expect(isPasswordStructureValid(constraints, '123')).toBeFalsy()
expect(isPasswordStructureValid(constraints, 'abc')).toBeFalsy()
expect(isPasswordStructureValid(constraints, 'a#c')).toBeTruthy()
expect(isPasswordStructureValid(constraints, 'a#2')).toBeTruthy()
expect(isPasswordStructureValid(constraints, '💩')).toBeTruthy()
})
})
29 changes: 29 additions & 0 deletions frontend/src/lib-common/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2017-2025 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import { PasswordConstraints } from './generated/api-types/shared'

export function isPasswordStructureValid(
constraints: PasswordConstraints,
password: string
) {
function count(iter: Iterator<RegExpMatchArray>) {
let result = iter.next()
let count = 0
while (!result.done) {
count += 1
result = iter.next()
}
return count
}
if (password.length < constraints.minLength) return false
if (password.length > constraints.maxLength) return false
// \p{...} check unicode categories: https://unicode.org/reports/tr18/#General_Category_Property
if (count(password.matchAll(/\p{Ll}/gu)) < constraints.minLowers) return false
if (count(password.matchAll(/\p{Lu}/gu)) < constraints.minUppers) return false
if (count(password.matchAll(/\p{Nd}/gu)) < constraints.minDigits) return false
if (count(password.matchAll(/[^\p{L}\p{N}]/gu)) < constraints.minSymbols)
return false
return true
}
Loading

0 comments on commit 53da94b

Please sign in to comment.