allow rate limiting failed logins and totp checks

This commit is contained in:
Andris Reinman 2017-08-07 16:19:38 +03:00
parent c22612c869
commit 77c24c04fc
7 changed files with 243 additions and 146 deletions

View file

@ -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"

View file

@ -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);
}

View file

@ -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
};

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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,112 +113,138 @@ class UserHandler {
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
}
// try master password
bcrypt.compare(password, userData.password || '', (err, success) => {
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 (success) {
meta.result = 'success';
meta.source = 'master';
if (userData.enabled2fa) {
meta.require2fa = true;
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) {
return callback(err);
}
return this.logAuthEvent(userData._id, meta, () =>
callback(null, {
user: userData._id,
username: userData.username,
scope: 'master',
// if 2FA is enabled then require token validation
require2fa: !!userData.enabled2fa
if (success) {
meta.result = 'success';
meta.source = 'master';
if (userData.enabled2fa) {
meta.require2fa = true;
}
return this.logAuthEvent(userData._id, meta, () =>
authSuccess(null, {
user: userData._id,
username: userData.username,
scope: 'master',
// if 2FA is enabled then require token validation
require2fa: !!userData.enabled2fa
})
);
}
if (requiredScope === 'master') {
// only master password can be used for management tasks
meta.result = 'fail';
meta.source = 'master';
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
}
// try application specific passwords
password = password.replace(/\s+/g, '').toLowerCase();
if (!/^[a-z]{16}$/.test(password)) {
// does not look like an application specific password
meta.result = 'fail';
meta.source = 'master';
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
}
let prefix = crypto.createHash('md5').update(password.substr(0, 4)).digest('hex');
this.users
.collection('asps')
.find({
user: userData._id
})
);
}
.toArray((err, asps) => {
if (err) {
return callback(err);
}
if (requiredScope === 'master') {
// only master password can be used for management tasks
meta.result = 'fail';
meta.source = 'master';
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
}
// try application specific passwords
password = password.replace(/\s+/g, '').toLowerCase();
if (!/^[a-z]{16}$/.test(password)) {
// does not look like an application specific password
meta.result = 'fail';
meta.source = 'master';
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
}
let prefix = crypto.createHash('md5').update(password.substr(0, 4)).digest('hex');
this.users
.collection('asps')
.find({
user: userData._id
})
.toArray((err, asps) => {
if (err) {
return callback(err);
}
if (!asps || !asps.length) {
// user does not have app specific passwords set
meta.result = 'fail';
meta.source = 'asp';
return this.logAuthEvent(userData._id, meta, () => callback(null, false));
}
let pos = 0;
let checkNext = () => {
if (pos >= asps.length) {
if (!asps || !asps.length) {
// 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 asp = asps[pos++];
if (asp.prefix && asp.prefix !== prefix) {
// no need to check, definitely a wrong one
return setImmediate(checkNext);
}
bcrypt.compare(password, asp.password || '', (err, success) => {
if (err) {
return callback(err);
let pos = 0;
let checkNext = () => {
if (pos >= asps.length) {
meta.result = 'fail';
meta.source = 'asp';
return this.logAuthEvent(userData._id, meta, () => authFail(null, false));
}
if (!success) {
let asp = asps[pos++];
if (asp.prefix && asp.prefix !== prefix) {
// no need to check, definitely a wrong one
return setImmediate(checkNext);
}
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
meta.result = 'fail';
bcrypt.compare(password, asp.password || '', (err, success) => {
if (err) {
return callback(err);
}
if (!success) {
return setImmediate(checkNext);
}
if (!asp.scopes.includes('*') && !asp.scopes.includes(requiredScope)) {
meta.result = 'fail';
meta.source = 'asp';
meta.asp = asp._id.toString();
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(new Error('Authentication failed. Invalid scope')));
}
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
});
});
});
};
meta.result = 'success';
meta.source = 'asp';
meta.asp = asp._id.toString();
return this.logAuthEvent(userData._id, meta, () =>
callback(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
})
);
});
};
checkNext();
});
checkNext();
});
});
});
});
});
@ -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');
seed += cipher.final('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,53 +845,84 @@ class UserHandler {
}
check2fa(user, data, callback) {
this.users.collection('users').findOne({
_id: user
}, {
fields: {
username: true,
enabled2fa: true,
seed: true
}
}, (err, userData) => {
let rlkey = 'totp:' + user.toString();
this.counters.ttlcounter(rlkey, 0, consts.AUTH_FAILURES, consts.AUTH_WINDOW * 3, (err, res) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
return callback(new Error('Database Error, failed to find user'));
return callback(err);
}
if (!userData) {
let err = new Error('This user does not exist');
if (!res.success) {
let err = new Error('Authentication was rate limited. Check again in ' + res.ttl + ' seconds');
err.response = 'NO';
return callback(err);
}
if (!userData.seed || !userData.enabled2fa) {
// 2fa not set up
let err = new Error('2FA is not enabled for this user');
return callback(err);
}
let authSuccess = (...args) => {
// clear rate limit counter on success
this.redis.del(rlkey, () => false);
callback(...args);
};
let secret = userData.seed;
if (userData.seed.charAt(0) === '$') {
let decipher = crypto.createDecipher(config.totp.cipher, config.totp.secret);
secret = decipher.update(userData.seed.substr(1), 'hex', 'utf-8');
secret += decipher.final('utf8');
}
let authFail = (...args) => {
// increment rate limit counter on failure
this.counters.ttlcounter(rlkey, 1, consts.TOTP_FAILURES, consts.TOTP_WINDOW, () => {
callback(...args);
});
};
let verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token: data.token,
window: 6
this.users.collection('users').findOne({
_id: user
}, {
fields: {
username: true,
enabled2fa: true,
seed: true
}
}, (err, userData) => {
if (err) {
log.error('DB', 'LOADFAIL id=%s error=%s', user, err.message);
return callback(new Error('Database Error, failed to find user'));
}
if (!userData) {
let err = new Error('This user does not exist');
return callback(err);
}
if (!userData.seed || !userData.enabled2fa) {
// 2fa not set up
let err = new Error('2FA is not enabled for this user');
return callback(err);
}
let secret = userData.seed;
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');
}
let verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token: data.token,
window: 6
});
return this.logAuthEvent(
user,
{
action: '2fa',
ip: data.ip,
result: verified ? 'success' : 'fail'
},
() => {
if (verified) {
authSuccess(null, verified);
} else {
authFail(null, verified);
}
}
);
});
return this.logAuthEvent(
user,
{
action: '2fa',
ip: data.ip,
result: verified ? 'success' : 'fail'
},
() => callback(null, verified)
);
});
}

View file

@ -243,6 +243,7 @@ const serverOptions = {
'wdf:' + user._id.toString(),
forwardTargets.size + forwardTargetUrls.size,
user.forwards,
false,
(err, result) => {
if (err) {
// failed checks