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;
}