diff --git a/meshcentral-config-schema.json b/meshcentral-config-schema.json index 9b85a48f..1d19bd82 100644 --- a/meshcentral-config-schema.json +++ b/meshcentral-config-schema.json @@ -240,6 +240,17 @@ "coolofftime": { "type": "integer", "default": null, "description": "Additional time in minute that login attempts will be denied once the invalid login limit is reached." } } }, + "maxInvalid2fa": { + "type": "object", + "additionalProperties": false, + "description": "This section described a policy for how many times an IP address is allowed to attempt to perform two-factor authenticaiton (2FA) incorrectly. By default it's 10 times in 10 minutes, but this can be changed here.", + "properties": { + "exclude": { "type": "string", "default": null, "description": "Ranges of IP addresses that are not subject to invalid 2FA limitations. For example: 192.168.1.0/24,172.16.0.1"}, + "time": { "type": "integer", "default": 10, "description": "Time in minutes over which the a maximum number of invalid 2FA attempts is allowed from an IP address." }, + "count": { "type": "integer", "default": 10, "description": "Maximum number of invalid 2FA attempts from an IP address in the time period." }, + "coolofftime": { "type": "integer", "default": null, "description": "Additional time in minute that 2FA attempts will be denied once the invalid 2FA limit is reached." } + } + }, "amtProvisioningServer": { "type": "object", "additionalProperties": false, diff --git a/meshuser.js b/meshuser.js index fabec9bb..7d8f655d 100644 --- a/meshuser.js +++ b/meshuser.js @@ -5370,7 +5370,8 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use 'args': [serverUserCommandArgs, ""], 'autobackup': [serverUserCommandAutoBackup, ""], 'backupconfig': [serverUserCommandBackupConfig, ""], - 'badlogins': [serverUserCommandBadLogins, ""], + 'badlogins': [serverUserCommandBadLogins, "Displays or resets the invalid login rate limiting table."], + 'bad2fa': [serverUserCommandBad2fa, "Displays or resets the invalid 2FA rate limiting table."], 'certexpire': [serverUserCommandCertExpire, ""], 'certhashes': [serverUserCommandCertHashes, ""], 'closeusersessions': [serverUserCommandCloseUserSessions, "Disconnects all sessions for a specified user."], @@ -6367,6 +6368,43 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use } } + function serverUserCommandBad2fa(cmdData) { + if (parent.parent.config.settings.maxinvalid2fa == false) { + cmdData.result = 'Bad 2FA filter is disabled.'; + } else { + if (cmdData.cmdargs['_'] == 'reset') { + // Reset bad login table + parent.bad2faTable = {}; + parent.bad2faTableLastClean = 0; + cmdData.result = 'Done.'; + } else if (cmdData.cmdargs['_'] == '') { + // Show current bad login table + if (typeof parent.parent.config.settings.maxinvalid2fa.coolofftime == 'number') { + cmdData.result = "Max is " + parent.parent.config.settings.maxinvalid2fa.count + " bad 2FA(s) in " + parent.parent.config.settings.maxinvalid2fa.time + " minute(s), " + parent.parent.config.settings.maxinvalid2fa.coolofftime + " minute(s) cooloff.\r\n"; + } else { + cmdData.result = "Max is " + parent.parent.config.settings.maxinvalid2fa.count + " bad 2FA(s) in " + parent.parent.config.settings.maxinvalid2fa.time + " minute(s).\r\n"; + } + var bad2faCount = 0; + parent.cleanBad2faTable(); + for (var i in parent.bad2faTable) { + bad2faCount++; + if (typeof parent.bad2faTable[i] == 'number') { + cmdData.result += "Cooloff for " + Math.floor((parent.bad2faTable[i] - Date.now()) / 60000) + " minute(s)\r\n"; + } else { + if (parent.bad2faTable[i].length > 1) { + cmdData.result += (i + ' - ' + parent.bad2faTable[i].length + " records\r\n"); + } else { + cmdData.result += (i + ' - ' + parent.bad2faTable[i].length + " record\r\n"); + } + } + } + if (bad2faCount == 0) { cmdData.result += 'No bad 2FA.'; } + } else { + cmdData.result = 'Usage: bad2fa [reset]'; + } + } + } + function serverUserCommandDispatchTable(cmdData) { for (var i in parent.parent.eventsDispatch) { cmdData.result += (i + ', ' + parent.parent.eventsDispatch[i].length + '\r\n'); diff --git a/sample-config-advanced.json b/sample-config-advanced.json index 3b958922..8903d994 100644 --- a/sample-config-advanced.json +++ b/sample-config-advanced.json @@ -113,6 +113,12 @@ "count": 10, "coolofftime": 10 }, + "__maxInvalid2fa": "Time in minutes, max amount of bad two-factor authentication from a source IP in the time before 2FA's are rejected.", + "_maxInvalid2fa": { + "time": 10, + "count": 10, + "coolofftime": 10 + }, "watchDog": { "interval": 100, "timeout": 400 }, "_AmtProvisioningServer": { "port": 9971, diff --git a/webserver.js b/webserver.js index 79a460ec..3bf68ebf 100644 --- a/webserver.js +++ b/webserver.js @@ -1097,6 +1097,16 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF if (result == false) { var randomWaitTime = 0; + // Check if 2FA is allowed for this IP address + if (obj.checkAllow2Fa(req) == false) { + // Wait and redirect the user + setTimeout(function () { + req.session.messageid = 114; // IP address blocked, try again later. + if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); } + }, 2000 + (obj.crypto.randomBytes(2).readUInt16BE(0) % 4095)); + return; + } + // 2-step auth is required, but the token is not present or not valid. if ((req.body.token != null) || (req.body.hwtoken != null)) { randomWaitTime = 2000 + (obj.crypto.randomBytes(2).readUInt16BE(0) % 4095); // This is a fail, wait a random time. 2 to 6 seconds. @@ -1105,7 +1115,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF parent.debug('web', 'handleLoginRequest: invalid 2FA token'); const ua = getUserAgentInfo(req); obj.parent.DispatchEvent(['*', 'server-users', user._id], obj, { action: 'authfail', username: user.name, userid: user._id, domain: domain.id, msg: 'User login attempt with incorrect 2nd factor from ' + req.clientIp, msgid: 108, msgArgs: [req.clientIp, ua.browserStr, ua.osStr] }); - obj.setbadLogin(req); + obj.setbad2Fa(req); } else { parent.debug('web', 'handleLoginRequest: 2FA token required'); } @@ -1599,6 +1609,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF checkUserOneTimePassword(req, domain, user, req.body.token, req.body.hwtoken, function (result) { if (result == false) { if (i == 0) { + + // Check if 2FA is allowed for this IP address + if (obj.checkAllow2Fa(req) == false) { + // Wait and redirect the user + setTimeout(function () { + req.session.messageid = 114; // IP address blocked, try again later. + if (direct === true) { handleRootRequestEx(req, res, domain); } else { res.redirect(domain.url + getQueryPortion(req)); } + }, 2000 + (obj.crypto.randomBytes(2).readUInt16BE(0) % 4095)); + return; + } + // 2-step auth is required, but the token is not present or not valid. parent.debug('web', 'handleResetAccountRequest: Invalid 2FA token, try again'); if ((req.body.token != null) || (req.body.hwtoken != null)) { @@ -1614,7 +1635,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF req.session.messageid = 108; // Invalid token, try again. const ua = getUserAgentInfo(req); obj.parent.DispatchEvent(['*', 'server-users', user._id], obj, { action: 'authfail', username: user.name, userid: user._id, domain: domain.id, msg: 'User login attempt with incorrect 2nd factor from ' + req.clientIp, msgid: 108, msgArgs: [req.clientIp, ua.browserStr, ua.osStr] }); - obj.setbadLogin(req); + obj.setbad2Fa(req); } } req.session.loginmode = 5; @@ -7828,6 +7849,64 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF obj.badLoginTableLastClean = 0; } + // This is the invalid 2FA throttling code + obj.bad2faTable = {}; + obj.bad2faTableLastClean = 0; + if (parent.config.settings == null) { parent.config.settings = {}; } + if (parent.config.settings.maxinvalid2fa !== false) { + if (typeof parent.config.settings.maxinvalid2fa != 'object') { parent.config.settings.maxinvalid2fa = { time: 10, count: 10 }; } + if (typeof parent.config.settings.maxinvalid2fa.time != 'number') { parent.config.settings.maxinvalid2fa.time = 10; } + if (typeof parent.config.settings.maxinvalid2fa.count != 'number') { parent.config.settings.maxinvalid2fa.count = 10; } + if ((typeof parent.config.settings.maxinvalid2fa.coolofftime != 'number') || (parent.config.settings.maxinvalid2fa.coolofftime < 1)) { parent.config.settings.maxinvalid2fa.coolofftime = null; } + } + obj.setbad2Fa = function (ip) { // Set an IP address that just did a bad 2FA request + if (parent.config.settings.maxinvalid2fa === false) return; + if (typeof ip == 'object') { ip = ip.clientIp; } + if (parent.config.settings.maxinvalid2fa != null) { + if (typeof parent.config.settings.maxinvalid2fa.exclude == 'string') { + const excludeSplit = parent.config.settings.maxinvalid2fa.exclude.split(','); + for (var i in excludeSplit) { if (require('ipcheck').match(ip, excludeSplit[i])) return; } + } else if (Array.isArray(parent.config.settings.maxinvalid2fa.exclude)) { + for (var i in parent.config.settings.maxinvalid2fa.exclude) { if (require('ipcheck').match(ip, parent.config.settings.maxinvalid2fa.exclude[i])) return; } + } + } + var splitip = ip.split('.'); + if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } + if (++obj.bad2faTableLastClean > 100) { obj.cleanBad2faTable(); } + if (typeof obj.bad2faTable[ip] == 'number') { if (obj.bad2faTable[ip] < Date.now()) { delete obj.bad2faTable[ip]; } else { return; } } // Check cooloff period + if (obj.bad2faTable[ip] == null) { obj.bad2faTable[ip] = [Date.now()]; } else { obj.bad2faTable[ip].push(Date.now()); } + if ((obj.bad2faTable[ip].length >= parent.config.settings.maxinvalid2fa.count) && (parent.config.settings.maxinvalid2fa.coolofftime != null)) { + obj.bad2faTable[ip] = Date.now() + (parent.config.settings.maxinvalid2fa.coolofftime * 60000); // Move to cooloff period + } + } + obj.checkAllow2Fa = function (ip) { // Check if an IP address is allowed to perform 2FA + if (parent.config.settings.maxinvalid2fa === false) return true; + if (typeof ip == 'object') { ip = ip.clientIp; } + var splitip = ip.split('.'); + if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first + var cutoffTime = Date.now() - (parent.config.settings.maxinvalid2fa.time * 60000); // Time in minutes + var ipTable = obj.bad2faTable[ip]; + if (ipTable == null) return true; + if (typeof ipTable == 'number') { if (obj.bad2faTable[ip] < Date.now()) { delete obj.bad2faTable[ip]; } else { return false; } } // Check cooloff period + while ((ipTable.length > 0) && (ipTable[0] < cutoffTime)) { ipTable.shift(); } + if (ipTable.length == 0) { delete obj.bad2faTable[ip]; return true; } + return (ipTable.length < parent.config.settings.maxinvalid2fa.count); // No more than x bad 2FAs in x minutes + } + obj.cleanBad2faTable = function () { // Clean up the IP address 2FA blockage table, we do this occasionaly. + if (parent.config.settings.maxinvalid2fa === false) return; + var cutoffTime = Date.now() - (parent.config.settings.maxinvalid2fa.time * 60000); // Time in minutes + for (var ip in obj.bad2faTable) { + var ipTable = obj.bad2faTable[ip]; + if (typeof ipTable == 'number') { + if (obj.bad2faTable[ip] < Date.now()) { delete obj.bad2faTable[ip]; } // Check cooloff period + } else { + while ((ipTable.length > 0) && (ipTable[0] < cutoffTime)) { ipTable.shift(); } + if (ipTable.length == 0) { delete obj.bad2faTable[ip]; } + } + } + obj.bad2faTableLastClean = 0; + } + // Hold a websocket until additional arguments are provided within the socket. // This is a generic function that can be used for any websocket to avoid passing arguments in the URL. function getWebsocketArgs(ws, req, func) {