1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: rate limit password reset attempts (#6257)

This commit is contained in:
Mateusz Kwasniewski 2024-02-21 08:49:54 +01:00 committed by GitHub
parent f3c01545f2
commit e5c07f00cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 73 additions and 13 deletions

View File

@ -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(<ForgottenPassword />);
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();
});

View File

@ -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<State>('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 = () => {
<StyledDiv ref={ref}>
<StyledTitle data-loading>Forgotten password</StyledTitle>
<ConditionallyRender
condition={attempted}
condition={state === 'attempted'}
show={
<Alert severity='success' data-loading>
<AlertTitle>Attempted to send email</AlertTitle>
@ -91,6 +94,17 @@ const ForgottenPassword = () => {
</Alert>
}
/>
<ConditionallyRender
condition={state === 'too_many_attempts'}
show={
<Alert severity='warning' data-loading>
<AlertTitle>
Too many password reset attempts
</AlertTitle>
Please wait another minute before your next attempt
</Alert>
}
/>
<StyledForm onSubmit={onClick}>
<StyledTypography variant='body1' data-loading>
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'}
>
<ConditionallyRender
condition={!attempted}
condition={state === 'initial'}
show={<span>Submit</span>}
elseShow={<span>Try again</span>}
/>
@ -126,7 +140,7 @@ const ForgottenPassword = () => {
type='submit'
data-loading
variant='outlined'
disabled={loading}
disabled={state === 'loading'}
component={Link}
to='/login'
sx={(theme) => ({

View File

@ -176,6 +176,7 @@ exports[`should create default config 1`] = `
"rateLimiting": {
"callIncomingWebhookMaxPerSecond": 1,
"createUserMaxPerMinute": 20,
"passwordResetMaxPerMinute": 1,
"simpleLoginMaxPerMinute": 10,
},
"secureHeaders": false,

View File

@ -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 || {}]);

View File

@ -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',

View File

@ -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,
}),
],
});
}

View File

@ -206,6 +206,7 @@ export interface IMetricsRateLimiting {
export interface IRateLimiting {
createUserMaxPerMinute: number;
simpleLoginMaxPerMinute: number;
passwordResetMaxPerMinute: number;
callIncomingWebhookMaxPerSecond: number;
}