diff --git a/frontend/src/citizen-frontend/generated/api-clients/pis.ts b/frontend/src/citizen-frontend/generated/api-clients/pis.ts index 6ef4d894a0..83917ffa04 100644 --- a/frontend/src/citizen-frontend/generated/api-clients/pis.ts +++ b/frontend/src/citizen-frontend/generated/api-clients/pis.ts @@ -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' @@ -40,6 +41,18 @@ export async function getNotificationSettings(): Promise { } +/** +* Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.getPasswordConstraints +*/ +export async function getPasswordConstraints(): Promise { + const { data: json } = await client.request>({ + url: uri`/citizen/personal-data/password-constraints`.toString(), + method: 'GET' + }) + return json +} + + /** * Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.sendEmailVerificationCode */ diff --git a/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx b/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx index ae1283d4ba..c5aac82757 100644 --- a/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx +++ b/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx @@ -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' @@ -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' @@ -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 @@ -156,6 +161,7 @@ export default React.memo(function LoginDetailsSection({ <> {modalOpen && ( { - 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 @@ -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(false) + + const onFailure = useCallback( + (failure: Failure) => { + setUnacceptable(failure.errorCode === 'PASSWORD_UNACCEPTABLE') + }, + [setUnacceptable] + ) + return (
e.preventDefault()}> @@ -300,6 +333,44 @@ const WeakCredentialsFormModal = React.memo(function WeakCredentialsFormModal({ hideErrorsBeforeTouched={true} pattern={pattern} /> + + {`${t.passwordConstraints.label}:`} + +
  • + {t.passwordConstraints.length( + passwordConstraints.minLength, + passwordConstraints.maxLength + )} +
  • + {passwordConstraints.minLowers > 0 && ( +
  • + {t.passwordConstraints.minLowers(passwordConstraints.minLowers)} +
  • + )} + {passwordConstraints.minUppers > 0 && ( +
  • + {t.passwordConstraints.minUppers(passwordConstraints.minUppers)} +
  • + )} + {passwordConstraints.minDigits > 0 && ( +
  • + {t.passwordConstraints.minDigits(passwordConstraints.minDigits)} +
  • + )} + {passwordConstraints.minSymbols > 0 && ( +
  • + {t.passwordConstraints.minSymbols( + passwordConstraints.minSymbols + )} +
  • + )} +
    + {isUnacceptable && ( + + )}
    diff --git a/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx b/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx index a5dd647920..68b0d0ca80 100644 --- a/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx +++ b/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx @@ -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() { @@ -34,6 +35,7 @@ export default React.memo(function PersonalDetails() { const notificationSettings = useQueryResult(notificationSettingsQuery()) const notificationSettingsSection = useRef(null) const emailVerificationStatus = useQueryResult(emailVerificationStatusQuery()) + const passwordConstraints = useQueryResult(passwordConstraintsQuery()) useEffect(() => { if ( @@ -78,8 +80,8 @@ export default React.memo(function PersonalDetails() { ))} {renderResult( - combine(user, emailVerificationStatus), - ([user, emailVerificationStatus]) => + combine(user, emailVerificationStatus, passwordConstraints), + ([user, emailVerificationStatus, passwordConstraints]) => user ? ( <> {(!!user.keycloakEmail || featureFlags.weakLogin) && ( @@ -87,6 +89,7 @@ export default React.memo(function PersonalDetails() { diff --git a/frontend/src/citizen-frontend/personal-details/queries.ts b/frontend/src/citizen-frontend/personal-details/queries.ts index 8ba0975b0f..d094d4509a 100644 --- a/frontend/src/citizen-frontend/personal-details/queries.ts +++ b/frontend/src/citizen-frontend/personal-details/queries.ts @@ -7,6 +7,7 @@ import { Queries } from 'lib-common/query' import { getEmailVerificationStatus, getNotificationSettings, + getPasswordConstraints, sendEmailVerificationCode, updateNotificationSettings, updatePersonalData, @@ -41,3 +42,5 @@ export const sendEmailVerificationCodeMutation = q.mutation( export const verifyEmailMutation = q.mutation(verifyEmail, [ emailVerificationStatusQuery ]) + +export const passwordConstraintsQuery = q.query(getPasswordConstraints) diff --git a/frontend/src/e2e-test/generated/api-clients.ts b/frontend/src/e2e-test/generated/api-clients.ts index d16c9df965..e3b204b13e 100644 --- a/frontend/src/e2e-test/generated/api-clients.ts +++ b/frontend/src/e2e-test/generated/api-clients.ts @@ -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 { + try { + const { data: json } = await devClient.request>({ + url: uri`/password-blacklist`.toString(), + method: 'PUT', + headers: { EvakaMockedTime: options?.mockedTime?.formatIso() }, + data: request.body satisfies JsonCompatible + }) + return json + } catch (e) { + throw new DevApiError(e) + } +} + + /** * Generated from fi.espoo.evaka.shared.dev.DevApi.upsertPerson */ diff --git a/frontend/src/e2e-test/pages/citizen/citizen-personal-details.ts b/frontend/src/e2e-test/pages/citizen/citizen-personal-details.ts index 969cddf584..a1a8a2c951 100644 --- a/frontend/src/e2e-test/pages/citizen/citizen-personal-details.ts +++ b/frontend/src/e2e-test/pages/citizen/citizen-personal-details.ts @@ -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') } } diff --git a/frontend/src/e2e-test/specs/0_citizen/citizen-weak-credentials.spec.ts b/frontend/src/e2e-test/specs/0_citizen/citizen-weak-credentials.spec.ts index df207ba7ec..18d5056591 100644 --- a/frontend/src/e2e-test/specs/0_citizen/citizen-weak-credentials.spec.ts +++ b/frontend/src/e2e-test/specs/0_citizen/citizen-weak-credentials.spec.ts @@ -5,7 +5,11 @@ import HelsinkiDateTime from 'lib-common/helsinki-date-time' import { Fixture } from '../../dev-api/fixtures' -import { resetServiceState, runJobs } from '../../generated/api-clients' +import { + resetServiceState, + runJobs, + upsertPasswordBlacklist +} from '../../generated/api-clients' import { DevPerson } from '../../generated/api-types' import CitizenHeader from '../../pages/citizen/citizen-header' import CitizenPersonalDetailsPage, { @@ -164,6 +168,53 @@ describe('Citizen weak credentials', () => { newEmail ) }) + test('a new password must be valid and acceptable', async () => { + const email = 'test@example.com' + const validPassword = 'aifiefaeC3io?dee' + const invalidPassword = 'wrong2short' + const unacceptablePassword = 'TestPassword123!' + const citizen = await Fixture.person({ + email, + verifiedEmail: email + }).saveAdult({ + updateMockVtjWithDependants: [], + updateWeakCredentials: { + username: email, + password: validPassword + } + }) + await upsertPasswordBlacklist({ body: [unacceptablePassword] }) + page = await Page.open({ mockedTime }) + + const personalDetailsPage = await openPersonalDetailsPage(citizen) + await personalDetailsPage.personalDetailsSection.verifiedEmailStatus.waitUntilVisible() + const section = personalDetailsPage.loginDetailsSection + await section.weakLoginEnabled.waitUntilVisible() + await section.updatePassword.click() + + const modal = new WeakCredentialsModal(page) + await modal.password.fill(invalidPassword) + await modal.password.blur() + await modal.passwordInfo.assertTextEquals('Salasana ei täytä vaatimuksia') + await modal.ok.assertDisabled(true) + await modal.password.fill(unacceptablePassword) + await modal.confirmPassword.fill(invalidPassword) + await modal.confirmPassword.blur() + await modal.confirmPasswordInfo.assertTextEquals('Salasanat eivät täsmää') + await modal.ok.assertDisabled(true) + await modal.confirmPassword.fill(unacceptablePassword) + await modal.ok.click() + await modal.unacceptablePasswordAlert.waitUntilVisible() + + await modal.password.fill(validPassword) + await modal.confirmPassword.fill(validPassword) + await modal.confirmPassword.blur() + await modal.passwordInfo.waitUntilHidden() + await modal.confirmPasswordInfo.waitUntilHidden() + await modal.unacceptablePasswordAlert.waitUntilHidden() + await modal.ok.click() + await modal.waitUntilHidden() + }) }) async function openPersonalDetailsPage(citizen: DevPerson) { diff --git a/frontend/src/lib-common/generated/api-types/shared.ts b/frontend/src/lib-common/generated/api-types/shared.ts index fb29d8fc40..9048aa98ca 100644 --- a/frontend/src/lib-common/generated/api-types/shared.ts +++ b/frontend/src/lib-common/generated/api-types/shared.ts @@ -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'> diff --git a/frontend/src/lib-common/password.spec.ts b/frontend/src/lib-common/password.spec.ts new file mode 100644 index 0000000000..649ac3778d --- /dev/null +++ b/frontend/src/lib-common/password.spec.ts @@ -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() + }) +}) diff --git a/frontend/src/lib-common/password.ts b/frontend/src/lib-common/password.ts new file mode 100644 index 0000000000..807e5d1026 --- /dev/null +++ b/frontend/src/lib-common/password.ts @@ -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) { + 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 +} diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx index 6288b722d0..e3da1065eb 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx @@ -1863,7 +1863,24 @@ const en: Translations = { activationSuccess: 'Email login has been enabled', activationSuccessOk: 'Okay', confirmPassword: 'Confirm password', - confirmActivateCredentials: 'Enable' + confirmActivateCredentials: 'Enable', + validationErrors: { + passwordFormat: 'The password does not meet the requirements', + passwordMismatch: 'The passwords do not match' + }, + passwordConstraints: { + label: 'Password requirements', + length: (min: number, _max: number) => + `length at least ${min} characters`, + minLowers: (v: number) => + `at least ${v} ${v > 1 ? 'lowercase letters' : 'lowercase letter'}`, + minUppers: (v: number) => + `at least ${v} ${v > 1 ? 'uppercase letters' : 'uppercase letter'}`, + minDigits: (v: number) => `at least ${v} ${v > 1 ? 'digits' : 'digit'}`, + minSymbols: (v: number) => + `at least ${v} ${v > 1 ? 'special characters' : 'special character'}` + }, + unacceptablePassword: 'The password is too easy to guess' }, notificationsSection: { title: 'Email notifications', diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx index b67d78711b..73777c7a90 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx @@ -2102,7 +2102,25 @@ export default { activationSuccess: 'Sähköpostilla kirjautuminen otettu käyttöön', activationSuccessOk: 'Selvä', confirmPassword: 'Vahvista salasana', - confirmActivateCredentials: 'Ota käyttöön' + confirmActivateCredentials: 'Ota käyttöön', + validationErrors: { + passwordFormat: 'Salasana ei täytä vaatimuksia', + passwordMismatch: 'Salasanat eivät täsmää' + }, + passwordConstraints: { + label: 'Salasanavaatimukset', + length: (min: number, _max: number) => + `pituus vähintään ${min} merkkiä`, + minLowers: (v: number) => + `vähintään ${v} ${v > 1 ? 'pientä kirjainta' : 'pieni kirjain'}`, + minUppers: (v: number) => + `vähintään ${v} ${v > 1 ? 'isoa kirjainta' : 'iso kirjain'}`, + minDigits: (v: number) => + `vähintään ${v} ${v > 1 ? 'numeroa' : 'numero'}`, + minSymbols: (v: number) => + `vähintään ${v} ${v > 1 ? 'erikoismerkkiä' : 'erikoismerkki'}` + }, + unacceptablePassword: 'Salasana on liian helposti arvattava' }, notificationsSection: { title: 'Sähköposti-ilmoitukset', diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx index 6c7ded358a..94f415f84c 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx @@ -2101,7 +2101,24 @@ const sv: Translations = { activationSuccess: 'E-postinloggning aktiverad', activationSuccessOk: 'Klart', confirmPassword: 'Bekräfta lösenordet', - confirmActivateCredentials: 'Aktivera' + confirmActivateCredentials: 'Aktivera', + validationErrors: { + passwordFormat: 'Lösenordet uppfyller inte kraven', + passwordMismatch: 'Lösenorden stämmer inte överens' + }, + passwordConstraints: { + label: 'Krav på lösenord', + length: (min: number, _max: number) => `längd minst ${min} tecken`, + minLowers: (v: number) => + `minst ${v} ${v > 1 ? 'gemena bokstäver' : 'gemen bokstav'}`, + minUppers: (v: number) => + `minst ${v} ${v > 1 ? 'stora bokstäver' : 'stor bokstav'}`, + minDigits: (v: number) => + `${v} eller fler ${v > 1 ? 'siffror' : 'siffra'}`, + minSymbols: (v: number) => + `${v} eller ${v > 1 ? 'flera specialtecken' : 'fler specialtecken'}` + }, + unacceptablePassword: 'Lösenordet är för lätt att gissa' }, notificationsSection: { title: 'E-postmeddelanden', diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/controller/WeakCredentialsIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/controller/WeakCredentialsIntegrationTest.kt index f43ddf8b05..fc99ecb883 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/controller/WeakCredentialsIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/controller/WeakCredentialsIntegrationTest.kt @@ -8,17 +8,17 @@ import fi.espoo.evaka.FullApplicationTest import fi.espoo.evaka.Sensitive import fi.espoo.evaka.pis.SystemController import fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen -import fi.espoo.evaka.shared.auth.AuthenticatedUser -import fi.espoo.evaka.shared.auth.CitizenAuthLevel -import fi.espoo.evaka.shared.auth.PasswordService +import fi.espoo.evaka.shared.auth.* import fi.espoo.evaka.shared.dev.DevCitizenUser import fi.espoo.evaka.shared.dev.DevPerson import fi.espoo.evaka.shared.dev.DevPersonType import fi.espoo.evaka.shared.dev.insert +import fi.espoo.evaka.shared.domain.BadRequest import fi.espoo.evaka.shared.domain.MockEvakaClock import fi.espoo.evaka.vtjclient.service.persondetails.MockPersonDetailsService import kotlin.test.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired class WeakCredentialsIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) { @@ -118,6 +118,50 @@ class WeakCredentialsIntegrationTest : FullApplicationTest(resetDbBeforeEach = t assertEquals(person.id, identity.id) } + @Test + fun `a person can't use a password that does not match constraints`() { + db.transaction { tx -> tx.insert(person, DevPersonType.ADULT) } + MockPersonDetailsService.addPersons(person) + + citizenStrongLogin() + val password = Sensitive("nope") + val error = + assertThrows { + updateWeakLoginCredentials( + PersonalDataControllerCitizen.UpdateWeakLoginCredentialsRequest( + username = email, + password = password, + ) + ) + } + assertEquals("PASSWORD_FORMAT", error.errorCode) + } + + @Test + fun `a person can't use a password that is blacklisted`() { + val password = Sensitive("ValidButBlacklisted123!!") + db.transaction { tx -> + tx.insert(person, DevPersonType.ADULT) + tx.upsertPasswordBlacklist( + PasswordBlacklistSource("test", clock.now()), + sequenceOf(password.value), + ) + } + MockPersonDetailsService.addPersons(person) + + citizenStrongLogin() + val error = + assertThrows { + updateWeakLoginCredentials( + PersonalDataControllerCitizen.UpdateWeakLoginCredentialsRequest( + username = email, + password = password, + ) + ) + } + assertEquals("PASSWORD_UNACCEPTABLE", error.errorCode) + } + private fun updateWeakLoginCredentials( request: PersonalDataControllerCitizen.UpdateWeakLoginCredentialsRequest ) = controller.updateWeakLoginCredentials(dbInstance(), user, clock, request) diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklistIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklistIntegrationTest.kt new file mode 100644 index 0000000000..69a98c188e --- /dev/null +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklistIntegrationTest.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import fi.espoo.evaka.FullApplicationTest +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.shared.domain.HelsinkiDateTime +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +class PasswordBlacklistIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) { + @Autowired private lateinit var passwordBlacklist: PasswordBlacklist + + private val directory = + Path.of( + this.javaClass.classLoader.getResource("password-blacklist")?.toURI() + ?: error("Failed to locate password blacklist directory") + ) + private val blacklistedPassword = Sensitive("Password123!") + + @BeforeEach + fun beforeEach() { + val importCount = passwordBlacklist.importBlacklists(db, directory) + assertEquals(2, importCount) + assertTrue(db.read { it.isPasswordBlacklisted(blacklistedPassword) }) + } + + @Test + fun `unchanged files are skipped`() { + val importCount = passwordBlacklist.importBlacklists(db, directory) + assertEquals(0, importCount) + } + + @Test + fun `the same password can be imported multiple times from different sources`() { + db.transaction { tx -> + val source = + PasswordBlacklistSource(name = "someotherfile", updatedAt = HelsinkiDateTime.now()) + val importCount = + passwordBlacklist.importPasswords(tx, source, sequenceOf(blacklistedPassword.value)) + assertEquals(1, importCount) + } + } +} diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt index 0cbbfb38a9..c311f5eb38 100755 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt @@ -13,6 +13,7 @@ import fi.espoo.evaka.EvakaEnv import fi.espoo.evaka.TestInvoiceProductProvider import fi.espoo.evaka.emailclient.EvakaEmailMessageProvider import fi.espoo.evaka.emailclient.IEmailMessageProvider +import fi.espoo.evaka.espoo.DefaultPasswordSpecification import fi.espoo.evaka.espoo.invoicing.EspooIncomeCoefficientMultiplierProvider import fi.espoo.evaka.invoicing.domain.PaymentIntegrationClient import fi.espoo.evaka.invoicing.integration.InvoiceIntegrationClient @@ -27,6 +28,8 @@ import fi.espoo.evaka.reports.patu.PatuIntegrationClient import fi.espoo.evaka.shared.ArchiveProcessConfig import fi.espoo.evaka.shared.ArchiveProcessType import fi.espoo.evaka.shared.FeatureConfig +import fi.espoo.evaka.shared.auth.PasswordConstraints +import fi.espoo.evaka.shared.auth.PasswordSpecification import fi.espoo.evaka.shared.auth.UserRole import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.db.configureJdbi @@ -209,6 +212,12 @@ class SharedIntegrationTestConfig { } @Bean fun mealTypeMapper(): MealTypeMapper = DefaultMealTypeMapper + + @Bean + fun passwordSpecification(): PasswordSpecification = + DefaultPasswordSpecification( + PasswordConstraints.UNCONSTRAINED.copy(minLength = 8, minDigits = 1) + ) } val testFeatureConfig = diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt index a3c08fdcec..7652ea36fc 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt @@ -513,6 +513,9 @@ class SchemaConventionsTest : PureJdbiTest(resetDbBeforeEach = false) { ColumnRef("pairing", "employee_id"), ColumnRef("pairing", "mobile_device_id"), ColumnRef("pairing", "unit_id"), + // this is intentional, because the table may have a large number of rows (> 10 + // million) + ColumnRef("password_blacklist", "source"), ColumnRef("payment", "sent_by"), ColumnRef("placement", "modified_by"), ColumnRef("placement", "terminated_by"), diff --git a/service/src/integrationTest/resources/password-blacklist/blacklist.txt b/service/src/integrationTest/resources/password-blacklist/blacklist.txt new file mode 100644 index 0000000000..9595edf4bb --- /dev/null +++ b/service/src/integrationTest/resources/password-blacklist/blacklist.txt @@ -0,0 +1,3 @@ +Password123! + +Hunter2? \ No newline at end of file diff --git a/service/src/integrationTest/resources/password-blacklist/blacklist.txt.license b/service/src/integrationTest/resources/password-blacklist/blacklist.txt.license new file mode 100644 index 0000000000..40a23550c1 --- /dev/null +++ b/service/src/integrationTest/resources/password-blacklist/blacklist.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2017-2024 City of Espoo + +SPDX-License-Identifier: LGPL-2.1-or-later \ No newline at end of file diff --git a/service/src/main/kotlin/fi/espoo/evaka/EspooConfig.kt b/service/src/main/kotlin/fi/espoo/evaka/EspooConfig.kt index 78bb57a553..131066686d 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/EspooConfig.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/EspooConfig.kt @@ -7,11 +7,7 @@ package fi.espoo.evaka import com.fasterxml.jackson.databind.json.JsonMapper import fi.espoo.evaka.emailclient.EvakaEmailMessageProvider import fi.espoo.evaka.emailclient.IEmailMessageProvider -import fi.espoo.evaka.espoo.EspooActionRuleMapping -import fi.espoo.evaka.espoo.EspooAsyncJob -import fi.espoo.evaka.espoo.EspooAsyncJobRegistration -import fi.espoo.evaka.espoo.EspooScheduledJob -import fi.espoo.evaka.espoo.EspooScheduledJobs +import fi.espoo.evaka.espoo.* import fi.espoo.evaka.espoo.bi.EspooBiClient import fi.espoo.evaka.espoo.bi.EspooBiHttpClient import fi.espoo.evaka.espoo.bi.EspooBiJob @@ -39,6 +35,8 @@ import fi.espoo.evaka.shared.ArchiveProcessType import fi.espoo.evaka.shared.FeatureConfig import fi.espoo.evaka.shared.async.AsyncJob import fi.espoo.evaka.shared.async.AsyncJobRunner +import fi.espoo.evaka.shared.auth.PasswordConstraints +import fi.espoo.evaka.shared.auth.PasswordSpecification import fi.espoo.evaka.shared.db.DevDataInitializer import fi.espoo.evaka.shared.message.EvakaMessageProvider import fi.espoo.evaka.shared.message.IMessageProvider @@ -253,6 +251,18 @@ class EspooConfig { ): EspooScheduledJobs = EspooScheduledJobs(patuReportingService, espooAsyncJobRunner, env) @Bean fun espooMealTypeMapper(): MealTypeMapper = DefaultMealTypeMapper + + @Bean + fun espooPasswordSpecification(): PasswordSpecification = + DefaultPasswordSpecification( + PasswordConstraints.UNCONSTRAINED.copy( + minLength = 15, + minLowers = 1, + minUppers = 1, + minDigits = 1, + minSymbols = 1, + ) + ) } data class EspooEnv( diff --git a/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt b/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt index 858390fc65..2abfb8be46 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt @@ -11,6 +11,7 @@ import fi.espoo.evaka.shared.job.JobSchedule import fi.espoo.evaka.shared.job.ScheduledJobSettings import io.github.oshai.kotlinlogging.KotlinLogging import java.net.URI +import java.nio.file.Path import java.security.KeyStore import java.time.Duration import java.time.LocalDate @@ -42,6 +43,7 @@ data class EvakaEnv( val personAddressEnvelopeWindowPosition: Rectangle, val replacementInvoicesStart: YearMonth?, val newCitizenWeakLoginEnabled: Boolean, + val passwordBlacklistDirectory: Path?, ) { companion object { fun fromEnvironment(env: Environment): EvakaEnv { @@ -79,6 +81,7 @@ data class EvakaEnv( }, newCitizenWeakLoginEnabled = env.lookup("evaka.new_citizen_weak_login.enabled") ?: false, + passwordBlacklistDirectory = env.lookup("evaka.password_blacklist_directory"), ) } } diff --git a/service/src/main/kotlin/fi/espoo/evaka/espoo/DefaultPasswordSpecification.kt b/service/src/main/kotlin/fi/espoo/evaka/espoo/DefaultPasswordSpecification.kt new file mode 100644 index 0000000000..b093ef6ed1 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/espoo/DefaultPasswordSpecification.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.espoo + +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.shared.auth.PasswordConstraints +import fi.espoo.evaka.shared.auth.PasswordSpecification +import fi.espoo.evaka.shared.auth.isPasswordBlacklisted +import fi.espoo.evaka.shared.db.Database + +class DefaultPasswordSpecification(private val constraints: PasswordConstraints) : + PasswordSpecification { + override fun constraints(): PasswordConstraints = constraints + + override fun isPasswordAcceptable( + dbc: Database.Connection, + password: Sensitive, + ): Boolean = dbc.read { !it.isPasswordBlacklisted(password) } +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/pis/SystemController.kt b/service/src/main/kotlin/fi/espoo/evaka/pis/SystemController.kt index dd06bc6a69..d9e63d6918 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/pis/SystemController.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/pis/SystemController.kt @@ -18,10 +18,7 @@ import fi.espoo.evaka.shared.EmployeeId import fi.espoo.evaka.shared.FeatureConfig import fi.espoo.evaka.shared.MobileDeviceId import fi.espoo.evaka.shared.PersonId -import fi.espoo.evaka.shared.auth.AuthenticatedUser -import fi.espoo.evaka.shared.auth.CitizenAuthLevel -import fi.espoo.evaka.shared.auth.PasswordService -import fi.espoo.evaka.shared.auth.UserRole +import fi.espoo.evaka.shared.auth.* import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.domain.BadRequest import fi.espoo.evaka.shared.domain.EvakaClock @@ -440,7 +437,13 @@ class SystemController( val keycloakEmail: String?, ) - data class CitizenWeakLoginRequest(val username: String, val password: Sensitive) + data class CitizenWeakLoginRequest(val username: String, val password: Sensitive) { + init { + if (password.value.length !in PasswordConstraints.SUPPORTED_LENGTH) { + throw BadRequest("Invalid password length") + } + } + } data class EmployeeUserResponse( val id: EmployeeId, diff --git a/service/src/main/kotlin/fi/espoo/evaka/pis/controllers/PersonalDataControllerCitizen.kt b/service/src/main/kotlin/fi/espoo/evaka/pis/controllers/PersonalDataControllerCitizen.kt index 353364382e..931f0fa036 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/pis/controllers/PersonalDataControllerCitizen.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/pis/controllers/PersonalDataControllerCitizen.kt @@ -13,7 +13,9 @@ import fi.espoo.evaka.shared.PersonEmailVerificationId import fi.espoo.evaka.shared.async.AsyncJob import fi.espoo.evaka.shared.async.AsyncJobRunner import fi.espoo.evaka.shared.auth.AuthenticatedUser +import fi.espoo.evaka.shared.auth.PasswordConstraints import fi.espoo.evaka.shared.auth.PasswordService +import fi.espoo.evaka.shared.auth.PasswordSpecification import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.domain.BadRequest import fi.espoo.evaka.shared.domain.EvakaClock @@ -39,6 +41,7 @@ class PersonalDataControllerCitizen( private val accessControl: AccessControl, private val passwordService: PasswordService, private val asyncJobRunner: AsyncJobRunner, + private val passwordSpecification: PasswordSpecification, private val env: EvakaEnv, ) { private val secureRandom = SecureRandom() @@ -141,10 +144,7 @@ class PersonalDataControllerCitizen( ) { init { if ( - password != null && - (password.value.isEmpty() || - password.value.length < 8 || - password.value.length > 128) + password != null && password.value.length !in PasswordConstraints.SUPPORTED_LENGTH ) { throw BadRequest("Invalid password") } @@ -160,8 +160,18 @@ class PersonalDataControllerCitizen( ) { if (!env.newCitizenWeakLoginEnabled) throw BadRequest("New citizen weak login is disabled") Audit.CitizenCredentialsUpdateAttempt.log(targetId = AuditId(user.id)) + if (body.password != null) { + if (!passwordSpecification.constraints().isPasswordStructureValid(body.password)) { + throw BadRequest("Password is not structurally valid", "PASSWORD_FORMAT") + } + } val password = body.password?.let { passwordService.encode(it) } db.connect { dbc -> + if (body.password != null) { + if (!passwordSpecification.isPasswordAcceptable(dbc, body.password)) { + throw BadRequest("Password is not acceptable", "PASSWORD_UNACCEPTABLE") + } + } dbc.transaction { tx -> accessControl.requirePermissionFor( tx, @@ -290,6 +300,10 @@ class PersonalDataControllerCitizen( generateSequence { "0123456789".random(secureRandom.asKotlinRandom()) } .take(CONFIRMATION_CODE_LENGTH) .joinToString(separator = "") + + @GetMapping("/password-constraints") + fun getPasswordConstraints(user: AuthenticatedUser.Citizen): PasswordConstraints = + passwordSpecification.constraints() } val CONFIRMATION_CODE_DURATION: Duration = Duration.ofHours(2) diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklist.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklist.kt new file mode 100644 index 0000000000..ebaed06fb7 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklist.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.shared.db.Database +import fi.espoo.evaka.shared.domain.HelsinkiDateTime +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.Path +import java.time.Instant +import org.springframework.stereotype.Component + +data class PasswordBlacklistSource(val name: String, val updatedAt: HelsinkiDateTime) + +@Component +class PasswordBlacklist(private val passwordSpecification: PasswordSpecification) { + private val logger = KotlinLogging.logger {} + + fun importBlacklists(dbc: Database.Connection, directory: Path): Int { + logger.info { "Importing password blacklists from $directory" } + val files = directory.toFile().listFiles { _, name -> name.endsWith(".txt") } ?: return 0 + + var total = 0 + for (file in files) { + val source = + PasswordBlacklistSource( + file.name, + HelsinkiDateTime.from(Instant.ofEpochMilli(file.lastModified())), + ) + total += + dbc.transaction { tx -> + importPasswords( + tx, + source, + file.bufferedReader(Charsets.UTF_8).lineSequence().map { it.trim() }, + ) + } + } + + logger.info { "Imported $total blacklisted passwords" } + return total + } + + fun importPasswords( + tx: Database.Transaction, + source: PasswordBlacklistSource, + passwords: Sequence, + ): Int = + if (tx.isBlacklistSourceUpToDate(source)) { + logger.info { "Skipping up-to-date blacklist $source" } + 0 + } else { + logger.info { "Importing passwords from $source" } + var total = 0 + for (chunk in + passwords + .filter { + passwordSpecification.constraints().isPasswordStructureValid(Sensitive(it)) + } + .chunked(100_000)) { + tx.upsertPasswordBlacklist(source, chunk.asSequence()) + total += chunk.size + } + total + } +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklistQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklistQueries.kt new file mode 100644 index 0000000000..15d726114e --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordBlacklistQueries.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.shared.db.Database + +fun Database.Read.isBlacklistSourceUpToDate(source: PasswordBlacklistSource): Boolean = + createQuery { + sql( + """ +SELECT EXISTS ( + SELECT FROM password_blacklist_source + WHERE name = ${bind(source.name)} + AND imported_at = ${bind(source.updatedAt)} +) +""" + ) + } + .exactlyOne() + +fun Database.Transaction.upsertPasswordBlacklist( + source: PasswordBlacklistSource, + passwords: Sequence, +) { + val sourceId: Int = + createUpdate { + sql( + """ +INSERT INTO password_blacklist_source (name, imported_at) +VALUES (${bind(source.name)}, ${bind(source.updatedAt)}) +ON CONFLICT (name) DO UPDATE + SET imported_at = excluded.imported_at +RETURNING id +""" + ) + } + .executeAndReturnGeneratedKeys() + .exactlyOne() + executeBatch(passwords) { + sql( + """ +INSERT INTO password_blacklist (password, source) +VALUES (${bind { it }}, ${bind(sourceId)}) +ON CONFLICT DO NOTHING +""" + ) + } +} + +fun Database.Read.isPasswordBlacklisted(password: Sensitive): Boolean = + createQuery { + sql( + "SELECT EXISTS(SELECT FROM password_blacklist WHERE password = ${bind(password.value)})" + ) + } + .exactlyOne() diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordConstraints.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordConstraints.kt new file mode 100644 index 0000000000..56d9c5ae92 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordConstraints.kt @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import fi.espoo.evaka.Sensitive + +/** Structural constraints for a password */ +data class PasswordConstraints( + /** Minimum length in characters */ + val minLength: Int, + /** Maximum length in characters */ + val maxLength: Int, + /** Minimum number of lowercase characters */ + val minLowers: Int, + /** Minimum number of uppercase characters */ + val minUppers: Int, + /** Minimum number of digit characters */ + val minDigits: Int, + /** Minimum number of symbol characters */ + val minSymbols: Int, +) { + init { + require(minLength in SUPPORTED_LENGTH) + require(maxLength in SUPPORTED_LENGTH) + require(minLength <= maxLength) + require(minLowers >= 0) + require(minUppers >= 0) + require(minDigits >= 0) + require(minSymbols >= 0) + } + + /** Returns true if the given password is structurally valid based on these constraints */ + fun isPasswordStructureValid(password: Sensitive): Boolean { + if (password.value.length !in minLength..maxLength) { + return false + } + if (password.value.count { it.isLowerCase() } < minLowers) { + return false + } + if (password.value.count { it.isUpperCase() } < minUppers) { + return false + } + if (password.value.count { it.isDigit() } < minDigits) { + return false + } + if (password.value.count { !it.isLetterOrDigit() } < minSymbols) { + return false + } + return true + } + + companion object { + /** + * Min/max password length supported by the system. + * + * This is not changeable, because it's a technical constraint + */ + val SUPPORTED_LENGTH = 1..128 + + val UNCONSTRAINED = + PasswordConstraints( + minLength = SUPPORTED_LENGTH.first, + maxLength = SUPPORTED_LENGTH.last, + minLowers = 0, + minUppers = 0, + minDigits = 0, + minSymbols = 0, + ) + } +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordSpecification.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordSpecification.kt new file mode 100644 index 0000000000..3e972da2d1 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordSpecification.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.shared.db.Database + +interface PasswordSpecification { + /** Returns the structural constraints for new passwords. */ + fun constraints(): PasswordConstraints + + /** + * Checks if an otherwise structurally valid password is acceptable. + * + * A password could be rejected for example if it's included in some list of too easily + * guessable passwords + */ + fun isPasswordAcceptable(dbc: Database.Connection, password: Sensitive): Boolean = true +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt index ff0e2fa752..c586925de2 100755 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt @@ -180,10 +180,7 @@ import fi.espoo.evaka.shared.ServiceNeedOptionId import fi.espoo.evaka.shared.StaffAttendanceRealtimeId import fi.espoo.evaka.shared.VoucherValueDecisionId import fi.espoo.evaka.shared.async.AsyncJobRunner -import fi.espoo.evaka.shared.auth.AuthenticatedUser -import fi.espoo.evaka.shared.auth.CitizenAuthLevel -import fi.espoo.evaka.shared.auth.PasswordService -import fi.espoo.evaka.shared.auth.UserRole +import fi.espoo.evaka.shared.auth.* import fi.espoo.evaka.shared.data.DateSet import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.db.psqlCause @@ -1647,6 +1644,22 @@ UPDATE person SET email=${bind(body.email)} WHERE id=${bind(body.personId)} } } } + + @PutMapping("/password-blacklist") + fun upsertPasswordBlacklist( + db: Database, + clock: EvakaClock, + @RequestBody request: List, + ) { + db.connect { dbc -> + dbc.transaction { tx -> + tx.upsertPasswordBlacklist( + PasswordBlacklistSource("Dev API", clock.now()), + request.asSequence(), + ) + } + } + } } // https://www.postgresql.org/docs/14/errcodes-appendix.html diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/job/ScheduledJobs.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/job/ScheduledJobs.kt index 66dba70bb1..9f7eaca0d7 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/job/ScheduledJobs.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/job/ScheduledJobs.kt @@ -4,6 +4,7 @@ package fi.espoo.evaka.shared.job +import fi.espoo.evaka.EvakaEnv import fi.espoo.evaka.ScheduledJobsEnv import fi.espoo.evaka.VardaEnv import fi.espoo.evaka.application.PendingDecisionEmailService @@ -33,6 +34,7 @@ import fi.espoo.evaka.reservations.MissingReservationsReminders import fi.espoo.evaka.sficlient.SfiMessagesClient import fi.espoo.evaka.shared.async.removeOldAsyncJobs import fi.espoo.evaka.shared.auth.AuthenticatedUser +import fi.espoo.evaka.shared.auth.PasswordBlacklist import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.db.runSanityChecks import fi.espoo.evaka.shared.domain.EvakaClock @@ -215,6 +217,10 @@ enum class ScheduledJob( ScheduledJobs::generateReplacementDraftInvoices, ScheduledJobSettings(enabled = true, schedule = JobSchedule.daily(LocalTime.of(4, 15))), ), + ImportPasswordsBlacklists( + ScheduledJobs::importPasswordBlacklists, + ScheduledJobSettings(enabled = true, schedule = JobSchedule.daily(LocalTime.of(0, 20))), + ), } private val logger = KotlinLogging.logger {} @@ -223,6 +229,7 @@ private val logger = KotlinLogging.logger {} class ScheduledJobs( // private val vardaService: VardaService, // Use this once varda fixes MA003 retry glitch private val vardaUpdateServiceNew: VardaUpdateServiceNew, + private val evakaEnv: EvakaEnv, private val vardaEnv: VardaEnv, private val dvvModificationsBatchRefreshService: DvvModificationsBatchRefreshService, private val pendingDecisionEmailService: PendingDecisionEmailService, @@ -238,6 +245,7 @@ class ScheduledJobs( private val attachmentService: AttachmentService, private val jamixService: JamixService, private val sfiMessagesClient: SfiMessagesClient?, + private val passwordBlacklist: PasswordBlacklist, env: ScheduledJobsEnv, ) : JobSchedule { override val jobs: List = @@ -462,4 +470,9 @@ WHERE id IN (SELECT id FROM attendances_to_end) fun generateReplacementDraftInvoices(db: Database.Connection, clock: EvakaClock) = invoiceGenerator.generateAllReplacementDraftInvoices(db, clock.today()) + + fun importPasswordBlacklists(db: Database.Connection, clock: EvakaClock) = + evakaEnv.passwordBlacklistDirectory?.let { directory -> + passwordBlacklist.importBlacklists(db, directory) + } } diff --git a/service/src/main/resources/db/migration/V491__password_blacklist.sql b/service/src/main/resources/db/migration/V491__password_blacklist.sql new file mode 100644 index 0000000000..77df72ced0 --- /dev/null +++ b/service/src/main/resources/db/migration/V491__password_blacklist.sql @@ -0,0 +1,18 @@ +CREATE TABLE password_blacklist_source ( + id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL, + imported_at timestamp with time zone NOT NULL +); + +ALTER TABLE password_blacklist_source + ADD CONSTRAINT uniq$password_blacklist_source_name UNIQUE (name) +; + +CREATE TABLE password_blacklist ( + password text PRIMARY KEY, + source int NOT NULL +); + +ALTER TABLE password_blacklist + ADD CONSTRAINT fk$source FOREIGN KEY (source) REFERENCES password_blacklist_source (id) ON DELETE CASCADE +; diff --git a/service/src/main/resources/dev-data/dev-data.sql b/service/src/main/resources/dev-data/dev-data.sql index 29d963109e..5a849b6a2f 100644 --- a/service/src/main/resources/dev-data/dev-data.sql +++ b/service/src/main/resources/dev-data/dev-data.sql @@ -40,3 +40,9 @@ INSERT INTO assistance_action_option (value, name_fi, display_order) VALUES ('PERIODICAL_VEO_SUPPORT', 'Lisäresurssi hankerahoituksella', 80); UPDATE daycare SET enabled_pilot_features = '{MESSAGING, MOBILE, RESERVATIONS, VASU_AND_PEDADOC, MOBILE_MESSAGING}'; + +WITH source AS ( + INSERT INTO password_blacklist_source (name, imported_at) VALUES ('dev-data', now()) + RETURNING id +) +INSERT INTO password_blacklist (password, source) VALUES ('TestPassword123!', (SELECT id FROM source)); diff --git a/service/src/main/resources/migrations.txt b/service/src/main/resources/migrations.txt index 56d856b0f9..72fab86364 100644 --- a/service/src/main/resources/migrations.txt +++ b/service/src/main/resources/migrations.txt @@ -486,3 +486,4 @@ V487__staff_attendance_realtime_modified.sql V488__no_head_of_self.sql V489__person_email_verification.sql V490__daycare_service_worker_note.sql +V491__password_blacklist.sql diff --git a/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordConstraintsTest.kt b/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordConstraintsTest.kt new file mode 100644 index 0000000000..682ab8bad8 --- /dev/null +++ b/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordConstraintsTest.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import fi.espoo.evaka.Sensitive +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PasswordConstraintsTest { + private val unconstrained = PasswordConstraints.UNCONSTRAINED + + @Test + fun `isPasswordStructureValid checks minLength correctly`() { + val constraints = unconstrained.copy(minLength = 4) + assertFalse(constraints.isPasswordStructureValid(Sensitive("123"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1234"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("12345"))) + } + + @Test + fun `isPasswordStructureValid checks maxLength correctly`() { + val constraints = unconstrained.copy(maxLength = 4) + assertFalse(constraints.isPasswordStructureValid(Sensitive("12345"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1234"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("123"))) + } + + @Test + fun `isPasswordStructureValid checks minLowers correctly`() { + val constraints = unconstrained.copy(minLowers = 1) + assertFalse(constraints.isPasswordStructureValid(Sensitive("1_2"))) + assertFalse(constraints.isPasswordStructureValid(Sensitive("1A2"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1a2"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1ab"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1ä2"))) + } + + @Test + fun `isPasswordStructureValid checks minUppers correctly`() { + val constraints = unconstrained.copy(minUppers = 1) + assertFalse(constraints.isPasswordStructureValid(Sensitive("1_2"))) + assertFalse(constraints.isPasswordStructureValid(Sensitive("1a2"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1A2"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1AB"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("1Ä2"))) + } + + @Test + fun `isPasswordStructureValid checks minDigits correctly`() { + val constraints = unconstrained.copy(minDigits = 1) + assertFalse(constraints.isPasswordStructureValid(Sensitive("abc"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("a1c"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("a12"))) + } + + @Test + fun `isPasswordStructureValid checks minSymbols correctly`() { + val constraints = unconstrained.copy(minSymbols = 1) + assertFalse(constraints.isPasswordStructureValid(Sensitive("123"))) + assertFalse(constraints.isPasswordStructureValid(Sensitive("abc"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("a#c"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("a#2"))) + assertTrue(constraints.isPasswordStructureValid(Sensitive("💩"))) + } +}