diff --git a/apigw/src/enduser/app.ts b/apigw/src/enduser/app.ts index b529d0c01e3..908d5d60920 100644 --- a/apigw/src/enduser/app.ts +++ b/apigw/src/enduser/app.ts @@ -23,6 +23,7 @@ import { createDevSfiRouter } from './dev-sfi-auth.js' import { authenticateKeycloakCitizen } from './keycloak-citizen-saml.js' import mapRoutes from './mapRoutes.js' import authStatus from './routes/auth-status.js' +import { authWeakLogin } from './routes/auth-weak-login.js' import { authenticateSuomiFi } from './suomi-fi-saml.js' export function enduserGwRouter( @@ -89,6 +90,7 @@ export function enduserGwRouter( }) ) router.get('/auth/status', authStatus) + router.post('/auth/weak-login', express.json(), authWeakLogin(redisClient)) router.use(requireAuthentication) router.use(csrf) router.all('/citizen/*', createProxy()) diff --git a/apigw/src/enduser/routes/auth-weak-login.ts b/apigw/src/enduser/routes/auth-weak-login.ts new file mode 100644 index 00000000000..402f34448e0 --- /dev/null +++ b/apigw/src/enduser/routes/auth-weak-login.ts @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import { getHours } from 'date-fns/getHours' +import { z } from 'zod' + +import { EvakaSessionUser, login } from '../../shared/auth/index.js' +import { toRequestHandler } from '../../shared/express.js' +import { logAuditEvent } from '../../shared/logging.js' +import { RedisClient } from '../../shared/redis-client.js' +import { citizenWeakLogin } from '../../shared/service-client.js' + +const Request = z.object({ + username: z + .string() + .min(1) + .max(128) + .transform((email) => email.toLowerCase()), + password: z.string().min(1).max(128) +}) + +const eventCode = (name: string) => `evaka.citizen_weak.${name}` + +const loginAttemptsPerHour = 20 + +export const authWeakLogin = (redis: RedisClient) => + toRequestHandler(async (req, res) => { + logAuditEvent(eventCode('sign_in_requested'), req, 'Login endpoint called') + try { + const body = Request.parse(req.body) + + // Apply rate limit (attempts per hour) + // Reference: Redis Rate Limiting Best Practices + // https://redis.io/glossary/rate-limiting/ + const hour = getHours(new Date()) + const key = `citizen-weak-login:${body.username}:${hour}` + const value = Number.parseInt((await redis.get(key)) ?? '', 10) + if (Number.isNaN(value) || value < loginAttemptsPerHour) { + // expire in 1 hour, so there's no old entry when the hours value repeats the next day + const expirySeconds = 60 * 60 + await redis.multi().incr(key).expire(key, expirySeconds).exec() + } else { + res.sendStatus(429) + return + } + + const { id } = await citizenWeakLogin(body) + const user: EvakaSessionUser = { + id, + userType: 'CITIZEN_WEAK', + globalRoles: [], + allScopedRoles: [] + } + await login(req, user) + logAuditEvent(eventCode('sign_in'), req, 'User logged in successfully') + res.sendStatus(200) + } catch (err) { + logAuditEvent( + eventCode('sign_in_failed'), + req, + `Error logging user in. Error: ${err?.toString()}` + ) + if (!res.headersSent) { + if (err instanceof z.ZodError) { + res.sendStatus(400) + } else { + res.sendStatus(403) + } + } else { + throw err + } + } + }) diff --git a/apigw/src/index.ts b/apigw/src/index.ts index c10b1c4e255..ad7b83fcf92 100644 --- a/apigw/src/index.ts +++ b/apigw/src/index.ts @@ -32,7 +32,6 @@ deprecatedEnvVariables.forEach((name) => { logWarn(`Deprecated environment variable ${name} was specified`) } }) - const redisClient = redis.createClient(toRedisClientOpts(config)) redisClient.on('error', (err) => // eslint-disable-next-line @typescript-eslint/no-unsafe-argument diff --git a/apigw/src/shared/__tests__/parse-url-with-origin.ts b/apigw/src/shared/__tests__/parse-url-with-origin.ts new file mode 100644 index 00000000000..2ea1026d79e --- /dev/null +++ b/apigw/src/shared/__tests__/parse-url-with-origin.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import { describe, expect, it } from '@jest/globals' + +import { parseUrlWithOrigin } from '../parse-url-with-origin.js' + +describe('parseUrlWithOrigin', () => { + const origin = 'https://example.com' + const base = { origin } + it('returns a parsed URL if the input URL is empty', () => { + const url = parseUrlWithOrigin(base, '') + expect(url?.toString()).toEqual(`${origin}/`) + }) + it('returns a parsed URL if the input URL is /', () => { + const url = parseUrlWithOrigin(base, '/') + expect(url?.toString()).toEqual(`${origin}/`) + }) + it('returns a parsed URL if the input URL is a relative path', () => { + const url = parseUrlWithOrigin(base, '/test') + expect(url?.toString()).toEqual(`${origin}/test`) + }) + it('returns a parsed URL if the input URL has the correct origin', () => { + const url = parseUrlWithOrigin(base, `${origin}/valid`) + expect(url?.toString()).toEqual(`${origin}/valid`) + }) + it('retains the query and hash, if present', () => { + const url = parseUrlWithOrigin(base, '/test?query=qvalue#hash') + expect(url?.toString()).toEqual(`${origin}/test?query=qvalue#hash`) + expect(url?.search).toEqual('?query=qvalue') + expect(url?.hash).toEqual('#hash') + }) + it('returns undefined if the input URL is not relative and has the wrong origin', () => { + const url = parseUrlWithOrigin(base, 'https://other.example.com') + expect(url).toBeUndefined() + }) + it('returns undefined if the input URL has a protocol-relative URL (two slashes)', () => { + const url = parseUrlWithOrigin(base, '//something') + expect(url).toBeUndefined() + }) + it('returns undefined if the input URL has an unusual protocol + value combination', () => { + const url = parseUrlWithOrigin(base, 'x:https://example.com') + expect(url).toBeUndefined() + }) +}) diff --git a/apigw/src/shared/auth/dev-auth.ts b/apigw/src/shared/auth/dev-auth.ts index 8f7b62fc33d..f98baff8a3e 100644 --- a/apigw/src/shared/auth/dev-auth.ts +++ b/apigw/src/shared/auth/dev-auth.ts @@ -38,7 +38,7 @@ export function createDevAuthRouter({ res.redirect(`${root}?loginError=true`) } else { await login(req, user) - res.redirect(validateRelayStateUrl(req) ?? root) + res.redirect(validateRelayStateUrl(req)?.toString() ?? root) } } catch (err) { if (!res.headersSent) { diff --git a/apigw/src/shared/config.ts b/apigw/src/shared/config.ts index 887ac3097f2..90c97c2b714 100644 --- a/apigw/src/shared/config.ts +++ b/apigw/src/shared/config.ts @@ -426,7 +426,7 @@ function createLocalDevelopmentOverrides(): Partial { JWT_PRIVATE_KEY: 'config/test-cert/jwt_private_key.pem', JWT_REFRESH_ENABLED: !isTest, - EVAKA_BASE_URL: 'local', + EVAKA_BASE_URL: 'http://localhost:9099', EVAKA_SERVICE_URL: 'http://localhost:8888', AD_MOCK: true, @@ -786,7 +786,7 @@ export const jwtRefreshEnabled = required('JWT_REFRESH_ENABLED', parseBoolean) export const serviceName = 'evaka-api-gw' export const jwtKid = required('JWT_KID', unchanged) -export const evakaBaseUrl = required('EVAKA_BASE_URL', unchanged) +export const evakaBaseUrl = new URL(required('EVAKA_BASE_URL', unchanged)) export const evakaServiceUrl = required('EVAKA_SERVICE_URL', unchanged) export const useSecureCookies = required('USE_SECURE_COOKIES', parseBoolean) diff --git a/apigw/src/shared/parse-url-with-origin.ts b/apigw/src/shared/parse-url-with-origin.ts new file mode 100644 index 00000000000..e78f97e2a78 --- /dev/null +++ b/apigw/src/shared/parse-url-with-origin.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +/** + * Parses a string as a URL, requiring it to be either a relative URL, or to have exactly the correct origin. + * + * If the string does not pass the validation, undefined is returned. + */ +export function parseUrlWithOrigin( + base: { origin: string }, + value: string +): URL | undefined { + try { + const url = new URL(value, base.origin) + return url.origin === base.origin ? url : undefined + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + return undefined + } +} diff --git a/apigw/src/shared/redis-client.ts b/apigw/src/shared/redis-client.ts index 809045a7a9d..8ae42f19a77 100644 --- a/apigw/src/shared/redis-client.ts +++ b/apigw/src/shared/redis-client.ts @@ -2,7 +2,9 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -export interface RedisClient { +export interface RedisCommands {} + +export interface RedisClient extends RedisCommands { isReady: boolean get(key: string): Promise @@ -10,14 +12,26 @@ export interface RedisClient { set( key: string, value: string, - options: { EX: number } + options: { EX: number; GET?: true; NX?: true } ): Promise del(key: string | string[]): Promise expire(key: string, seconds: number): Promise + incr(key: string): Promise + ping(): Promise + + multi(): RedisTransaction +} + +export interface RedisTransaction extends RedisCommands { + incr(key: string): RedisTransaction + + expire(key: string, seconds: number): RedisTransaction + + exec(): Promise } export async function assertRedisConnection( diff --git a/apigw/src/shared/routes/saml.ts b/apigw/src/shared/routes/saml.ts index 61a87a31578..be761a3efd8 100755 --- a/apigw/src/shared/routes/saml.ts +++ b/apigw/src/shared/routes/saml.ts @@ -140,7 +140,7 @@ export default function createSamlRouter( // These errors can happen for example when the user browses back to the login callback after login throw new SamlError('Login failed', { redirectUrl: req.user - ? validateRelayStateUrl(req) ?? defaultPageUrl + ? validateRelayStateUrl(req)?.toString() ?? defaultPageUrl : errorRedirectUrl(err), cause: err, // just ignore without logging to reduce noise in logs @@ -191,7 +191,8 @@ export default function createSamlRouter( req.session.idpProvider = strategyName await sessions.saveLogoutToken(req, createLogoutToken(profile)) - const redirectUrl = validateRelayStateUrl(req) ?? defaultPageUrl + const redirectUrl = + validateRelayStateUrl(req)?.toString() ?? defaultPageUrl logDebug(`Redirecting to ${redirectUrl}`, req, { redirectUrl }) return res.redirect(redirectUrl) } catch (err) { @@ -287,7 +288,7 @@ export default function createSamlRouter( success ) } else { - url = validateRelayStateUrl(req) ?? defaultPageUrl + url = validateRelayStateUrl(req)?.toString() ?? defaultPageUrl } return res.redirect(url) } catch (err) { diff --git a/apigw/src/shared/saml/index.ts b/apigw/src/shared/saml/index.ts index 263414cff37..b28d9d3b6c9 100644 --- a/apigw/src/shared/saml/index.ts +++ b/apigw/src/shared/saml/index.ts @@ -3,7 +3,6 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import { readFileSync } from 'node:fs' -import path from 'node:path' import { CacheProvider, Profile, SamlConfig } from '@node-saml/node-saml' import express from 'express' @@ -13,6 +12,7 @@ import { EvakaSessionUser } from '../auth/index.js' import certificates, { TrustedCertificates } from '../certificates.js' import { evakaBaseUrl, EvakaSamlConfig } from '../config.js' import { logError } from '../logging.js' +import { parseUrlWithOrigin } from '../parse-url-with-origin.js' export function createSamlConfig( config: EvakaSamlConfig, @@ -109,20 +109,10 @@ export function validateRelayStateUrl( req: express.Request ): string | undefined { const relayState = getRawUnvalidatedRelayState(req) - - if (relayState && path.isAbsolute(relayState)) { - if (evakaBaseUrl === 'local') { - return relayState - } else { - const baseUrl = evakaBaseUrl.replace(/\/$/, '') - const redirect = new URL(relayState, baseUrl) - if (redirect.origin == baseUrl) { - return redirect.href - } - } + if (relayState) { + const url = parseUrlWithOrigin(evakaBaseUrl, relayState) + if (url) return url.toString() + logError('Invalid RelayState in request', req) } - - if (relayState) logError('Invalid RelayState in request', req) - return undefined } diff --git a/apigw/src/shared/service-client.ts b/apigw/src/shared/service-client.ts index 02c02974f2e..d1e8148f24f 100644 --- a/apigw/src/shared/service-client.ts +++ b/apigw/src/shared/service-client.ts @@ -161,6 +161,24 @@ export async function citizenLogin( return data } +interface CitizenWeakLoginRequest { + username: string + password: string +} + +export async function citizenWeakLogin( + request: CitizenWeakLoginRequest +): Promise { + const { data } = await client.post( + `/system/citizen-weak-login`, + request, + { + headers: createServiceRequestHeaders(undefined, machineUser) + } + ) + return data +} + export async function getCitizenDetails( req: express.Request, personId: string diff --git a/apigw/src/shared/test/mock-redis-client.ts b/apigw/src/shared/test/mock-redis-client.ts index e45e370d00d..cdeb64595b9 100644 --- a/apigw/src/shared/test/mock-redis-client.ts +++ b/apigw/src/shared/test/mock-redis-client.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import { RedisClient } from '../redis-client.js' +import { RedisClient, RedisTransaction } from '../redis-client.js' export class MockRedisClient implements RedisClient { private time: number @@ -60,7 +60,43 @@ export class MockRedisClient implements RedisClient { return Promise.resolve(true) } + incr(key: string): Promise { + const record = this.db[key] ?? { value: '0', expires: null } + const value = Number.parseInt(record.value, 10) + 1 + if (Number.isNaN(value)) throw new Error('Not a number') + this.db[key] = { ...record, value: value.toString() } + return Promise.resolve(value) + } + ping(): Promise { return Promise.resolve('PONG') } + + multi(): RedisTransaction { + return new MockTransaction(this) + } +} + +class MockTransaction implements RedisTransaction { + constructor(private client: RedisClient) {} + #queue: (() => Promise)[] = [] + incr(key: string): RedisTransaction { + this.#queue.push(async () => { + await this.client.incr(key) + }) + return this + } + expire(key: string, seconds: number): RedisTransaction { + this.#queue.push(async () => { + await this.client.expire(key, seconds) + }) + return this + } + async exec(): Promise { + const returnValues: unknown[] = [] + for (const op of this.#queue) { + returnValues.push(await op()) + } + return returnValues + } } diff --git a/frontend/src/citizen-frontend/auth/api.ts b/frontend/src/citizen-frontend/auth/api.ts index ad64811cfd2..c4a88c95433 100644 --- a/frontend/src/citizen-frontend/auth/api.ts +++ b/frontend/src/citizen-frontend/auth/api.ts @@ -20,3 +20,16 @@ export type AuthStatus = export async function getAuthStatus(): Promise { return (await client.get>('/auth/status')).data } + +interface WeakLoginRequest { + username: string + password: string +} + +export async function authWeakLogin( + username: string, + password: string +): Promise { + const reqBody: WeakLoginRequest = { username, password } + await client.post('/auth/weak-login', reqBody) +} diff --git a/frontend/src/citizen-frontend/generated/api-clients/pis.ts b/frontend/src/citizen-frontend/generated/api-clients/pis.ts index 41d8c5c1c29..03c8e117ff1 100644 --- a/frontend/src/citizen-frontend/generated/api-clients/pis.ts +++ b/frontend/src/citizen-frontend/generated/api-clients/pis.ts @@ -8,6 +8,7 @@ import { EmailMessageType } from 'lib-common/generated/api-types/pis' import { JsonCompatible } from 'lib-common/json' import { JsonOf } from 'lib-common/json' import { PersonalDataUpdate } from 'lib-common/generated/api-types/pis' +import { UpdatePasswordRequest } from 'lib-common/generated/api-types/pis' import { client } from '../../api-client' import { uri } from 'lib-common/uri' @@ -41,6 +42,23 @@ export async function updateNotificationSettings( } +/** +* Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.updatePassword +*/ +export async function updatePassword( + request: { + body: UpdatePasswordRequest + } +): Promise { + const { data: json } = await client.request>({ + url: uri`/citizen/personal-data/password`.toString(), + method: 'PUT', + data: request.body satisfies JsonCompatible + }) + return json +} + + /** * Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.updatePersonalData */ diff --git a/frontend/src/citizen-frontend/login/LoginPage.tsx b/frontend/src/citizen-frontend/login/LoginPage.tsx index 6771c8be225..b0b1c7aa98a 100644 --- a/frontend/src/citizen-frontend/login/LoginPage.tsx +++ b/frontend/src/citizen-frontend/login/LoginPage.tsx @@ -7,9 +7,16 @@ import React, { useMemo, useState } from 'react' import { Link, Navigate, useSearchParams } from 'react-router-dom' import styled from 'styled-components' +import { wrapResult } from 'lib-common/api' +import { string } from 'lib-common/form/fields' +import { object, validated } from 'lib-common/form/form' +import { useForm, useFormFields } from 'lib-common/form/hooks' import { useQueryResult } from 'lib-common/query' +import { parseUrlWithOrigin } from 'lib-common/utils/parse-url-with-origin' import Main from 'lib-components/atoms/Main' +import { AsyncButton } from 'lib-components/atoms/buttons/AsyncButton' import LinkButton from 'lib-components/atoms/buttons/LinkButton' +import { InputFieldF } from 'lib-components/atoms/form/InputField' import Container, { ContentArea } from 'lib-components/layout/Container' import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers' import { @@ -23,12 +30,14 @@ import { import { AlertBox } from 'lib-components/molecules/MessageBoxes' import { fontWeights, H1, H2, P } from 'lib-components/typography' import { defaultMargins, Gap } from 'lib-components/white-space' +import { featureFlags } from 'lib-customizations/citizen' import { farMap } from 'lib-icons' import Footer from '../Footer' +import { authWeakLogin } from '../auth/api' import { useUser } from '../auth/state' import { useTranslation } from '../localization' -import { getStrongLoginUriWithPath, getWeakLoginUri } from '../navigation/const' +import { getStrongLoginUri, getWeakLoginUri } from '../navigation/const' import { systemNotificationsQuery } from './queries' @@ -36,30 +45,12 @@ const ParagraphInfoButton = styled(InfoButton)` margin-left: ${defaultMargins.xs}; ` -/** - * Ensures that the redirect URL will not contain any host - * information, only the path/search params/hash. - */ -const getSafeNextPath = (nextParam: string | null) => { - if (nextParam === null) { - return null - } - - const url = new URL(nextParam, window.location.origin) - - return `${url.pathname}${url.search}${url.hash}` -} - export default React.memo(function LoginPage() { const i18n = useTranslation() const user = useUser() const [searchParams] = useSearchParams() - - const nextPath = useMemo( - () => getSafeNextPath(searchParams.get('next')), - [searchParams] - ) + const unvalidatedNextPath = searchParams.get('next') const [showInfoBoxText1, setShowInfoBoxText1] = useState(false) const [showInfoBoxText2, setShowInfoBoxText2] = useState(false) @@ -114,11 +105,14 @@ export default React.memo(function LoginPage() { )} {i18n.loginPage.login.link} + {featureFlags.weakLogin && ( + + )}

{i18n.loginPage.applying.title}

@@ -144,7 +138,7 @@ export default React.memo(function LoginPage() { {i18n.loginPage.applying.link} @@ -165,6 +159,87 @@ export default React.memo(function LoginPage() { ) }) +const weakLoginForm = validated( + object({ + username: string(), + password: string() + }), + (form) => { + if (form.username.length === 0 || form.password.length === 0) { + return 'required' + } + return undefined + } +) + +const authWeakLoginResult = wrapResult(authWeakLogin) + +const WeakLoginForm = React.memo(function WeakLogin({ + unvalidatedNextPath +}: { + unvalidatedNextPath: string | null +}) { + const i18n = useTranslation() + const [rateLimitError, setRateLimitError] = useState(false) + + const nextUrl = useMemo( + () => + unvalidatedNextPath + ? parseUrlWithOrigin(window.location, unvalidatedNextPath) + : undefined, + [unvalidatedNextPath] + ) + + const form = useForm( + weakLoginForm, + () => ({ username: '', password: '' }), + i18n.validationErrors + ) + const { username, password } = useFormFields(form) + return ( + <> + +
e.preventDefault()}> + + {rateLimitError && ( + + )} + + + + authWeakLoginResult(form.state.username, form.state.password) + } + onSuccess={() => window.location.replace(nextUrl ?? '/')} + onFailure={(error) => { + if (error.statusCode === 429) { + setRateLimitError(true) + } + }} + /> + +
+ + ) +}) + const MapLink = styled(Link)` text-decoration: none; display: inline-block; diff --git a/frontend/src/citizen-frontend/navigation/const.ts b/frontend/src/citizen-frontend/navigation/const.ts index 3f9ce67394c..c4a1a8484c2 100644 --- a/frontend/src/citizen-frontend/navigation/const.ts +++ b/frontend/src/citizen-frontend/navigation/const.ts @@ -4,20 +4,14 @@ import { User } from '../auth/state' -export const getWeakLoginUri = (path?: string) => - `/api/application/auth/evaka-customer/login?RelayState=${encodeURIComponent( - path ?? window.location.pathname - )}` +export const getWeakLoginUri = ( + url = `${window.location.pathname}${window.location.search}${window.location.hash}` +) => + `/api/application/auth/evaka-customer/login?RelayState=${encodeURIComponent(url)}` -export const getStrongLoginUri = (path?: string) => - getStrongLoginUriWithPath( - `${path ?? window.location.pathname}${window.location.search}${ - window.location.hash - }` - ) - -export const getStrongLoginUriWithPath = (path: string) => - `/api/application/auth/saml/login?RelayState=${encodeURIComponent(path)}` +export const getStrongLoginUri = ( + url = `${window.location.pathname}${window.location.search}${window.location.hash}` +) => `/api/application/auth/saml/login?RelayState=${encodeURIComponent(url)}` export const getLogoutUri = (user: User) => `/api/application/auth/${ diff --git a/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx b/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx index f6e3e792e6e..2f91f276f7e 100644 --- a/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx +++ b/frontend/src/citizen-frontend/personal-details/LoginDetailsSection.tsx @@ -4,28 +4,167 @@ import React from 'react' +import { string } from 'lib-common/form/fields' +import { object, validated } from 'lib-common/form/form' +import { useBoolean, useForm, useFormFields } from 'lib-common/form/hooks' +import { Button } from 'lib-components/atoms/buttons/Button' +import { MutateButton } from 'lib-components/atoms/buttons/MutateButton' +import { InputFieldF } from 'lib-components/atoms/form/InputField' import ListGrid from 'lib-components/layout/ListGrid' +import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers' import { H2, Label } from 'lib-components/typography' +import { featureFlags } from 'lib-customizations/citizen' import { User } from '../auth/state' import { useTranslation } from '../localization' +import { updatePasswordMutation } from './queries' + +const minLength = 8 +const maxLength = 128 + export interface Props { user: User + reloadUser: () => void } -export default React.memo(function LoginDetailsSection({ user }: Props) { - const t = useTranslation() +export default React.memo(function LoginDetailsSection({ + user, + reloadUser +}: Props) { + const t = useTranslation().personalDetails.loginDetailsSection return (
-

{t.personalDetails.loginDetailsSection.title}

+

{t.title}

- +
{user.keycloakEmail}
+ {featureFlags.weakLogin && !!user.weakLoginUsername && ( + <> + +
+ {user.weakLoginUsername} +
+ + )} + {featureFlags.weakLogin && + user.authLevel === 'STRONG' && + !!user.weakLoginUsername && ( + <> + + + + )}
) }) + +const passwordForm = validated( + object({ + password1: string(), + password2: string() + }), + (form) => { + if ( + form.password1.length === 0 || + form.password1 !== form.password2 || + form.password1.length < minLength || + form.password1.length > maxLength + ) { + return 'required' + } + return undefined + } +) + +const PasswordForm = React.memo(function PasswordForm({ + onClose, + reloadUser +}: { + reloadUser: () => void + onClose: () => void +}) { + const i18n = useTranslation() + const t = i18n.personalDetails.loginDetailsSection + + const form = useForm( + passwordForm, + () => ({ password1: '', password2: '' }), + i18n.validationErrors + ) + const { password1, password2 } = useFormFields(form) + const pattern = `.{${minLength},${maxLength}}` + return ( +
e.preventDefault()}> + + + + ({ + body: { + password: form.state.password1 + } + })} + onSuccess={() => { + reloadUser() + onClose() + }} + /> + +
+ ) +}) + +const PasswordSection = React.memo(function PasswordSection({ + user, + reloadUser +}: Props) { + const t = useTranslation().personalDetails.loginDetailsSection + + const isWeakLoginSetup = !!user.weakLoginUsername + const [editing, { on: startEditing, off: stopEditing }] = useBoolean(false) + return ( +
+ {editing ? ( + + ) : ( +
+ ) +}) diff --git a/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx b/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx index 3d07f737501..973b364bc21 100644 --- a/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx +++ b/frontend/src/citizen-frontend/personal-details/PersonalDetails.tsx @@ -12,6 +12,7 @@ import Main from 'lib-components/atoms/Main' import Container, { ContentArea } from 'lib-components/layout/Container' import { H1 } from 'lib-components/typography' import { Gap } from 'lib-components/white-space' +import { featureFlags } from 'lib-customizations/citizen' import Footer from '../Footer' import { renderResult } from '../async-rendering' @@ -71,10 +72,13 @@ export default React.memo(function PersonalDetails() { {renderResult(user, (user) => user ? ( <> - {!!user.keycloakEmail && ( + {(!!user.keycloakEmail || featureFlags.weakLogin) && ( <> - + )} diff --git a/frontend/src/citizen-frontend/personal-details/queries.ts b/frontend/src/citizen-frontend/personal-details/queries.ts index 09ec81d1c01..e12e87a7336 100644 --- a/frontend/src/citizen-frontend/personal-details/queries.ts +++ b/frontend/src/citizen-frontend/personal-details/queries.ts @@ -7,6 +7,7 @@ import { mutation, query } from 'lib-common/query' import { getNotificationSettings, updateNotificationSettings, + updatePassword, updatePersonalData } from '../generated/api-clients/pis' import { createQueryKeys } from '../query' @@ -19,6 +20,10 @@ export const updatePersonalDetailsMutation = mutation({ api: updatePersonalData }) +export const updatePasswordMutation = mutation({ + api: updatePassword +}) + export const notificationSettingsQuery = query({ api: getNotificationSettings, queryKey: queryKeys.notificationSettings diff --git a/frontend/src/lib-common/generated/api-types/pis.ts b/frontend/src/lib-common/generated/api-types/pis.ts index f79af9ed7ba..bf0ab8bcc9d 100644 --- a/frontend/src/lib-common/generated/api-types/pis.ts +++ b/frontend/src/lib-common/generated/api-types/pis.ts @@ -43,6 +43,7 @@ export interface CitizenUserDetails { postalCode: string preferredName: string streetAddress: string + weakLoginUsername: string | null } /** @@ -661,6 +662,13 @@ export interface TemporaryEmployee { pinCode: PinCode | null } +/** +* Generated from fi.espoo.evaka.pis.controllers.PersonalDataControllerCitizen.UpdatePasswordRequest +*/ +export interface UpdatePasswordRequest { + password: string +} + /** * Generated from fi.espoo.evaka.pis.controllers.EmployeeController.UpsertEmployeeDaycareRolesRequest */ diff --git a/frontend/src/lib-common/utils/parse-url-with-origin.spec.ts b/frontend/src/lib-common/utils/parse-url-with-origin.spec.ts new file mode 100644 index 00000000000..dfc6adf7e0a --- /dev/null +++ b/frontend/src/lib-common/utils/parse-url-with-origin.spec.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import { parseUrlWithOrigin } from './parse-url-with-origin' + +describe('parseUrlWithOrigin', () => { + const origin = 'https://example.com' + const base = { origin } + it('returns a parsed URL if the input URL is empty', () => { + const url = parseUrlWithOrigin(base, '') + expect(url?.toString()).toEqual(`${origin}/`) + }) + it('returns a parsed URL if the input URL is /', () => { + const url = parseUrlWithOrigin(base, '/') + expect(url?.toString()).toEqual(`${origin}/`) + }) + it('returns a parsed URL if the input URL is a relative path', () => { + const url = parseUrlWithOrigin(base, '/test') + expect(url?.toString()).toEqual(`${origin}/test`) + }) + it('returns a parsed URL if the input URL has the correct origin', () => { + const url = parseUrlWithOrigin(base, `${origin}/valid`) + expect(url?.toString()).toEqual(`${origin}/valid`) + }) + it('retains the query and hash, if present', () => { + const url = parseUrlWithOrigin(base, '/test?query=qvalue#hash') + expect(url?.toString()).toEqual(`${origin}/test?query=qvalue#hash`) + expect(url?.search).toEqual('?query=qvalue') + expect(url?.hash).toEqual('#hash') + }) + it('returns undefined if the input URL is not relative and has the wrong origin', () => { + const url = parseUrlWithOrigin(base, 'https://other.example.com') + expect(url).toBeUndefined() + }) + it('returns undefined if the input URL has a protocol-relative URL (two slashes)', () => { + const url = parseUrlWithOrigin(base, '//something') + expect(url).toBeUndefined() + }) + it('returns undefined if the input URL has an unusual protocol + value combination', () => { + const url = parseUrlWithOrigin(base, 'x:https://example.com') + expect(url).toBeUndefined() + }) +}) diff --git a/frontend/src/lib-common/utils/parse-url-with-origin.ts b/frontend/src/lib-common/utils/parse-url-with-origin.ts new file mode 100644 index 00000000000..ed42f591bf6 --- /dev/null +++ b/frontend/src/lib-common/utils/parse-url-with-origin.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +/** + * Parses a string as a URL, requiring it to be either a relative URL, or to have exactly the correct origin. + * + * If the string does not pass the validation, undefined is returned. + */ +export function parseUrlWithOrigin( + base: { origin: string }, + value: string +): URL | undefined { + try { + const url = new URL(value, base.origin) + return url.origin === base.origin ? url : undefined + } catch (err) { + return undefined + } +} diff --git a/frontend/src/lib-components/atoms/form/InputField.tsx b/frontend/src/lib-components/atoms/form/InputField.tsx index 24191d06245..377f1d955b8 100755 --- a/frontend/src/lib-components/atoms/form/InputField.tsx +++ b/frontend/src/lib-components/atoms/form/InputField.tsx @@ -212,6 +212,7 @@ export interface InputProps extends BaseProps { autoFocus?: boolean inputRef?: RefObject wrapperClassName?: string + pattern?: string } interface ClearableInputProps extends OtherInputProps { diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx index 4b21efb8d35..94afd525c7f 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx @@ -213,7 +213,11 @@ const en: Translations = { . You can also log in with strong authentication.

- ) + ), + email: 'E-mail', + password: 'Password', + rateLimitError: + 'Your account has been temporarily locked due to a large number of login attempts. Please try again later.' }, applying: { title: 'Sign in using Suomi.fi', @@ -1756,7 +1760,12 @@ const en: Translations = { }, loginDetailsSection: { title: 'Login information', - keycloakEmail: 'Username' + weakLoginUsername: 'Username', + password: 'Password', + newPassword: 'New password', + repeatPassword: 'Confirm new password', + setPassword: 'Set password', + updatePassword: 'Change password' }, 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 8684be53acb..6d59f1d05a1 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx @@ -210,7 +210,11 @@ export default { . Voit kirjautua myös käyttämällä vahvaa tunnistautumista.

- ) + ), + email: 'Sähköpostiosoite', + password: 'Salasana', + rateLimitError: + 'Käyttäjätunnuksesi on väliaikaisesti lukittu kirjautumisyritysten määrästä johtuen. Kokeile myöhemmin uudelleen.' }, applying: { title: 'Kirjaudu Suomi.fi:ssä', @@ -1999,7 +2003,12 @@ export default { }, loginDetailsSection: { title: 'Kirjautumistiedot', - keycloakEmail: 'Käyttäjätunnus' + weakLoginUsername: 'Käyttäjätunnus', + password: 'Salasana', + newPassword: 'Uusi salasana', + repeatPassword: 'Vahvista uusi salasana', + setPassword: 'Aseta salasana', + updatePassword: 'Vaihda salasana' }, 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 741233376d7..ce831ed4f0a 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx @@ -211,7 +211,11 @@ const sv: Translations = { . Du kan också logga in med stark autentisering.

- ) + ), + email: 'E-post', + password: 'Lösenord', + rateLimitError: + 'Ditt konto har tillfälligt låsts på grund av ett stort antal inloggningsförsök. Vänligen försök igen senare.' }, applying: { title: 'Logga in via Suomi.fi', @@ -1996,7 +2000,12 @@ const sv: Translations = { }, loginDetailsSection: { title: 'Inloggningsinformation', - keycloakEmail: 'Användarnamn' + weakLoginUsername: 'Användarnamn', + password: 'Lösenord', + newPassword: 'Nytt lösenord', + repeatPassword: 'Bekräfta lösenordet', + setPassword: 'Ställ in lösenord', + updatePassword: 'Uppdatera lösenord' }, notificationsSection: { title: 'E-postmeddelanden', diff --git a/frontend/src/lib-customizations/espoo/featureFlags.tsx b/frontend/src/lib-customizations/espoo/featureFlags.tsx index 18412483305..4479c491797 100644 --- a/frontend/src/lib-customizations/espoo/featureFlags.tsx +++ b/frontend/src/lib-customizations/espoo/featureFlags.tsx @@ -43,7 +43,8 @@ const features: Features = { forceUnpublishDocumentTemplate: true, invoiceDisplayAccountNumber: true, serviceApplications: true, - multiSelectDeparture: true + multiSelectDeparture: true, + weakLogin: true }, staging: { environmentLabel: 'Staging', @@ -77,7 +78,8 @@ const features: Features = { forceUnpublishDocumentTemplate: true, invoiceDisplayAccountNumber: true, serviceApplications: true, - multiSelectDeparture: true + multiSelectDeparture: true, + weakLogin: true }, prod: { environmentLabel: null, @@ -110,7 +112,8 @@ const features: Features = { forceUnpublishDocumentTemplate: false, invoiceDisplayAccountNumber: true, serviceApplications: false, - multiSelectDeparture: false + multiSelectDeparture: false, + weakLogin: false } } diff --git a/frontend/src/lib-customizations/types.d.ts b/frontend/src/lib-customizations/types.d.ts index 0016a6b5a30..301103b2c09 100644 --- a/frontend/src/lib-customizations/types.d.ts +++ b/frontend/src/lib-customizations/types.d.ts @@ -273,6 +273,11 @@ interface BaseFeatureFlags { * Allow marking multiple children as departed in the employee mobile */ multiSelectDeparture?: boolean + + /** + * Enable support for citizen weak login + */ + weakLogin?: boolean } export type FeatureFlags = DeepReadonly diff --git a/service/build.gradle.kts b/service/build.gradle.kts index 821ff112adf..c18223a4679 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -273,6 +273,12 @@ tasks { classpath = sourceSets["test"].runtimeClasspath } + register("encodePassword", JavaExec::class) { + description = "Encode a password with the default password hash algorithm" + mainClass.set("fi.espoo.evaka.shared.auth.PasswordHasherCliKt") + classpath = sourceSets["test"].runtimeClasspath + } + register("copyDownloadOnlyDeps", Copy::class) { from(downloadOnly) into(layout.buildDirectory.dir("download-only")) diff --git a/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt b/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt index 74ac7570b29..c330987eecb 100644 --- a/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt +++ b/service/codegen/src/main/kotlin/evaka/codegen/api/Config.kt @@ -6,6 +6,7 @@ package evaka.codegen.api import evaka.codegen.api.TsProject.E2ETest import evaka.codegen.api.TsProject.LibCommon +import fi.espoo.evaka.Sensitive import fi.espoo.evaka.identity.ExternalId import fi.espoo.evaka.invoicing.service.ProductKey import fi.espoo.evaka.messaging.MessageReceiver @@ -212,6 +213,7 @@ val defaultMetadata = Map::class to TsRecord, Pair::class to TsTuple(size = 2), Triple::class to TsTuple(size = 3), + Sensitive::class to GenericWrapper, Void::class to Excluded, ExternalId::class to TsPlain("string"), YearMonth::class to diff --git a/service/codegen/src/main/kotlin/evaka/codegen/api/TsCodeGenerator.kt b/service/codegen/src/main/kotlin/evaka/codegen/api/TsCodeGenerator.kt index a79d7cfc2fb..77e3eac5bad 100644 --- a/service/codegen/src/main/kotlin/evaka/codegen/api/TsCodeGenerator.kt +++ b/service/codegen/src/main/kotlin/evaka/codegen/api/TsCodeGenerator.kt @@ -79,6 +79,7 @@ abstract class TsCodeGenerator(val metadata: TypeMetadata) { is TsPlain -> TsCode(tsRepr.type) is TsStringEnum -> TsCode(typeRef(tsRepr)) is TsExternalTypeRef -> tsRepr.keyRepresentation + is GenericWrapper -> keyType(tsRepr.getTypeArgs(tsType.typeArguments)) is Excluded, is TsArray, is TsRecord, @@ -97,6 +98,7 @@ abstract class TsCodeGenerator(val metadata: TypeMetadata) { is TsArray -> arrayType(tsRepr.getTypeArgs(tsType.typeArguments), compact) is TsRecord -> recordType(tsRepr.getTypeArgs(tsType.typeArguments), compact) is TsTuple -> tupleType(tsRepr.getTypeArgs(tsType.typeArguments), compact) + is GenericWrapper -> tsType(tsRepr.getTypeArgs(tsType.typeArguments), compact) is TsPlainObject -> { val typeArguments = tsRepr.getTypeArgs(tsType.typeArguments).map { typeArg -> @@ -211,6 +213,7 @@ export type ${sealed.name} = ${variants.joinToString(separator = " | ") { "${sea tsRepr.getTypeArgs(type.arguments).second?.let { check(it) } ?: false is TsTuple -> tsRepr.getTypeArgs(type.arguments).filterNotNull().any { check(it) } + is GenericWrapper -> check(tsRepr.getTypeArgs(type.arguments)) is TsPlainObject -> tsRepr.applyTypeArguments(type.arguments).values.any { check(it) } is TsObjectLiteral -> tsRepr.properties.values.any { check(it.type) } @@ -363,6 +366,8 @@ ${join(propCodes, ",\n").prependIndent(" ")} postfix = "]", ) } + is GenericWrapper -> + jsonDeserializerExpression(tsRepr.getTypeArgs(type.arguments), jsonExpression) is TsObjectLiteral, is TsSealedVariant -> TODO() is TsPlainObject -> { @@ -404,6 +409,8 @@ ${join(propCodes, ",\n").prependIndent(" ")} is TsArray, is TsStringEnum -> valueExpression is TsExternalTypeRef -> tsRepr.serializePathVariable?.invoke(valueExpression) + is GenericWrapper -> + serializePathVariable(tsRepr.getTypeArgs(type.arguments), valueExpression) is TsPlainObject, is TsSealedClass, is TsObjectLiteral, @@ -439,6 +446,8 @@ ${join(propCodes, ",\n").prependIndent(" ")} tsRepr.serializeRequestParam?.invoke(valueExpression, type.isMarkedNullable) ?: if (type.isMarkedNullable) valueExpression + "?.toString()" else valueExpression + ".toString()" + is GenericWrapper -> + serializeRequestParam(tsRepr.getTypeArgs(type.arguments), valueExpression) is TsArray, is TsPlainObject, is TsSealedClass, diff --git a/service/codegen/src/main/kotlin/evaka/codegen/api/TsRepresentation.kt b/service/codegen/src/main/kotlin/evaka/codegen/api/TsRepresentation.kt index 7bd449f21f2..2ae2244389f 100644 --- a/service/codegen/src/main/kotlin/evaka/codegen/api/TsRepresentation.kt +++ b/service/codegen/src/main/kotlin/evaka/codegen/api/TsRepresentation.kt @@ -33,8 +33,23 @@ sealed interface TsNamedType : TsRepresentation { get() = clazz.qualifiedName ?: clazz.jvmName } +/** Excludes a type from code generation completely */ data object Excluded : TsRepresentation +/** + * Marks a type to behave like a Kotlin-only wrapper parameterized with one type parameter. + * + * In Kotlin code, the type must have the form SomeType. In generated TS code, the wrapper + * doesn't exist and the underlying type T is used directly. The Kotlin type must serialize to / + * deserialize from a single value of type T. + */ +data object GenericWrapper : TsRepresentation { + override fun getTypeArgs(typeArgs: List): KType { + require(typeArgs.size == 1) { "Expected 1 type argument, got $typeArgs" } + return requireNotNull(typeArgs.single().type) + } +} + data class TsExternalTypeRef( val type: String, val keyRepresentation: TsCode?, diff --git a/service/codegen/src/main/kotlin/evaka/codegen/api/TypeMetadata.kt b/service/codegen/src/main/kotlin/evaka/codegen/api/TypeMetadata.kt index 6bf8634f0c0..0b6c34e0a85 100644 --- a/service/codegen/src/main/kotlin/evaka/codegen/api/TypeMetadata.kt +++ b/service/codegen/src/main/kotlin/evaka/codegen/api/TypeMetadata.kt @@ -66,7 +66,8 @@ fun discoverMetadata(initial: TypeMetadata, rootTypes: Sequence): TypeMet when (representation) { is TsArray, is TsRecord, - is TsTuple -> typeArguments.forEach { it.type?.discover() } + is TsTuple, + is GenericWrapper -> typeArguments.forEach { it.type?.discover() } is TsPlainObject -> representation.applyTypeArguments(typeArguments).values.forEach { it.discover() diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/PersonIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/PersonIntegrationTest.kt index 43b55785680..724984de900 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/PersonIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/PersonIntegrationTest.kt @@ -112,6 +112,7 @@ class PersonIntegrationTest : PureJdbiTest(resetDbBeforeEach = true) { PersonReference("child_document", "child_id"), PersonReference("child_document_read", "person_id"), PersonReference("child_sticky_note", "child_id"), + PersonReference("citizen_user", "id"), PersonReference("daily_service_time", "child_id"), PersonReference("daily_service_time_notification", "guardian_id"), PersonReference("daycare_assistance", "child_id"), diff --git a/service/src/main/kotlin/fi/espoo/evaka/Audit.kt b/service/src/main/kotlin/fi/espoo/evaka/Audit.kt index d997d404dd5..adb633785f7 100755 --- a/service/src/main/kotlin/fi/espoo/evaka/Audit.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/Audit.kt @@ -214,7 +214,11 @@ enum class Audit( CitizenNotificationSettingsRead, CitizenNotificationSettingsUpdate, CitizenLogin(securityEvent = true, securityLevel = "high"), + CitizenPasswordUpdate(securityEvent = true, securityLevel = "high"), + CitizenPasswordUpdateAttempt(securityEvent = true, securityLevel = "high"), CitizenUserDetailsRead(securityEvent = true, securityLevel = "high"), + CitizenWeakLogin(securityEvent = true, securityLevel = "high"), + CitizenWeakLoginAttempt(securityEvent = true, securityLevel = "high"), CitizenVoucherValueDecisionDownloadPdf, ClubTermCreate, ClubTermUpdate, diff --git a/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt b/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt index 68ce199602c..1e30ebc232a 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/EvakaEnv.kt @@ -4,6 +4,7 @@ package fi.espoo.evaka +import com.fasterxml.jackson.annotation.JsonValue import fi.espoo.evaka.daycare.domain.Language import fi.espoo.evaka.shared.domain.Rectangle import fi.espoo.evaka.shared.job.JobSchedule @@ -40,6 +41,7 @@ data class EvakaEnv( val plannedAbsenceEnabledForHourBasedServiceNeeds: Boolean, val personAddressEnvelopeWindowPosition: Rectangle, val replacementInvoicesStart: YearMonth?, + val newCitizenWeakLoginEnabled: Boolean, ) { companion object { fun fromEnvironment(env: Environment): EvakaEnv { @@ -75,6 +77,8 @@ data class EvakaEnv( env.lookup("evaka.replacement_invoices_start")?.let { YearMonth.parse(it) }, + newCitizenWeakLoginEnabled = + env.lookup("evaka.new_citizen_weak_login.enabled") ?: false, ) } } @@ -634,7 +638,7 @@ data class JamixEnv(val url: URI, val user: String, val password: Sensitive(val value: T) { +data class Sensitive(@JsonValue val value: T) { override fun toString(): String = "**REDACTED**" } diff --git a/service/src/main/kotlin/fi/espoo/evaka/pis/PersonQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/pis/PersonQueries.kt index ee80cd4c41e..141c92a0e68 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/pis/PersonQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/pis/PersonQueries.kt @@ -69,14 +69,17 @@ data class CitizenUserDetails( val backupPhone: String, val email: String?, val keycloakEmail: String?, + val weakLoginUsername: String?, ) fun Database.Read.getCitizenUserDetails(id: PersonId): CitizenUserDetails? = createQuery { sql( """ -SELECT id, first_name, last_name, preferred_name, street_address, postal_code, post_office, phone, backup_phone, email, keycloak_email -FROM person WHERE id = ${bind(id)} +SELECT id, first_name, last_name, preferred_name, street_address, postal_code, post_office, phone, backup_phone, email, keycloak_email, citizen_user.username AS weak_login_username +FROM person +LEFT JOIN citizen_user USING (id) +WHERE id = ${bind(id)} """ ) } 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 b04c5040279..94a823437cc 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/pis/SystemController.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/pis/SystemController.kt @@ -7,41 +7,32 @@ package fi.espoo.evaka.pis import fi.espoo.evaka.Audit import fi.espoo.evaka.AuditId import fi.espoo.evaka.EvakaEnv +import fi.espoo.evaka.Sensitive import fi.espoo.evaka.daycare.anyUnitHasFeature import fi.espoo.evaka.identity.ExternalId import fi.espoo.evaka.identity.ExternalIdentifier -import fi.espoo.evaka.pairing.MobileDeviceDetails -import fi.espoo.evaka.pairing.MobileDeviceIdentity -import fi.espoo.evaka.pairing.getDevice -import fi.espoo.evaka.pairing.getDeviceByToken -import fi.espoo.evaka.pairing.updateDeviceTracking +import fi.espoo.evaka.pairing.* import fi.espoo.evaka.pis.service.PersonService import fi.espoo.evaka.shared.EmployeeId 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.db.Database +import fi.espoo.evaka.shared.domain.BadRequest import fi.espoo.evaka.shared.domain.EvakaClock import fi.espoo.evaka.shared.domain.Forbidden import fi.espoo.evaka.shared.domain.NotFound -import fi.espoo.evaka.shared.security.AccessControl -import fi.espoo.evaka.shared.security.AccessControlCitizen -import fi.espoo.evaka.shared.security.Action -import fi.espoo.evaka.shared.security.CitizenFeatures -import fi.espoo.evaka.shared.security.EmployeeFeatures -import fi.espoo.evaka.shared.security.PilotFeature -import fi.espoo.evaka.shared.security.upsertCitizenUser -import fi.espoo.evaka.shared.security.upsertEmployeeUser -import fi.espoo.evaka.shared.security.upsertMobileDeviceUser +import fi.espoo.evaka.shared.security.* +import fi.espoo.evaka.user.getCitizenWeakLoginDetails +import fi.espoo.evaka.user.updateLastStrongLogin +import fi.espoo.evaka.user.updateLastWeakLogin +import fi.espoo.evaka.user.updatePassword import fi.espoo.evaka.webpush.WebPush -import java.util.UUID -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController +import java.util.* +import org.springframework.web.bind.annotation.* /** * Controller for "system" endpoints intended to be only called from apigw as the system internal @@ -53,6 +44,7 @@ class SystemController( private val accessControl: AccessControl, private val accessControlCitizen: AccessControlCitizen, private val env: EvakaEnv, + private val passwordService: PasswordService, private val webPush: WebPush?, ) { @PostMapping("/system/citizen-login") @@ -74,6 +66,9 @@ class SystemController( ) ?.let { CitizenUserIdentity(it.id) } ?: error("No person found with ssn") + if (request.keycloakEmail == null) { + tx.updateLastStrongLogin(clock, citizen.id) + } tx.updateCitizenOnLogin( clock, citizen.id, @@ -93,6 +88,49 @@ class SystemController( } } + @PostMapping("/system/citizen-weak-login") + fun citizenWeakLogin( + db: Database, + user: AuthenticatedUser.SystemInternalUser, + clock: EvakaClock, + @RequestBody request: CitizenWeakLoginRequest, + ): CitizenUserIdentity { + Audit.CitizenWeakLoginAttempt.log(targetId = AuditId(request.username)) + if (!env.newCitizenWeakLoginEnabled) throw BadRequest("New citizen weak login is disabled") + return db.connect { dbc -> + val citizen = dbc.read { it.getCitizenWeakLoginDetails(request.username) } + dbc.close() // avoid hogging the connection while we check the password + + // We want to run a constant-time password check even if we can't find the user, + // in order to avoid exposing information about username validity. A dummy + // placeholder is used if necessary, so we have *something* to compare against. + // Reference: OWASP Authentication Cheat Sheet - Authentication Responses + // https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses + val isMatch = passwordService.isMatch(request.password, citizen?.password) + if (!isMatch || citizen == null) throw Forbidden() + + dbc.transaction { tx -> + if (passwordService.needsRehashing(citizen.password)) { + tx.updatePassword( + clock = null, // avoid updating the password timestamp + citizen.id, + passwordService.encode(request.password), + ) + } + + tx.updateLastWeakLogin(clock, citizen.id) + personService.getPersonWithChildren(tx, user, citizen.id) + CitizenUserIdentity(citizen.id) + } + .also { + Audit.CitizenWeakLogin.log( + targetId = AuditId(request.username), + objectId = AuditId(it.id), + ) + } + } + } + @GetMapping("/system/citizen/{id}") fun citizenUser( db: Database, @@ -356,6 +394,8 @@ class SystemController( val keycloakEmail: String?, ) + data class CitizenWeakLoginRequest(val username: String, val password: Sensitive) + data class EmployeeUserResponse( val id: EmployeeId, val firstName: String, 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 4daa1e65a87..c10af37a61a 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 @@ -6,13 +6,11 @@ package fi.espoo.evaka.pis.controllers import fi.espoo.evaka.Audit import fi.espoo.evaka.AuditId -import fi.espoo.evaka.pis.EmailMessageType -import fi.espoo.evaka.pis.PersonalDataUpdate -import fi.espoo.evaka.pis.getDisabledEmailTypes -import fi.espoo.evaka.pis.getPersonById -import fi.espoo.evaka.pis.updateDisabledEmailTypes -import fi.espoo.evaka.pis.updatePersonalDetails +import fi.espoo.evaka.EvakaEnv +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.pis.* import fi.espoo.evaka.shared.auth.AuthenticatedUser +import fi.espoo.evaka.shared.auth.PasswordService import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.domain.BadRequest import fi.espoo.evaka.shared.domain.EvakaClock @@ -20,6 +18,7 @@ import fi.espoo.evaka.shared.security.AccessControl import fi.espoo.evaka.shared.security.Action import fi.espoo.evaka.shared.utils.EMAIL_PATTERN import fi.espoo.evaka.shared.utils.PHONE_PATTERN +import fi.espoo.evaka.user.updatePassword import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody @@ -28,7 +27,11 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/citizen/personal-data") -class PersonalDataControllerCitizen(private val accessControl: AccessControl) { +class PersonalDataControllerCitizen( + private val accessControl: AccessControl, + private val passwordService: PasswordService, + private val env: EvakaEnv, +) { @PutMapping fun updatePersonalData( db: Database, @@ -117,4 +120,39 @@ class PersonalDataControllerCitizen(private val accessControl: AccessControl) { } Audit.PersonalDataUpdate.log(targetId = AuditId(user.id)) } + + data class UpdatePasswordRequest(val password: Sensitive) { + init { + if ( + password.value.isEmpty() || password.value.length < 8 || password.value.length > 128 + ) { + throw BadRequest("Invalid password") + } + } + } + + @PutMapping("/password") + fun updatePassword( + db: Database, + user: AuthenticatedUser.Citizen, + clock: EvakaClock, + @RequestBody body: UpdatePasswordRequest, + ) { + if (!env.newCitizenWeakLoginEnabled) throw BadRequest("New citizen weak login is disabled") + Audit.CitizenPasswordUpdateAttempt.log(targetId = AuditId(user.id)) + val password = passwordService.encode(body.password) + db.connect { dbc -> + dbc.transaction { tx -> + accessControl.requirePermissionFor( + tx, + user, + clock, + Action.Citizen.Person.UPDATE_PASSWORD, + user.id, + ) + tx.updatePassword(clock, user.id, password) + } + } + Audit.CitizenPasswordUpdate.log(targetId = AuditId(user.id)) + } } diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/auth/Password.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/Password.kt new file mode 100644 index 00000000000..7511fedea23 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/Password.kt @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.JsonValue +import fi.espoo.evaka.Sensitive +import java.security.SecureRandom +import java.util.* +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.util.DigestFactory +import org.bouncycastle.util.Arrays + +// Password encoding/hashing +// The Bouncy Castle library handles all heavy lifting, such as: +// - Argon2 hash implementation +// - PBKDF2 key derivation implementation +// - constant-time byte array comparison + +/** A hashed and encoded password */ +data class EncodedPassword(val algorithm: PasswordHashAlgorithm, val salt: Salt, val hash: Hash) { + init { + require(algorithm.hashLength() == hash.length()) { + "Invalid password hash length: expected ${algorithm.hashLength()} bytes, got ${hash.length()}" + } + } + + /** + * Returns true if this encoded password matches the given raw password string. + * + * This check uses a constant-time comparison to protect against timing attacks. + */ + fun isMatch(password: Sensitive): Boolean = + this.algorithm.hash(this.salt, password) == this.hash + + /** A wrapper of salt bytes for better type-safety and simpler equals/hashCode/toString */ + class Salt(val value: ByteArray) { + override fun equals(other: Any?): Boolean = + other is Salt && Arrays.areEqual(this.value, other.value) + + override fun hashCode(): Int = Arrays.hashCode(value) + + @JsonValue override fun toString(): String = Base64.getEncoder().encodeToString(value) + + companion object { + @JsonCreator + @JvmStatic + fun parse(value: String): Salt = Salt(Base64.getDecoder().decode(value)) + + fun generate(random: SecureRandom): Salt { + val bytes = ByteArray(size = 16) + random.nextBytes(bytes) + return Salt(bytes) + } + } + } + + /** A wrapper of hash bytes for better type-safety and simpler equals/hashCode/toString */ + class Hash(private val value: ByteArray) { + override fun equals(other: Any?): Boolean = + other is Hash && + // *IMPORTANT*: uses constant time comparison to protect against timing attacks + Arrays.constantTimeAreEqual(this.value, other.value) + + override fun hashCode(): Int = Arrays.hashCode(value) + + fun length(): Int = value.size + + @JsonValue override fun toString(): String = Base64.getEncoder().encodeToString(value) + + companion object { + @JsonCreator + @JvmStatic + fun parse(value: String): Hash = Hash(Base64.getDecoder().decode(value)) + } + } +} + +/** + * A supported password hash algorithm. + * + * This is a sealed class and new algorithms can be added in a backwards-compatible way by adding + * more variants + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.SIMPLE_NAME, property = "type") +sealed class PasswordHashAlgorithm { + fun encode(password: Sensitive): EncodedPassword { + val salt = EncodedPassword.Salt.generate(SECURE_RANDOM) + return EncodedPassword(this, salt, hash(salt, password)) + } + + abstract fun hash(salt: EncodedPassword.Salt, password: Sensitive): EncodedPassword.Hash + + /** Length of the generated hash in bytes */ + abstract fun hashLength(): Int + + /** + * Returns a placeholder password that can be used for constant-time checks when a real encoded + * password is not available. + * + * The returned placeholder should have the correct hash length, but otherwise doesn't need to + * be a valid password + */ + abstract fun placeholder(): EncodedPassword + + data class Argon2id( + val hashLength: Int, + val version: Version, + val memoryKbytes: Int, + val iterations: Int, + val parallelism: Int, + ) : PasswordHashAlgorithm() { + enum class Version(val rawValue: Int) { + VERSION_13(Argon2Parameters.ARGON2_VERSION_13) + } + + override fun hashLength(): Int = hashLength + + override fun hash( + salt: EncodedPassword.Salt, + password: Sensitive, + ): EncodedPassword.Hash { + val output = ByteArray(size = hashLength) + val generator = Argon2BytesGenerator() + generator.init( + Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(this.version.rawValue) + .withMemoryAsKB(this.memoryKbytes) + .withIterations(this.iterations) + .withParallelism(this.parallelism) + .withSalt(salt.value) + .build() + ) + generator.generateBytes(password.value.toByteArray(Charsets.UTF_8), output) + return EncodedPassword.Hash(output) + } + + override fun placeholder(): EncodedPassword = + EncodedPassword( + this, + EncodedPassword.Salt.generate(SECURE_RANDOM), + EncodedPassword.Hash(ByteArray(size = hashLength)), + ) + } + + data class Pbkdf2(val hashType: HashType, val keySize: Int, val iterationCount: Int) : + PasswordHashAlgorithm() { + enum class HashType { + SHA256, + SHA512, + } + + override fun hashLength(): Int = keySize / 8 + + override fun hash( + salt: EncodedPassword.Salt, + password: Sensitive, + ): EncodedPassword.Hash { + val gen = + PKCS5S2ParametersGenerator( + when (hashType) { + HashType.SHA256 -> DigestFactory.createSHA256() + HashType.SHA512 -> DigestFactory.createSHA512() + } + ) + gen.init(password.value.toByteArray(Charsets.UTF_8), salt.value, iterationCount) + val parameters = gen.generateDerivedParameters(keySize) + return EncodedPassword.Hash((parameters as KeyParameter).key) + } + + override fun placeholder(): EncodedPassword = + EncodedPassword( + this, + EncodedPassword.Salt.generate(SECURE_RANDOM), + EncodedPassword.Hash(ByteArray(size = hashLength())), + ) + } + + companion object { + // OWASP recommendation: Argon2id, m=19456 (19 MiB), t=2, p=1 + // Reference: OWASP Password Storage Cheat Sheet + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + val DEFAULT: PasswordHashAlgorithm = + Argon2id( + hashLength = 32, + version = Argon2id.Version.VERSION_13, + memoryKbytes = 19_456, + iterations = 2, + parallelism = 1, + ) + + private val SECURE_RANDOM: SecureRandom = SecureRandom() + } +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordService.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordService.kt new file mode 100644 index 00000000000..dd6a57a1f5c --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/auth/PasswordService.kt @@ -0,0 +1,75 @@ +// 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.domain.ServiceUnavailable +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import org.springframework.stereotype.Service + +@Service +class PasswordService : AutoCloseable { + private val algorithm = PasswordHashAlgorithm.DEFAULT + private val passwordPlaceholder = algorithm.placeholder() + private val pool: ExecutorService = run { + val corePoolSize = 1 + val maximumPoolSize = 16 + val workQueueCapacity = 128 + val keepAliveTime = Pair(15L, TimeUnit.MINUTES) + val workQueue = ArrayBlockingQueue(workQueueCapacity) + val threadNumber = AtomicInteger(1) + val threadFactory = { r: Runnable -> + thread( + start = false, + name = "${this.javaClass.simpleName}.worker-${threadNumber.getAndIncrement()}", + priority = Thread.MIN_PRIORITY, + block = r::run, + ) + } + val handler = RejectedExecutionHandler { _, _ -> + throw ServiceUnavailable("No capacity to handle password operation") + } + ThreadPoolExecutor( + corePoolSize, + maximumPoolSize, + keepAliveTime.first, + keepAliveTime.second, + workQueue, + threadFactory, + handler, + ) + } + + /** + * Checks if the given password matches the given optional encoded password. + * + * If the given encoded password is null, the password is checked against a dummy placeholder. + * The hashing and comparison operations are executed in a separate worker thread, and may throw + * `ServiceUnavailable` if the work queue is full. + */ + @Throws(ServiceUnavailable::class) + fun isMatch(password: Sensitive, encoded: EncodedPassword?): Boolean = + pool.submit { (encoded ?: passwordPlaceholder).isMatch(password) }.get() + + /** + * Encodes the given raw password. + * + * The encoding operation is executed in a separate worker thread, and may throw + * `ServiceUnavailable` if the work queue is full. + */ + @Throws(ServiceUnavailable::class) + fun encode(password: Sensitive): EncodedPassword = + pool.submit { algorithm.encode(password) }.get() + + /** + * Returns true if the encoded password should be rehashed for security and/or maintenance + * reasons. + */ + fun needsRehashing(encoded: EncodedPassword): Boolean = encoded.algorithm != algorithm + + override fun close() = pool.close() +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/controllers/ExceptionHandler.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/controllers/ExceptionHandler.kt index 5a100f42826..5ae7977a8ba 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/controllers/ExceptionHandler.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/controllers/ExceptionHandler.kt @@ -4,11 +4,7 @@ package fi.espoo.evaka.shared.controllers -import fi.espoo.evaka.shared.domain.BadRequest -import fi.espoo.evaka.shared.domain.Conflict -import fi.espoo.evaka.shared.domain.Forbidden -import fi.espoo.evaka.shared.domain.NotFound -import fi.espoo.evaka.shared.domain.Unauthorized +import fi.espoo.evaka.shared.domain.* import jakarta.servlet.http.HttpServletRequest import java.io.IOException import java.lang.Exception @@ -68,6 +64,16 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { .body(ErrorResponse(errorCode = ex.errorCode)) } + @ExceptionHandler(value = [ServiceUnavailable::class]) + fun serviceUnavailable( + req: HttpServletRequest, + ex: ServiceUnavailable, + ): ResponseEntity { + logger.warn("Service unavailable (${ex.message})", ex) + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ErrorResponse(errorCode = ex.errorCode)) + } + override fun handleMaxUploadSizeExceededException( ex: MaxUploadSizeExceededException, headers: HttpHeaders, diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/domain/Errors.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/domain/Errors.kt index af9f96df869..4d292185331 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/domain/Errors.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/domain/Errors.kt @@ -33,3 +33,9 @@ data class Unauthorized( val errorCode: String? = null, override val cause: Throwable? = null, ) : RuntimeException(message, cause) + +data class ServiceUnavailable( + override val message: String = "Service unavailable", + val errorCode: String? = null, + override val cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt index 3565d40fa1f..c14b9e18360 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt @@ -568,7 +568,8 @@ sealed interface Action { READ_CHILD_DOCUMENTS_UNREAD_COUNT(IsCitizen(allowWeakLogin = true).self()), UPDATE_PERSONAL_DATA(IsCitizen(allowWeakLogin = false).self()), READ_NOTIFICATION_SETTINGS(IsCitizen(allowWeakLogin = true).self()), - UPDATE_NOTIFICATION_SETTINGS(IsCitizen(allowWeakLogin = true).self()); + UPDATE_NOTIFICATION_SETTINGS(IsCitizen(allowWeakLogin = true).self()), + UPDATE_PASSWORD(IsCitizen(allowWeakLogin = false).self()); override fun toString(): String = "${javaClass.name}.$name" } diff --git a/service/src/main/kotlin/fi/espoo/evaka/user/CitizenUserQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/user/CitizenUserQueries.kt new file mode 100644 index 00000000000..a258495fd88 --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/user/CitizenUserQueries.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.user + +import fi.espoo.evaka.shared.PersonId +import fi.espoo.evaka.shared.auth.EncodedPassword +import fi.espoo.evaka.shared.db.Database +import fi.espoo.evaka.shared.domain.EvakaClock +import org.jdbi.v3.json.Json + +fun Database.Transaction.updateLastStrongLogin(clock: EvakaClock, id: PersonId) = + createUpdate { + sql( + """ +INSERT INTO citizen_user (id, last_strong_login) +VALUES (${bind(id)}, ${bind(clock.now())}) +ON CONFLICT (id) DO UPDATE SET last_strong_login = excluded.last_strong_login +""" + ) + } + .updateExactlyOne() + +data class CitizenWeakLoginDetails( + val id: PersonId, + val username: String, + @Json val password: EncodedPassword, +) + +fun Database.Read.getCitizenWeakLoginDetails(username: String): CitizenWeakLoginDetails? = + createQuery { + sql( + """ +SELECT id, username, password +FROM citizen_user +WHERE username = ${bind(username)} +""" + ) + } + .exactlyOneOrNull() + +fun Database.Transaction.updateLastWeakLogin(clock: EvakaClock, id: PersonId) = + createUpdate { + sql( + """ +UPDATE citizen_user SET last_weak_login = ${bind(clock.now())} +WHERE id = ${bind(id)} +""" + ) + } + .updateExactlyOne() + +fun Database.Transaction.updatePassword( + clock: EvakaClock?, // null = don't update timestamp + id: PersonId, + password: EncodedPassword, +) = + createUpdate { + sql( + """ +UPDATE citizen_user +SET + password = ${bindJson(password)}, + password_updated_at = coalesce(${bind(clock?.now())}, password_updated_at) +WHERE id = ${bind(id)} +""" + ) + } + .updateExactlyOne() diff --git a/service/src/main/resources/application-local.yaml b/service/src/main/resources/application-local.yaml index b472fcbc845..b4410893984 100755 --- a/service/src/main/resources/application-local.yaml +++ b/service/src/main/resources/application-local.yaml @@ -72,6 +72,8 @@ evaka: vapid_private_key: G3IfWt-tclp_R5d_SIMLl_jjttrC86dwG4Fs8OwMDmg use_new_assistance_model: true replacement_invoices_start: '2021-01' + new_citizen_weak_login: + enabled: true espoo: integration: invoice: diff --git a/service/src/main/resources/db/migration/V468__citizen_user.sql b/service/src/main/resources/db/migration/V468__citizen_user.sql new file mode 100644 index 00000000000..8bab4827edc --- /dev/null +++ b/service/src/main/resources/db/migration/V468__citizen_user.sql @@ -0,0 +1,24 @@ +CREATE TABLE citizen_user ( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + last_strong_login timestamp with time zone, + last_weak_login timestamp with time zone, + username text, + username_updated_at timestamp with time zone, + password jsonb, + password_updated_at timestamp with time zone +); + +ALTER TABLE citizen_user + ADD CONSTRAINT fk$person FOREIGN KEY (id) REFERENCES person (id), + ADD CONSTRAINT uniq$citizen_user_username UNIQUE (username), + ADD CONSTRAINT check$username_format CHECK (lower(trim(username)) = username), -- lowercase, no extra whitespace around it + ADD CONSTRAINT check$weak_credentials CHECK (num_nonnulls(username, username_updated_at, password, password_updated_at) = ANY(array[0, 4])); + +CREATE TRIGGER set_timestamp BEFORE UPDATE ON citizen_user + FOR EACH ROW EXECUTE PROCEDURE trigger_refresh_updated_at(); + +INSERT INTO citizen_user (id, created_at) +SELECT id, created +FROM person WHERE last_login IS NOT NULL; diff --git a/service/src/main/resources/migrations.txt b/service/src/main/resources/migrations.txt index 37141bcfde2..ce93570bb50 100644 --- a/service/src/main/resources/migrations.txt +++ b/service/src/main/resources/migrations.txt @@ -463,3 +463,4 @@ V464__scheduled_tasks_priority.sql V465__invoice_replacement_info.sql V466__push_notification_calendar_event_reservation.sql V467__push_notification_calendar_event_reservation_defaults.sql +V468__citizen_user.sql diff --git a/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordHasherCli.kt b/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordHasherCli.kt new file mode 100644 index 00000000000..ab4c2ef3af4 --- /dev/null +++ b/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordHasherCli.kt @@ -0,0 +1,24 @@ +// 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.config.defaultJsonMapperBuilder +import kotlin.system.exitProcess + +@Suppress("ktlint:evaka:no-println") +fun main(args: Array) { + val password = args.toList().singleOrNull()?.let(::Sensitive) + if (password == null) { + println("Expected exactly one argument: a password") + println( + "If you are running via gradle, try passing your password using --args: ./gradlew encodePassword --args=\"mypassword\"" + ) + exitProcess(1) + } + val jsonMapper = defaultJsonMapperBuilder().build() + println("Encoded password as JSON:") + println(jsonMapper.writeValueAsString(PasswordHashAlgorithm.DEFAULT.encode(password))) +} diff --git a/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordTest.kt b/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordTest.kt new file mode 100644 index 00000000000..ccd0c272d99 --- /dev/null +++ b/service/src/test/kotlin/fi/espoo/evaka/shared/auth/PasswordTest.kt @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.shared.auth + +import com.fasterxml.jackson.module.kotlin.readValue +import fi.espoo.evaka.Sensitive +import fi.espoo.evaka.shared.config.defaultJsonMapperBuilder +import java.util.stream.Stream +import kotlin.test.* +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class PasswordTest { + fun algorithms(): Stream = + listOf( + PasswordHashAlgorithm.DEFAULT, + // Keycloak default Argon2id + PasswordHashAlgorithm.Argon2id( + hashLength = 32, + version = PasswordHashAlgorithm.Argon2id.Version.VERSION_13, + memoryKbytes = 7168, + iterations = 5, + parallelism = 1, + ), + // Keycloak old pbkdf2-sha256 + PasswordHashAlgorithm.Pbkdf2( + hashType = PasswordHashAlgorithm.Pbkdf2.HashType.SHA256, + keySize = 512, + iterationCount = 27500, + ), + ) + .stream() + + private val jsonMapper = defaultJsonMapperBuilder().build() + + @ParameterizedTest + @MethodSource("algorithms") + fun `no validation error is thrown when a placeholder password is generated`( + algorithm: PasswordHashAlgorithm + ) { + assertDoesNotThrow { algorithm.placeholder() } + } + + @ParameterizedTest + @MethodSource("algorithms") + fun `encoding the same raw password twice gives passwords that match the original but have different salt and hash values`( + algorithm: PasswordHashAlgorithm + ) { + val raw = Sensitive("password") + + val first = algorithm.encode(raw) + assertTrue(first.isMatch(raw)) + + val second = algorithm.encode(raw) + assertTrue(second.isMatch(raw)) + + assertNotEquals(first, second) + assertEquals(first.algorithm, second.algorithm) + assertNotEquals(first.salt, second.salt) + assertNotEquals(first.hash, second.hash) + } + + @ParameterizedTest + @MethodSource("algorithms") + fun `an encoded password does not match a different raw password`( + algorithm: PasswordHashAlgorithm + ) { + val password = algorithm.encode(Sensitive("password")) + assertFalse(password.isMatch(Sensitive("wontmatch"))) + } + + @ParameterizedTest + @MethodSource("algorithms") + fun `an encoded password can be formatted to a string and parsed back to its original form`( + algorithm: PasswordHashAlgorithm + ) { + val password = algorithm.encode(Sensitive("password")) + val parsed = jsonMapper.readValue(jsonMapper.writeValueAsString(password)) + assertEquals(password, parsed) + } + + @Test + fun `matching an Argon2 password originating from Keycloak is possible`() { + // Raw example keycloak credential data from the `credential` database table: + // secret_data: + // {"value":"O7vS90g14jWr4ESbUpJ3KX5y1NMZcMgjuqPHrZ4Eq8U=","salt":"LgSKeCa5qZH6Dh0PE17AwQ==","additionalParameters":{}} + // credential_data: + // {"hashIterations":5,"algorithm":"argon2","additionalParameters":{"hashLength":["32"],"memory":["7168"],"type":["id"],"version":["1.3"],"parallelism":["1"]}} + val password = + EncodedPassword( + PasswordHashAlgorithm.Argon2id( + hashLength = 32, + version = PasswordHashAlgorithm.Argon2id.Version.VERSION_13, + memoryKbytes = 7168, + iterations = 5, + parallelism = 1, + ), + EncodedPassword.Salt.parse("LgSKeCa5qZH6Dh0PE17AwQ=="), + EncodedPassword.Hash.parse("O7vS90g14jWr4ESbUpJ3KX5y1NMZcMgjuqPHrZ4Eq8U="), + ) + assertTrue(password.isMatch(Sensitive("testpassword"))) + } + + @Test + fun `matching a PBKDF2-SHA256 password originating from Keycloak is possible`() { + // Raw example keycloak credential data from the `credential` database table: + // secret_data: + // {"value":"9yUI9up7DA09THuasmN5Z9pN+X5GUvJZY3lnXZYNB/gsGBtL8PjHANnR/H1G3IhhipUr27sBNJ4eek7AMP5UBw==","salt":"VUBELKb6poajPUjlaK1zCQ==","additionalParameters":{}} + // credential_data: + // {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} + val password = + EncodedPassword( + PasswordHashAlgorithm.Pbkdf2( + hashType = PasswordHashAlgorithm.Pbkdf2.HashType.SHA256, + keySize = 512, + iterationCount = 27500, + ), + EncodedPassword.Salt.parse("VUBELKb6poajPUjlaK1zCQ=="), + EncodedPassword.Hash.parse( + "9yUI9up7DA09THuasmN5Z9pN+X5GUvJZY3lnXZYNB/gsGBtL8PjHANnR/H1G3IhhipUr27sBNJ4eek7AMP5UBw==" + ), + ) + assertTrue(password.isMatch(Sensitive("test123"))) + } + + @Test + fun `matching a PBKDF2-SHA256 password originating from Keycloak is possible (alternative settings)`() { + // Raw example keycloak credential data from the `credential` database table: + // secret_data: + // {"value":"uqz68HhH43ZYJhTWB1L/dudCRIhsud4Xx2NbeG/nOGs=","salt":"0tjU8miBns3EPuTh63LYUA==","additionalParameters":{}} + // credential_data: + // {"hashIterations":600000,"algorithm":"pbkdf2-sha256","additionalParameters":{}} + val password = + EncodedPassword( + PasswordHashAlgorithm.Pbkdf2( + hashType = PasswordHashAlgorithm.Pbkdf2.HashType.SHA256, + keySize = 256, + iterationCount = 600000, + ), + EncodedPassword.Salt.parse("0tjU8miBns3EPuTh63LYUA=="), + EncodedPassword.Hash.parse("uqz68HhH43ZYJhTWB1L/dudCRIhsud4Xx2NbeG/nOGs="), + ) + assertTrue(password.isMatch(Sensitive("testpassword"))) + } + + @Test + fun `matching a PBKDF2-SHA512 password originating from Keycloak is possible`() { + // Raw example keycloak credential data from the `credential` database table: + // secret_data: + // {"value":"x4QqNb57CHVDsuIM+UMaOeryY7WsOGhxPFzjhEQgoisZ2hrviOSvuokCIXFXHkvBF47BLXDj2MA/g4vUBMuJcw==","salt":"IQU7tTvDbMtynBAFmSOrpA==","additionalParameters":{}} + // credential_data: + // {"hashIterations":210000,"algorithm":"pbkdf2-sha512","additionalParameters":{}} + val password = + EncodedPassword( + PasswordHashAlgorithm.Pbkdf2( + hashType = PasswordHashAlgorithm.Pbkdf2.HashType.SHA512, + keySize = 512, + iterationCount = 210000, + ), + EncodedPassword.Salt.parse("IQU7tTvDbMtynBAFmSOrpA=="), + EncodedPassword.Hash.parse( + "x4QqNb57CHVDsuIM+UMaOeryY7WsOGhxPFzjhEQgoisZ2hrviOSvuokCIXFXHkvBF47BLXDj2MA/g4vUBMuJcw==" + ), + ) + assertTrue(password.isMatch(Sensitive("testpassword"))) + } +}