-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #5926 from espoon-voltti/new-citizen-weak-auth
Alustava uusi kevytkirjautuminen kuntalaisille (ei vielä tuotantokäytössä)
- Loading branch information
Showing
50 changed files
with
1,373 additions
and
112 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.