mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-03-04 20:03:11 +08:00
allow rate limiting failed logins and totp checks
This commit is contained in:
parent
c22612c869
commit
77c24c04fc
7 changed files with 243 additions and 146 deletions
|
@ -53,10 +53,14 @@ maxForwards=2000
|
|||
#sender="zone-mta"
|
||||
|
||||
[totp]
|
||||
cipher="aes192"
|
||||
secret="a secret cat"
|
||||
# If enabled then encrypt TOTP seed tokens with the secret password. By default TOTP seeds
|
||||
# are not encrypted and stored as cleartext. Once set up do not change these values,
|
||||
# otherwise decrypting totp seeds is going to fail
|
||||
#cipher="aes192"
|
||||
#secret="a secret cat"
|
||||
|
||||
[attachments]
|
||||
# For now there's only a single option for attachment storage
|
||||
type="gridstore"
|
||||
bucket="attachments"
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ module.exports = (options, callback) => {
|
|||
}
|
||||
|
||||
// check limiting counters
|
||||
options.messageHandler.counters.ttlcounter('wda:' + options.user._id, 1, consts.MAX_AUTOREPLIES, (err, result) => {
|
||||
options.messageHandler.counters.ttlcounter('wda:' + options.user._id, 1, consts.MAX_AUTOREPLIES, false, (err, result) => {
|
||||
if (err || !result.success) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
|
|
@ -29,5 +29,15 @@ module.exports = {
|
|||
|
||||
MAX_AUTOREPLIES: 2000,
|
||||
|
||||
BCRYPT_ROUNDS: 12
|
||||
BCRYPT_ROUNDS: 12,
|
||||
|
||||
// how many authentication failures per user to allow before blocking until the end of the auth window
|
||||
AUTH_FAILURES: 5,
|
||||
// authentication window in seconds, starts counting from first invalid authentication
|
||||
AUTH_WINDOW: 60,
|
||||
|
||||
// how many TOTP failures per user to allow before blocking until the end of the auth window
|
||||
TOTP_FAILURES: 6,
|
||||
// TOTP authentication window in seconds, starts counting from first invalid authentication
|
||||
TOTP_WINDOW: 180
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ const ttlCounterScript = `
|
|||
local key = KEYS[1];
|
||||
local increment = tonumber(ARGV[1]) or 0;
|
||||
local limit = tonumber(ARGV[2]) or 0;
|
||||
local windowSize = tonumber(ARGV[3]) or 0;
|
||||
local current = tonumber(redis.call("GET", key)) or 0;
|
||||
|
||||
if current >= limit then
|
||||
|
@ -13,12 +14,21 @@ if current >= limit then
|
|||
return {0, current, ttl};
|
||||
end;
|
||||
|
||||
local updated = tonumber(redis.call("INCRBY", key, increment));
|
||||
if current == 0 then
|
||||
redis.call("EXPIRE", key, 86400);
|
||||
end;
|
||||
local updated;
|
||||
local ttl;
|
||||
|
||||
local ttl = tonumber(redis.call("TTL", key)) or 0;
|
||||
if increment > 0 then
|
||||
-- increment
|
||||
updated = tonumber(redis.call("INCRBY", key, increment));
|
||||
if current == 0 then
|
||||
redis.call("EXPIRE", key, windowSize);
|
||||
end;
|
||||
ttl = tonumber(redis.call("TTL", key)) or 0;
|
||||
else
|
||||
-- return current
|
||||
updated = current;
|
||||
ttl = tonumber(redis.call("TTL", key)) or windowSize;
|
||||
end;
|
||||
|
||||
return {1, updated, ttl};
|
||||
`;
|
||||
|
@ -43,12 +53,12 @@ module.exports = redis => {
|
|||
let scripty = new Scripty(redis);
|
||||
|
||||
return {
|
||||
ttlcounter(key, count, max, callback) {
|
||||
ttlcounter(key, count, max, windowSize, callback) {
|
||||
scripty.loadScript('ttlcounter', ttlCounterScript, (err, script) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
script.run(1, key, count, max, (err, res) => {
|
||||
script.run(1, key, count, max, windowSize || 86400, (err, res) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
|
|
@ -337,6 +337,10 @@ class POP3Connection extends EventEmitter {
|
|||
'USER',
|
||||
err.message
|
||||
);
|
||||
if (err.response === 'NO') {
|
||||
this.send('-ERR [AUTH] ' + err.message);
|
||||
return next();
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
|
||||
|
@ -783,6 +787,12 @@ class POP3Connection extends EventEmitter {
|
|||
'PLAIN',
|
||||
err.message
|
||||
);
|
||||
|
||||
if (err.response === 'NO') {
|
||||
this.send('-ERR [AUTH] ' + err.message);
|
||||
return next();
|
||||
}
|
||||
|
||||
return next(err);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ const speakeasy = require('speakeasy');
|
|||
const QRCode = require('qrcode');
|
||||
const tools = require('./tools');
|
||||
const consts = require('./consts');
|
||||
const counters = require('./counters');
|
||||
const ObjectID = require('mongodb').ObjectID;
|
||||
const generatePassword = require('generate-password');
|
||||
const os = require('os');
|
||||
|
@ -22,6 +23,7 @@ class UserHandler {
|
|||
this.users = options.users || options.database;
|
||||
this.redis = options.redis;
|
||||
this.messageHandler = options.messageHandler;
|
||||
this.counters = this.messageHandler ? this.messageHandler.counters : counters(this.redis);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -70,7 +72,7 @@ class UserHandler {
|
|||
return this.logAuthEvent(null, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
return next(null, {
|
||||
next(null, {
|
||||
_id: addressData.user
|
||||
});
|
||||
});
|
||||
|
@ -111,6 +113,30 @@ class UserHandler {
|
|||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
}
|
||||
|
||||
let rlkey = 'auth:' + userData._id.toString();
|
||||
this.counters.ttlcounter(rlkey, 0, consts.AUTH_FAILURES, consts.AUTH_WINDOW, (err, res) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!res.success) {
|
||||
let err = new Error('Authentication was rate limited. Check again in ' + res.ttl + ' seconds');
|
||||
err.response = 'NO';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let authSuccess = (...args) => {
|
||||
// clear rate limit counter on success
|
||||
this.redis.del(rlkey, () => false);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
let authFail = (...args) => {
|
||||
// increment rate limit counter on failure
|
||||
this.counters.ttlcounter(rlkey, 1, consts.AUTH_FAILURES, consts.AUTH_WINDOW, () => {
|
||||
callback(...args);
|
||||
});
|
||||
};
|
||||
|
||||
// try master password
|
||||
bcrypt.compare(password, userData.password || '', (err, success) => {
|
||||
if (err) {
|
||||
|
@ -123,7 +149,7 @@ class UserHandler {
|
|||
meta.require2fa = true;
|
||||
}
|
||||
return this.logAuthEvent(userData._id, meta, () =>
|
||||
callback(null, {
|
||||
authSuccess(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: 'master',
|
||||
|
@ -137,7 +163,7 @@ class UserHandler {
|
|||
// only master password can be used for management tasks
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
// try application specific passwords
|
||||
|
@ -147,7 +173,7 @@ class UserHandler {
|
|||
// does not look like an application specific password
|
||||
meta.result = 'fail';
|
||||
meta.source = 'master';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
let prefix = crypto.createHash('md5').update(password.substr(0, 4)).digest('hex');
|
||||
|
@ -166,7 +192,7 @@ class UserHandler {
|
|||
// user does not have app specific passwords set
|
||||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
let pos = 0;
|
||||
|
@ -174,7 +200,7 @@ class UserHandler {
|
|||
if (pos >= asps.length) {
|
||||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
|
||||
}
|
||||
|
||||
let asp = asps[pos++];
|
||||
|
@ -196,22 +222,23 @@ class UserHandler {
|
|||
meta.result = 'fail';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id.toString();
|
||||
return this.logAuthEvent(userData._id, meta, () => callback(new Error('Authentication failed. Invalid scope')));
|
||||
return this.logAuthEvent(userData._id, meta, () => authFail(new Error('Authentication failed. Invalid scope')));
|
||||
}
|
||||
|
||||
meta.result = 'success';
|
||||
meta.source = 'asp';
|
||||
meta.asp = asp._id.toString();
|
||||
return this.logAuthEvent(userData._id, meta, () =>
|
||||
callback(null, {
|
||||
return this.logAuthEvent(userData._id, meta, () => {
|
||||
this.redis.del(rlkey, () => false);
|
||||
authSuccess(null, {
|
||||
user: userData._id,
|
||||
username: userData.username,
|
||||
scope: requiredScope,
|
||||
asp: asp._id.toString(),
|
||||
require2fa: false, // application scope never requires 2FA
|
||||
requirePasswordChange: !!userData.requirePasswordChange // true, if password was reset
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -220,6 +247,7 @@ class UserHandler {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
generateASP(user, data, callback) {
|
||||
|
@ -623,8 +651,8 @@ class UserHandler {
|
|||
if (!data.fresh && userData.seed) {
|
||||
if (userData.seed) {
|
||||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$') {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher, config.totp.secret);
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
}
|
||||
|
@ -650,9 +678,12 @@ class UserHandler {
|
|||
name: userData.username
|
||||
});
|
||||
|
||||
let cipher = crypto.createCipher(config.totp.cipher, config.totp.secret);
|
||||
let seed = '$' + cipher.update(secret.base32, 'utf8', 'hex');
|
||||
let seed = secret.base32;
|
||||
if (config.totp && config.totp.secret) {
|
||||
let cipher = crypto.createCipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
seed = '$' + cipher.update(seed, 'utf8', 'hex');
|
||||
seed += cipher.final('hex');
|
||||
}
|
||||
|
||||
return this.users.collection('users').findOneAndUpdate({
|
||||
_id: user,
|
||||
|
@ -727,8 +758,8 @@ class UserHandler {
|
|||
}
|
||||
|
||||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$') {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher, config.totp.secret);
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
}
|
||||
|
@ -814,6 +845,30 @@ class UserHandler {
|
|||
}
|
||||
|
||||
check2fa(user, data, callback) {
|
||||
let rlkey = 'totp:' + user.toString();
|
||||
this.counters.ttlcounter(rlkey, 0, consts.AUTH_FAILURES, consts.AUTH_WINDOW * 3, (err, res) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
if (!res.success) {
|
||||
let err = new Error('Authentication was rate limited. Check again in ' + res.ttl + ' seconds');
|
||||
err.response = 'NO';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let authSuccess = (...args) => {
|
||||
// clear rate limit counter on success
|
||||
this.redis.del(rlkey, () => false);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
let authFail = (...args) => {
|
||||
// increment rate limit counter on failure
|
||||
this.counters.ttlcounter(rlkey, 1, consts.TOTP_FAILURES, consts.TOTP_WINDOW, () => {
|
||||
callback(...args);
|
||||
});
|
||||
};
|
||||
|
||||
this.users.collection('users').findOne({
|
||||
_id: user
|
||||
}, {
|
||||
|
@ -839,8 +894,8 @@ class UserHandler {
|
|||
}
|
||||
|
||||
let secret = userData.seed;
|
||||
if (userData.seed.charAt(0) === '$') {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher, config.totp.secret);
|
||||
if (userData.seed.charAt(0) === '$' && config.totp && config.totp.secret) {
|
||||
let decipher = crypto.createDecipher(config.totp.cipher || 'aes192', config.totp.secret);
|
||||
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
|
||||
secret += decipher.final('utf8');
|
||||
}
|
||||
|
@ -859,9 +914,16 @@ class UserHandler {
|
|||
ip: data.ip,
|
||||
result: verified ? 'success' : 'fail'
|
||||
},
|
||||
() => callback(null, verified)
|
||||
() => {
|
||||
if (verified) {
|
||||
authSuccess(null, verified);
|
||||
} else {
|
||||
authFail(null, verified);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
update(user, data, callback) {
|
||||
|
|
1
lmtp.js
1
lmtp.js
|
@ -243,6 +243,7 @@ const serverOptions = {
|
|||
'wdf:' + user._id.toString(),
|
||||
forwardTargets.size + forwardTargetUrls.size,
|
||||
user.forwards,
|
||||
false,
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
// failed checks
|
||||
|
|
Loading…
Reference in a new issue