Skip to content

Commit

Permalink
Merge pull request #6252 from espoon-voltti/citizen-password-validation
Browse files Browse the repository at this point in the history
Kuntalaisen salasanan parempi validointi
  • Loading branch information
Gekkio authored Jan 27, 2025
2 parents 4e59d13 + 9434ef2 commit 50fff8a
Show file tree
Hide file tree
Showing 34 changed files with 882 additions and 56 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
127 changes: 99 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, 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,54 @@ 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
: 'passwordFormat'
),
confirmPassword: required(string())
}),
(form) =>
form.password !== form.confirmPassword
? { confirmPassword: 'passwordMismatch' }
: undefined
),
[passwordConstraints]
)

const form = useForm(
passwordForm,
() => ({ password: '', confirmPassword: '' }),
i18n.validationErrors
{
...i18n.validationErrors,
...t.validationErrors
},
{
// clear error when password is updated
onUpdate: (prev, next, _) => {
if (prev.password !== next.password) {
setUnacceptable(false)
}
return next
}
}
)
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]
)

return (
<MutateFormModal
data-qa="weak-credentials-modal"
Expand All @@ -260,8 +292,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 +333,44 @@ 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
data-qa="unacceptable-password-alert"
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)
23 changes: 23 additions & 0 deletions frontend/src/e2e-test/generated/api-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2455,6 +2455,29 @@ export async function terminatePlacement(
}


/**
* Generated from fi.espoo.evaka.shared.dev.DevApi.upsertPasswordBlacklist
*/
export async function upsertPasswordBlacklist(
request: {
body: string[]
},
options?: { mockedTime?: HelsinkiDateTime }
): Promise<void> {
try {
const { data: json } = await devClient.request<JsonOf<void>>({
url: uri`/password-blacklist`.toString(),
method: 'PUT',
headers: { EvakaMockedTime: options?.mockedTime?.formatIso() },
data: request.body satisfies JsonCompatible<string[]>
})
return json
} catch (e) {
throw new DevApiError(e)
}
}


/**
* Generated from fi.espoo.evaka.shared.dev.DevApi.upsertPerson
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,22 @@ export class LoginDetailsSection extends Element {
export class WeakCredentialsModal extends Element {
username: Element
password: TextInput
passwordInfo: Element
confirmPassword: TextInput
confirmPasswordInfo: Element
unacceptablePasswordAlert: Element
ok: Element

constructor(page: Page) {
super(page.findByDataQa('weak-credentials-modal'))
this.username = this.findByDataQa('username')
this.password = new TextInput(this.findByDataQa('password'))
this.passwordInfo = this.findByDataQa('password-info')
this.confirmPassword = new TextInput(this.findByDataQa('confirm-password'))
this.confirmPasswordInfo = this.findByDataQa('confirm-password-info')
this.unacceptablePasswordAlert = this.findByDataQa(
'unacceptable-password-alert'
)
this.ok = this.findByDataQa('modal-okBtn')
}
}
Expand Down
Loading

0 comments on commit 50fff8a

Please sign in to comment.