Skip to content

Commit 3c12c8a

Browse files
authored
Merge pull request #836 from ExtensionEngine/improvement/max-login-attempts
Implement max login attempts
2 parents 6640a99 + a26755b commit 3c12c8a

File tree

4 files changed

+83
-4
lines changed

4 files changed

+83
-4
lines changed

client/components/auth/Login.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
import { mapActions } from 'vuex';
7777
7878
const LOGIN_ERR_MESSAGE = 'The email or password you entered is incorrect.';
79+
const TOO_MANY_REQ_CODE = 429;
80+
const TOO_MANY_REQ_ERR_MESSAGE = 'Too many login attempts. Please try again later.';
7981
const getOidcErrorMessage = (email, buttonLabel) =>
8082
`Account with email ${email} does not exist.
8183
Click "${buttonLabel}" to try with a different account.`;
@@ -104,7 +106,12 @@ export default {
104106
this.message = '';
105107
this.login({ email: this.email, password: this.password })
106108
.then(() => this.$router.push('/'))
107-
.catch(() => (this.localError = LOGIN_ERR_MESSAGE));
109+
.catch(err => {
110+
const code = err?.response?.status;
111+
this.localError = code === TOO_MANY_REQ_CODE
112+
? TOO_MANY_REQ_ERR_MESSAGE
113+
: LOGIN_ERR_MESSAGE;
114+
});
108115
}
109116
}
110117
};

server/shared/request/mw.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
11
'use strict';
22

33
const rateLimit = require('express-rate-limit');
4+
const Tapster = require('@extensionengine/tapster');
5+
const { provider, ...options } = require('../../../config/server').store;
46

57
const DEFAULT_WINDOW_MS = 15 * 60 * 1000; // every 15 minutes
68

7-
function requestLimiter({ max = 10, windowMs = DEFAULT_WINDOW_MS, ...opts } = {}) {
8-
return rateLimit({ max, windowMs, ...opts });
9+
// Store must be implemented using the following interface:
10+
// https://github.com/nfriedly/express-rate-limit/blob/master/README.md#store
11+
class Store {
12+
constructor() {
13+
this.cache = new Tapster({
14+
...options[provider],
15+
store: provider,
16+
namespace: 'request-limiter'
17+
});
18+
}
19+
20+
async incr(key, cb) {
21+
const initialState = { hits: 0 };
22+
const { hits, ...record } = await this.cache.has(key)
23+
? await this.cache.get(key)
24+
: initialState;
25+
await this.cache.set(key, { ...record, hits: hits + 1 });
26+
cb(null, hits);
27+
}
28+
29+
async decrement(key) {
30+
const { hits, ...record } = await this.cache.get(key) || {};
31+
if (!hits) return;
32+
return this.cache.set(key, { ...record, hits: hits - 1 });
33+
}
34+
35+
resetKey(key) {
36+
return this.cache.delete(key);
37+
}
38+
}
39+
40+
const defaultStore = new Store();
41+
42+
function requestLimiter({
43+
max = 10,
44+
windowMs = DEFAULT_WINDOW_MS,
45+
store = defaultStore,
46+
...opts
47+
} = {}) {
48+
return rateLimit({ max, windowMs, store, ...opts });
949
}
1050

1151
module.exports = { requestLimiter };

server/user/index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const { authenticate, logout } = require('../shared/auth');
4+
const { loginRequestLimiter, resetLoginAttempts, setLoginLimitKey } = require('./mw');
45
const { ACCEPTED } = require('http-status-codes');
56
const { authorize } = require('../shared/auth/mw');
67
const ctrl = require('./user.controller');
@@ -11,7 +12,14 @@ const { User } = require('../shared/database');
1112

1213
// Public routes:
1314
router
14-
.post('/login', authenticate('local', { setCookie: true }), ctrl.getProfile)
15+
.post(
16+
'/login',
17+
setLoginLimitKey,
18+
loginRequestLimiter,
19+
authenticate('local', { setCookie: true }),
20+
resetLoginAttempts,
21+
ctrl.getProfile
22+
)
1523
.post('/forgot-password', ctrl.forgotPassword)
1624
.use('/reset-password', requestLimiter(), authenticate('token'))
1725
.post('/reset-password', ctrl.resetPassword)

server/user/mw.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const crypto = require('crypto');
4+
const { requestLimiter } = require('../shared/request/mw');
5+
6+
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
7+
8+
const loginRequestLimiter = requestLimiter({
9+
windowMs: ONE_HOUR_IN_MS,
10+
keyGenerator: req => req.userKey
11+
});
12+
13+
function setLoginLimitKey(req, res, next) {
14+
const key = [req.ip, req.body.email].join(':');
15+
req.userKey = crypto.createHash('sha256').update(key).digest('base64');
16+
return next();
17+
}
18+
19+
function resetLoginAttempts(req, res, next) {
20+
return loginRequestLimiter.resetKey(req.userKey)
21+
.then(() => next());
22+
}
23+
24+
module.exports = { loginRequestLimiter, setLoginLimitKey, resetLoginAttempts };

0 commit comments

Comments
 (0)