mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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