Skip to content

Commit

Permalink
Merge pull request #6286 from espoon-voltti/finalize-new-weak-login
Browse files Browse the repository at this point in the history
Viimeistellään kuntalaisen kevytkirjautussivu käyttöönottoa varten
  • Loading branch information
Gekkio authored Jan 27, 2025
2 parents 50fff8a + b5bc7e5 commit dd23e80
Show file tree
Hide file tree
Showing 20 changed files with 328 additions and 642 deletions.
6 changes: 5 additions & 1 deletion apigw/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,11 @@ export function apiRouter(config: Config, redisClient: RedisClient) {
router.post(
'/citizen/auth/weak-login',
express.json(),
authWeakLogin(citizenSessions, redisClient)
authWeakLogin(
citizenSessions,
config.citizen.weakLoginRateLimit,
redisClient
)
)
router.use('/citizen/public/map-api', mapRoutes)
router.all('/citizen/public/*', citizenProxy)
Expand Down
31 changes: 16 additions & 15 deletions apigw/src/enduser/routes/auth-weak-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,31 @@ const Request = z.object({

const eventCode = (name: string) => `evaka.citizen_weak.${name}`

const loginAttemptsPerHour = 20

export const authWeakLogin = (
sessions: Sessions<'citizen'>,
loginAttemptsPerHour: number,
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
if (loginAttemptsPerHour > 0) {
// 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)
Expand Down
16 changes: 14 additions & 2 deletions apigw/src/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ const envVariables = {
*/
PIN_SESSION_TIMEOUT_SECONDS: 10 * 60,

/**
* Rate limit for citizen weak logins per username (attempts per hour).
*
* 0 means no limit
*/
CITIZEN_WEAK_LOGIN_RATE_LIMIT: 20,

// ----- Redis configuration -----
/**
* Redis server hostname
Expand Down Expand Up @@ -415,6 +422,7 @@ function createLocalDevelopmentOverrides(): Partial<EnvVariables> {
CITIZEN_COOKIE_SECRET: 'A very hush hush cookie secret.',
EMPLOYEE_COOKIE_SECRET: 'A very hush hush cookie secret.',
USE_SECURE_COOKIES: false,
CITIZEN_WEAK_LOGIN_RATE_LIMIT: 0,

REDIS_HOST: '127.0.0.1',
REDIS_PORT: 6379,
Expand Down Expand Up @@ -456,7 +464,7 @@ function createLocalDevelopmentOverrides(): Partial<EnvVariables> {
}

export interface Config {
citizen: SessionConfig
citizen: SessionConfig & { weakLoginRateLimit: number }
employee: SessionConfig
ad:
| { type: 'mock' | 'disabled' }
Expand Down Expand Up @@ -739,7 +747,11 @@ export function configFromEnv(): Config {
),
sessionTimeoutMinutes:
optional('CITIZEN_SESSION_TIMEOUT_MINUTES', parseInteger) ??
defaultSessionTimeoutMinutes
defaultSessionTimeoutMinutes,
weakLoginRateLimit: required(
'CITIZEN_WEAK_LOGIN_RATE_LIMIT',
parseInteger
)
},
employee: {
useSecureCookies,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/citizen-frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import IncomeStatementView from './income-statements/IncomeStatementView'
import IncomeStatements from './income-statements/IncomeStatements'
import { Localization, useTranslation } from './localization'
import LoginPage from './login/LoginPage'
import LoginFormPage from './login/WeakLoginFormPage'
import MapPage from './map/MapPage'
import MessagesPage from './messages/MessagesPage'
import { MessageContextProvider } from './messages/state'
Expand Down Expand Up @@ -143,6 +144,14 @@ export default createBrowserRouter([
path: '/',
element: <App />,
children: [
{
path: '/login/form',
element: (
<ScrollToTop>
<LoginFormPage />
</ScrollToTop>
)
},
{
path: '/login',
element: (
Expand Down
126 changes: 26 additions & 100 deletions frontend/src/citizen-frontend/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,13 @@
// SPDX-License-Identifier: LGPL-2.1-or-later

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { useMemo, useState } from 'react'
import { Link, Navigate, useSearchParams } from 'react-router'
import React, { useState } from 'react'
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router'
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 {
Expand All @@ -34,10 +27,13 @@ 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 { getStrongLoginUri, getWeakLoginUri } from '../navigation/const'
import {
getStrongLoginUri,
getWeakKeycloakLoginUri,
getWeakLoginUri
} from '../navigation/const'

import { systemNotificationsQuery } from './queries'

Expand All @@ -51,6 +47,7 @@ export default React.memo(function LoginPage() {

const [searchParams] = useSearchParams()
const unvalidatedNextPath = searchParams.get('next')
const navigate = useNavigate()

const [showInfoBoxText1, setShowInfoBoxText1] = useState(false)
const [showInfoBoxText2, setShowInfoBoxText2] = useState(false)
Expand Down Expand Up @@ -104,14 +101,24 @@ export default React.memo(function LoginPage() {
/>
)}
<Gap size="s" />
<LinkButton
href={getWeakLoginUri(unvalidatedNextPath ?? '/')}
data-qa="weak-login"
>
{i18n.loginPage.login.link}
</LinkButton>
{featureFlags.weakLogin && (
<WeakLoginForm unvalidatedNextPath={unvalidatedNextPath} />
{featureFlags.weakLogin ? (
<LinkButton
href={getWeakLoginUri(unvalidatedNextPath ?? '/')}
onClick={(e) => {
e.preventDefault()
void navigate(getWeakLoginUri(unvalidatedNextPath ?? '/'))
}}
data-qa="weak-login"
>
{i18n.loginPage.login.link}
</LinkButton>
) : (
<LinkButton
href={getWeakKeycloakLoginUri(unvalidatedNextPath ?? '/')}
data-qa="weak-login"
>
{i18n.loginPage.login.link}
</LinkButton>
)}
</ContentArea>
<ContentArea opaque>
Expand Down Expand Up @@ -159,87 +166,6 @@ 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 (
<>
<Gap size="m" />
<form action="" onSubmit={(e) => e.preventDefault()}>
<FixedSpaceColumn spacing="xs">
{rateLimitError && (
<AlertBox message={i18n.loginPage.login.rateLimitError} />
)}
<InputFieldF
autoComplete="email"
bind={username}
placeholder={i18n.loginPage.login.email}
width="L"
hideErrorsBeforeTouched={true}
/>
<InputFieldF
autoComplete="current-password"
bind={password}
type="password"
placeholder={i18n.loginPage.login.password}
width="L"
hideErrorsBeforeTouched={true}
/>
<AsyncButton
primary
type="submit"
text={i18n.loginPage.login.link}
disabled={!form.isValid()}
onClick={() =>
authWeakLoginResult(form.state.username, form.state.password)
}
onSuccess={() => window.location.replace(nextUrl ?? '/')}
onFailure={(error) => {
if (error.statusCode === 429) {
setRateLimitError(true)
}
}}
/>
</FixedSpaceColumn>
</form>
</>
)
})

const MapLink = styled(Link)`
text-decoration: none;
display: inline-block;
Expand Down
Loading

0 comments on commit dd23e80

Please sign in to comment.