diff --git a/frontend/src/component/user/ForgottenPassword/ForgottenPassword.test.tsx b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.test.tsx index 9185f3983a..fc52ccdb02 100644 --- a/frontend/src/component/user/ForgottenPassword/ForgottenPassword.test.tsx +++ b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.test.tsx @@ -2,9 +2,33 @@ import { screen } from '@testing-library/react'; import { FORGOTTEN_PASSWORD_FIELD } from 'utils/testIds'; import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword'; import { render } from 'utils/testRenderer'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import userEvent from '@testing-library/user-event'; + +const server = testServerSetup(); test('should render password auth', async () => { render(); + const user = userEvent.setup(); - await screen.findByTestId(FORGOTTEN_PASSWORD_FIELD); + const submitButton = screen.getByText('Submit'); + const emailField = screen.getByTestId(FORGOTTEN_PASSWORD_FIELD); + + userEvent.type(emailField, 'user@example.com'); + testServerRoute(server, '/auth/reset/password-email', {}, 'post', 200); + submitButton.click(); + + await screen.findByText('Attempted to send email'); + await screen.findByText('Try again'); + expect(screen.queryByText('Submit')).not.toBeInTheDocument(); + + testServerRoute(server, '/auth/reset/password-email', {}, 'post', 429); + submitButton.click(); + + await screen.findByText('Too many password reset attempts'); + await screen.findByText('Try again'); + expect(screen.queryByText('Submit')).not.toBeInTheDocument(); + expect( + screen.queryByText('Attempted to send email'), + ).not.toBeInTheDocument(); }); diff --git a/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx index 808691ffe6..0031085f22 100644 --- a/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx +++ b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx @@ -48,29 +48,32 @@ const StyledTypography = styled(Typography)(({ theme }) => ({ ...textCenter, })); +type State = 'initial' | 'loading' | 'attempted' | 'too_many_attempts'; + const ForgottenPassword = () => { const [email, setEmail] = useState(''); - const [attempted, setAttempted] = useState(false); - const [loading, setLoading] = useState(false); + const [state, setState] = useState('initial'); const [attemptedEmail, setAttemptedEmail] = useState(''); - const ref = useLoading(loading); + const ref = useLoading(state === 'loading'); const onClick = async (e: SyntheticEvent) => { e.preventDefault(); - setLoading(true); + setState('loading'); setAttemptedEmail(email); const path = formatApiPath('auth/reset/password-email'); - await fetch(path, { + const res = await fetch(path, { headers: { 'Content-Type': 'application/json', }, method: 'POST', body: JSON.stringify({ email }), }); - - setAttempted(true); - setLoading(false); + if (res.status === 429) { + setState('too_many_attempts'); + } else { + setState('attempted'); + } }; return ( @@ -78,7 +81,7 @@ const ForgottenPassword = () => { Forgotten password Attempted to send email @@ -91,6 +94,17 @@ const ForgottenPassword = () => { } /> + + + Too many password reset attempts + + Please wait another minute before your next attempt + + } + /> Please provide your email address. If it exists in the @@ -113,10 +127,10 @@ const ForgottenPassword = () => { type='submit' data-loading color='primary' - disabled={loading} + disabled={state === 'loading'} > Submit} elseShow={Try again} /> @@ -126,7 +140,7 @@ const ForgottenPassword = () => { type='submit' data-loading variant='outlined' - disabled={loading} + disabled={state === 'loading'} component={Link} to='/login' sx={(theme) => ({ diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index bcb5db4f2e..1750a7f480 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -176,6 +176,7 @@ exports[`should create default config 1`] = ` "rateLimiting": { "callIncomingWebhookMaxPerSecond": 1, "createUserMaxPerMinute": 20, + "passwordResetMaxPerMinute": 1, "simpleLoginMaxPerMinute": 10, }, "secureHeaders": false, diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index b8711e0509..661efe1450 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -142,6 +142,10 @@ function loadRateLimitingConfig(options: IUnleashOptions): IRateLimiting { process.env.SIMPLE_LOGIN_LIMIT_PER_MINUTE, 10, ); + const passwordResetMaxPerMinute = parseEnvVarNumber( + process.env.PASSWORD_RESET_LIMIT_PER_MINUTE, + 1, + ); const callIncomingWebhookMaxPerSecond = parseEnvVarNumber( process.env.INCOMING_WEBHOOK_RATE_LIMIT_PER_SECOND, 1, @@ -150,6 +154,7 @@ function loadRateLimitingConfig(options: IUnleashOptions): IRateLimiting { const defaultRateLimitOptions: IRateLimiting = { createUserMaxPerMinute, simpleLoginMaxPerMinute, + passwordResetMaxPerMinute, callIncomingWebhookMaxPerSecond, }; return mergeAll([defaultRateLimitOptions, options.rateLimiting || {}]); diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 98225f49ff..27331a56e1 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -355,6 +355,12 @@ export default class MetricsMonitor { rateLimits .labels({ endpoint: '/auth/simple', method: 'POST' }) .set(config.rateLimiting.simpleLoginMaxPerMinute); + rateLimits + .labels({ + endpoint: '/auth/reset/password-email', + method: 'POST', + }) + .set(config.rateLimiting.passwordResetMaxPerMinute); rateLimits .labels({ endpoint: '/api/incoming-webhook/:name', diff --git a/src/lib/routes/auth/reset-password-controller.ts b/src/lib/routes/auth/reset-password-controller.ts index 69e52597e7..28911acd4a 100644 --- a/src/lib/routes/auth/reset-password-controller.ts +++ b/src/lib/routes/auth/reset-password-controller.ts @@ -17,6 +17,8 @@ import { emptyResponse, getStandardResponses, } from '../../openapi/util/standard-responses'; +import rateLimit from 'express-rate-limit'; +import { minutesToMilliseconds } from 'date-fns'; interface IValidateQuery { token: string; @@ -129,6 +131,13 @@ class ResetPasswordController extends Controller { ...getStandardResponses(401, 404, 415), }, }), + rateLimit({ + windowMs: minutesToMilliseconds(1), + max: config.rateLimiting.passwordResetMaxPerMinute, + validate: false, + standardHeaders: true, + legacyHeaders: false, + }), ], }); } diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts index cb91475c27..e9132b5cf6 100644 --- a/src/lib/types/option.ts +++ b/src/lib/types/option.ts @@ -206,6 +206,7 @@ export interface IMetricsRateLimiting { export interface IRateLimiting { createUserMaxPerMinute: number; simpleLoginMaxPerMinute: number; + passwordResetMaxPerMinute: number; callIncomingWebhookMaxPerSecond: number; }