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:
parent
f3c01545f2
commit
e5c07f00cb
@ -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();
|
||||
});
|
||||
|
@ -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) => ({
|
||||
|
@ -176,6 +176,7 @@ exports[`should create default config 1`] = `
|
||||
"rateLimiting": {
|
||||
"callIncomingWebhookMaxPerSecond": 1,
|
||||
"createUserMaxPerMinute": 20,
|
||||
"passwordResetMaxPerMinute": 1,
|
||||
"simpleLoginMaxPerMinute": 10,
|
||||
},
|
||||
"secureHeaders": false,
|
||||
|
@ -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 || {}]);
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
@ -206,6 +206,7 @@ export interface IMetricsRateLimiting {
|
||||
export interface IRateLimiting {
|
||||
createUserMaxPerMinute: number;
|
||||
simpleLoginMaxPerMinute: number;
|
||||
passwordResetMaxPerMinute: number;
|
||||
callIncomingWebhookMaxPerSecond: number;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user