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 { FORGOTTEN_PASSWORD_FIELD } from 'utils/testIds';
|
||||||
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
||||||
import { render } from 'utils/testRenderer';
|
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 () => {
|
test('should render password auth', async () => {
|
||||||
render(<ForgottenPassword />);
|
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,
|
...textCenter,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type State = 'initial' | 'loading' | 'attempted' | 'too_many_attempts';
|
||||||
|
|
||||||
const ForgottenPassword = () => {
|
const ForgottenPassword = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [attempted, setAttempted] = useState(false);
|
const [state, setState] = useState<State>('initial');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [attemptedEmail, setAttemptedEmail] = useState('');
|
const [attemptedEmail, setAttemptedEmail] = useState('');
|
||||||
const ref = useLoading(loading);
|
const ref = useLoading(state === 'loading');
|
||||||
|
|
||||||
const onClick = async (e: SyntheticEvent) => {
|
const onClick = async (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setState('loading');
|
||||||
setAttemptedEmail(email);
|
setAttemptedEmail(email);
|
||||||
|
|
||||||
const path = formatApiPath('auth/reset/password-email');
|
const path = formatApiPath('auth/reset/password-email');
|
||||||
await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
|
if (res.status === 429) {
|
||||||
setAttempted(true);
|
setState('too_many_attempts');
|
||||||
setLoading(false);
|
} else {
|
||||||
|
setState('attempted');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -78,7 +81,7 @@ const ForgottenPassword = () => {
|
|||||||
<StyledDiv ref={ref}>
|
<StyledDiv ref={ref}>
|
||||||
<StyledTitle data-loading>Forgotten password</StyledTitle>
|
<StyledTitle data-loading>Forgotten password</StyledTitle>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={attempted}
|
condition={state === 'attempted'}
|
||||||
show={
|
show={
|
||||||
<Alert severity='success' data-loading>
|
<Alert severity='success' data-loading>
|
||||||
<AlertTitle>Attempted to send email</AlertTitle>
|
<AlertTitle>Attempted to send email</AlertTitle>
|
||||||
@ -91,6 +94,17 @@ const ForgottenPassword = () => {
|
|||||||
</Alert>
|
</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}>
|
<StyledForm onSubmit={onClick}>
|
||||||
<StyledTypography variant='body1' data-loading>
|
<StyledTypography variant='body1' data-loading>
|
||||||
Please provide your email address. If it exists in the
|
Please provide your email address. If it exists in the
|
||||||
@ -113,10 +127,10 @@ const ForgottenPassword = () => {
|
|||||||
type='submit'
|
type='submit'
|
||||||
data-loading
|
data-loading
|
||||||
color='primary'
|
color='primary'
|
||||||
disabled={loading}
|
disabled={state === 'loading'}
|
||||||
>
|
>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={!attempted}
|
condition={state === 'initial'}
|
||||||
show={<span>Submit</span>}
|
show={<span>Submit</span>}
|
||||||
elseShow={<span>Try again</span>}
|
elseShow={<span>Try again</span>}
|
||||||
/>
|
/>
|
||||||
@ -126,7 +140,7 @@ const ForgottenPassword = () => {
|
|||||||
type='submit'
|
type='submit'
|
||||||
data-loading
|
data-loading
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
disabled={loading}
|
disabled={state === 'loading'}
|
||||||
component={Link}
|
component={Link}
|
||||||
to='/login'
|
to='/login'
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
|
@ -176,6 +176,7 @@ exports[`should create default config 1`] = `
|
|||||||
"rateLimiting": {
|
"rateLimiting": {
|
||||||
"callIncomingWebhookMaxPerSecond": 1,
|
"callIncomingWebhookMaxPerSecond": 1,
|
||||||
"createUserMaxPerMinute": 20,
|
"createUserMaxPerMinute": 20,
|
||||||
|
"passwordResetMaxPerMinute": 1,
|
||||||
"simpleLoginMaxPerMinute": 10,
|
"simpleLoginMaxPerMinute": 10,
|
||||||
},
|
},
|
||||||
"secureHeaders": false,
|
"secureHeaders": false,
|
||||||
|
@ -142,6 +142,10 @@ function loadRateLimitingConfig(options: IUnleashOptions): IRateLimiting {
|
|||||||
process.env.SIMPLE_LOGIN_LIMIT_PER_MINUTE,
|
process.env.SIMPLE_LOGIN_LIMIT_PER_MINUTE,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
const passwordResetMaxPerMinute = parseEnvVarNumber(
|
||||||
|
process.env.PASSWORD_RESET_LIMIT_PER_MINUTE,
|
||||||
|
1,
|
||||||
|
);
|
||||||
const callIncomingWebhookMaxPerSecond = parseEnvVarNumber(
|
const callIncomingWebhookMaxPerSecond = parseEnvVarNumber(
|
||||||
process.env.INCOMING_WEBHOOK_RATE_LIMIT_PER_SECOND,
|
process.env.INCOMING_WEBHOOK_RATE_LIMIT_PER_SECOND,
|
||||||
1,
|
1,
|
||||||
@ -150,6 +154,7 @@ function loadRateLimitingConfig(options: IUnleashOptions): IRateLimiting {
|
|||||||
const defaultRateLimitOptions: IRateLimiting = {
|
const defaultRateLimitOptions: IRateLimiting = {
|
||||||
createUserMaxPerMinute,
|
createUserMaxPerMinute,
|
||||||
simpleLoginMaxPerMinute,
|
simpleLoginMaxPerMinute,
|
||||||
|
passwordResetMaxPerMinute,
|
||||||
callIncomingWebhookMaxPerSecond,
|
callIncomingWebhookMaxPerSecond,
|
||||||
};
|
};
|
||||||
return mergeAll([defaultRateLimitOptions, options.rateLimiting || {}]);
|
return mergeAll([defaultRateLimitOptions, options.rateLimiting || {}]);
|
||||||
|
@ -355,6 +355,12 @@ export default class MetricsMonitor {
|
|||||||
rateLimits
|
rateLimits
|
||||||
.labels({ endpoint: '/auth/simple', method: 'POST' })
|
.labels({ endpoint: '/auth/simple', method: 'POST' })
|
||||||
.set(config.rateLimiting.simpleLoginMaxPerMinute);
|
.set(config.rateLimiting.simpleLoginMaxPerMinute);
|
||||||
|
rateLimits
|
||||||
|
.labels({
|
||||||
|
endpoint: '/auth/reset/password-email',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.set(config.rateLimiting.passwordResetMaxPerMinute);
|
||||||
rateLimits
|
rateLimits
|
||||||
.labels({
|
.labels({
|
||||||
endpoint: '/api/incoming-webhook/:name',
|
endpoint: '/api/incoming-webhook/:name',
|
||||||
|
@ -17,6 +17,8 @@ import {
|
|||||||
emptyResponse,
|
emptyResponse,
|
||||||
getStandardResponses,
|
getStandardResponses,
|
||||||
} from '../../openapi/util/standard-responses';
|
} from '../../openapi/util/standard-responses';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { minutesToMilliseconds } from 'date-fns';
|
||||||
|
|
||||||
interface IValidateQuery {
|
interface IValidateQuery {
|
||||||
token: string;
|
token: string;
|
||||||
@ -129,6 +131,13 @@ class ResetPasswordController extends Controller {
|
|||||||
...getStandardResponses(401, 404, 415),
|
...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 {
|
export interface IRateLimiting {
|
||||||
createUserMaxPerMinute: number;
|
createUserMaxPerMinute: number;
|
||||||
simpleLoginMaxPerMinute: number;
|
simpleLoginMaxPerMinute: number;
|
||||||
|
passwordResetMaxPerMinute: number;
|
||||||
callIncomingWebhookMaxPerSecond: number;
|
callIncomingWebhookMaxPerSecond: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user