Skip to content

Commit d780f47

Browse files
authored
Add rate limiting for /password-reset endpoint (#80)
* Add rate limiting for /password-reset endpoint * 0.20.1-0 * check whether token matches, activate config in test * fix failing test by providing email * 0.20.1-1 * document the changes --------- Co-authored-by: Fynn Leitow
1 parent cc9bd94 commit d780f47

File tree

9 files changed

+81
-9
lines changed

9 files changed

+81
-9
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
## Change Log
22

3+
#### 0.20.X: Brute force protection
4+
5+
##### 0.20.1
6+
- :sparkles: if `security.passwordResetRateLimit` is set, password reset request are rate limited per username/email and the correct username/email must be included in the password reset requests
7+
- :bug: sporadic session creation errors are fixed
8+
9+
##### 0.20.0
10+
- :sparkles: if `security.loginRateLimit` is set, login requests are rate limited per username/email
311

412
#### 0.19.X: Token validation idempotency
513

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ It's easy to add custom fields to user documents. When added to a `profile` fiel
376376

377377
## Brute force protection
378378

379-
To enable brute force protection for the `/login` route you just need to add `loginRateLimit: {}` to `security` in your `config`. Adding just the empty object uses following defaults that can be overriden as needed:
379+
To enable brute force protection for the `/login` route you just need to add `loginRateLimit: {}` to `security` in your `config`. The same goes for the `/password-reset` route, where you just need to add `passwordResetRateLimit: {}` accordingly. Adding just the empty object uses following defaults that can be overriden as needed:
380380

381381
```ts
382382
const config {
@@ -406,6 +406,7 @@ couch-auth uses [express-slow-down](https://www.npmjs.com/package/express-slow-d
406406

407407
### Important notes:
408408
- You won't be able to override the keyGenerator option, as we use usernameField from the config.
409+
- When activating rate limiting for the `/password-reset` route, `username` field is required in the request body!
409410
- If you want to use Redis Store instead of Memory Store you currently need to use [rate-limit-redis@2x](https://github.com/wyattjoh/rate-limit-redis/tree/v2.1.0) for now [due to known issues](https://github.com/express-rate-limit/express-slow-down/issues/40#issuecomment-1548011953) with newer versions of rate-limit-redis.
410411

411412
## Advanced Configuration
@@ -481,6 +482,8 @@ forgot-password `token` and new password
481482
##### `POST /password-reset`
482483

483484
Resets the password. Required fields: `token`, `password`, and `confirmPassword`.
485+
If `security.passwordResetRateLimit` is set, `username` (or your configured
486+
username field) must be provided as for `/login`.
484487

485488
##### `POST /password-change`
486489

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@perfood/couch-auth",
3-
"version": "0.20.0",
3+
"version": "0.20.1-1",
44
"description": "Easy and secure authentication for CouchDB/Cloudant. Based on SuperLogin, updated and rewritten in Typescript.",
55
"main": "./lib/index.js",
66
"files": [

src/routes.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,50 @@ export default function (
232232
}
233233
);
234234

235-
if (!disabled.includes('password-reset'))
235+
if (!disabled.includes('password-reset')) {
236+
const speedLimiter = slowDown({
237+
windowMs:
238+
config.security.passwordResetRateLimit?.windowMs || 5 * 60 * 1000,
239+
delayAfter: config.security.passwordResetRateLimit?.delayAfter || 3,
240+
delayMs: config.security.passwordResetRateLimit
241+
? config.security.passwordResetRateLimit.delayMs || 500
242+
: 0,
243+
maxDelayMs: config.security.passwordResetRateLimit?.maxDelayMs || 10000,
244+
skipSuccessfulRequests:
245+
config.security.passwordResetRateLimit?.skipSuccessfulRequests || true,
246+
skipFailedRequests:
247+
config.security.passwordResetRateLimit?.skipFailedRequests || false,
248+
keyGenerator: function (req) {
249+
const usernameField = config.local.usernameField || 'username';
250+
251+
return req.body[usernameField];
252+
},
253+
onLimitReached:
254+
config.security.passwordResetRateLimit?.onLimitReached ||
255+
function () {},
256+
store: config.security.passwordResetRateLimit?.store || undefined,
257+
headers: config.security.passwordResetRateLimit?.headers || false
258+
});
259+
236260
router.post(
237261
'/password-reset',
262+
function (req: Request, res: Response, next: NextFunction) {
263+
if (!config.security.passwordResetRateLimit) {
264+
return next();
265+
}
266+
267+
const usernameField = config.local.usernameField || 'username';
268+
269+
if (!req.body[usernameField]) {
270+
return next({
271+
error: 'username required',
272+
status: 422
273+
});
274+
}
275+
276+
return next();
277+
},
278+
speedLimiter,
238279
function (req: Request, res: Response, next: NextFunction) {
239280
user.resetPassword(req.body, req).then(
240281
function (currentUser) {
@@ -264,6 +305,7 @@ export default function (
264305
);
265306
}
266307
);
308+
}
267309

268310
if (!disabled.includes('password-change'))
269311
router.post(

src/types/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export interface SecurityConfig {
9999
*/
100100
forwardErrors?: boolean;
101101
loginRateLimit?: ExpressSlowDownOptions;
102+
passwordResetRateLimit?: ExpressSlowDownOptions;
102103
}
103104

104105
export interface LengthConstraint {

src/user.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ import {
2828
} from './types/typings';
2929
import { DbManager } from './user/DbManager';
3030
import {
31-
arrayUnion,
3231
EMAIL_REGEXP,
32+
URLSafeUUID,
33+
USER_REGEXP,
34+
arrayUnion,
3335
extractCurrentConsents,
3436
getSessionKey,
3537
hashToken,
3638
hyphenizeUUID,
3739
removeHyphens,
38-
URLSafeUUID,
39-
USER_REGEXP,
4040
verifyConsentUpdate,
4141
verifySessionConfigRoles
4242
} from './util';
@@ -792,6 +792,20 @@ export class User {
792792
if (user.forgotPassword.expires < Date.now()) {
793793
return Promise.reject({ status: 400, error: 'Token expired' });
794794
}
795+
796+
if (this.config.security.passwordResetRateLimit) {
797+
const username = form[this.config.local.usernameField || 'username'];
798+
if (!username) {
799+
throw { status: 400, error: 'Invalid token' };
800+
}
801+
const slUser = await this.getUser(
802+
form[this.config.local.usernameField || 'username']
803+
);
804+
if (user._id !== slUser._id) {
805+
throw { status: 400, error: 'Invalid token' };
806+
}
807+
}
808+
795809
const hash = await this.hashPassword(form.password);
796810

797811
if (!user.local) {

test/test.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ export const config = {
2222
},
2323
security: {
2424
disabledRoutes: [],
25-
userActivityLogSize: 10
25+
userActivityLogSize: 10,
26+
passwordResetRateLimit: {
27+
delayMs: 500
28+
}
2629
},
2730
local: {
2831
sendConfirmEmail: true,

test/test.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ describe('SuperLogin', function () {
309309
.post(server + '/auth/password-reset')
310310
.send({
311311
token: resetToken,
312+
username: newUser.email,
312313
password: 'newpass1',
313314
confirmPassword: 'newpass1'
314315
})

0 commit comments

Comments
 (0)