diff --git a/config/default.toml b/config/default.toml index 4cd96fa3..46e68570 100644 --- a/config/default.toml +++ b/config/default.toml @@ -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" diff --git a/lib/autoreply.js b/lib/autoreply.js index 004b5cdc..b34da24f 100644 --- a/lib/autoreply.js +++ b/lib/autoreply.js @@ -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); } diff --git a/lib/consts.js b/lib/consts.js index 5fe3c513..8bcf1c06 100644 --- a/lib/consts.js +++ b/lib/consts.js @@ -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 }; diff --git a/lib/counters.js b/lib/counters.js index ed3dccd7..cc10fcc3 100644 --- a/lib/counters.js +++ b/lib/counters.js @@ -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); } diff --git a/lib/pop3-connection.js b/lib/pop3-connection.js index a5130c1b..25754ce1 100644 --- a/lib/pop3-connection.js +++ b/lib/pop3-connection.js @@ -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); } diff --git a/lib/user-handler.js b/lib/user-handler.js index ca262cc0..41504ac2 100644 --- a/lib/user-handler.js +++ b/lib/user-handler.js @@ -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) - ); }); } diff --git a/lmtp.js b/lmtp.js index 733d3bc1..1fdf9c43 100644 --- a/lmtp.js +++ b/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