diff --git a/TalkerNode.js b/TalkerNode.js index 5d00822..0762f92 100644 --- a/TalkerNode.js +++ b/TalkerNode.js @@ -99,6 +99,47 @@ function echo(bool) { return new Buffer.from(bytes); } +/* +* Generates 2FA secret keys and backup codes +*/ +function auth2faGenerateSecretKey() { + var OTPAuth = require('otpauth'); + return (new OTPAuth.Secret().b32); +} + +/* +* Validates 2FA OTP code +*/ +function auth2faValidateOTP(username,userToken) { + var userRecord = usersdb.get(username).value(); + userToken = userToken.replace(/-/g,'') // remove possible dashes from backup code + if(userToken === userRecord.auth2fa_backupCode) { + // user is using a backup code -> burn code and validate ok + auth2faBurnBackupCode(username); + return true; + } else { + // check provided OTP code is valid + const OTPAuth = require('otpauth'); + var secretKey = userRecord.auth2fa_secretKey; + var TOTP = new OTPAuth.TOTP({ secret: secretKey }); + + var authDelta = TOTP.validate({ + token: userToken, + window: 3 + }); + return !(authDelta === null); + } +} + +/* +* Burn 2FA backup code +*/ +function auth2faBurnBackupCode(username) { + var userRecord = usersdb.get(username.toLowerCase().charAt(0).toUpperCase() + username.toLowerCase().slice(1)).value(); + userRecord.auth2fa_backupCode = '!' + userRecord.auth2fa_backupCode; + usersdb.set(username,userRecord).write(); +} + /* * Method used to send data to a socket */ @@ -260,12 +301,33 @@ function receiveData(socket, data) { return; } } - } else if (socket.db.password !== crypto.createHash('sha512').update(cleanData).digest('hex')) { + } else if (typeof socket.auth2faOK === 'undefined' && socket.db.password !== crypto.createHash('sha512').update(cleanData).digest('hex')) { require("fs").appendFileSync('auth.log', new Date().toISOString() + " " + socket.remoteAddress + " with port " + socket.remotePort + " failed to log in, username attempted was: " + socket.username + "\r\n"); delete socket.username; delete socket.db; sendData(socket, chalk.red("\r\nWrong password! ") + "\r\nLet's start from the beggining...\r\n" + chalk.cyan("Tell me your name: ")); return; + } else { + // password authentication sucessfull + if(socket.db.auth2fa_status && typeof socket.auth2faOK === 'undefined') { + // 2FA challenge + sendData(socket, chalk.cyan("\r\nGive me your 2FA token or backup code: ")); + sendData(socket, echo(false)); + socket.auth2faOK = false; + return; + } else if(socket.db.auth2fa_status && !socket.auth2faOK) { + // 2FA verification + if(!auth2faValidateOTP(socket.username,cleanData)) { + require("fs").appendFileSync('auth.log', new Date().toISOString() + " " + socket.remoteAddress + " with port " + socket.remotePort + " failed to log in with a valid 2fa token, username attempted was: " + socket.username + "\r\n"); + delete socket.username; + delete socket.auth2faOK; + delete socket.db; + sendData(socket, chalk.red("\r\nInvalid token! ") + "\r\nLet's start from the beggining...\r\n" + chalk.cyan("Tell me your name: ")); + return; + } else { + socket.auth2faOK = true; + } + } } // entering the talker... @@ -461,36 +523,42 @@ function setCmdRank(command, rank) { */ function findCommand(socket, command) { var c = command; - var userRank = socket.db.rank; + var userRank = socket.db.rank; + // hierarchy: direct match > weighted partial match > alias direct match if(commands[c] && userRank >= getCmdRank(c)) { - return [commands[c]]; + // direct match + return [commands[c]]; } else { - // when we have more than one possible command, we - // choose the most heavier from the ones with lower - // getCmdRank - var results = []; - var weigth = 0; - var rank = ranks.list.length - 1; - for (var cmd in commands) { - if(cmd.substr(0, c.length) == c && userRank >= getCmdRank(cmd)) { - var cweigth = 0; - if (typeof commands[cmd].weigth !== 'undefined') - cweigth = commands[cmd].weigth; - if (getCmdRank(cmd) < rank) { - rank = getCmdRank(cmd); - weigth = cweigth; - results = [commands[cmd]]; - } else if (getCmdRank(cmd) === rank) { - if (cweigth > weigth) { - weigth = commands[cmd].weigth; - results = [commands[cmd]]; - } else if (cweigth === weigth) { - results.push(commands[cmd]); - } + // when we have more than one possible command, we + // choose the most heavier from the ones with lower + // getCmdRank + var results = []; + var weigth = 0; + var rank = ranks.list.length - 1; + for (var cmd in commands) { + if(cmd.substr(0, c.length) == c && userRank >= getCmdRank(cmd)) { + // partial match + var cweigth = 0; + if (typeof commands[cmd].weigth !== 'undefined') + cweigth = commands[cmd].weigth; + if (getCmdRank(cmd) < rank) { + rank = getCmdRank(cmd); + weigth = cweigth; + results = [commands[cmd]]; + } else if (getCmdRank(cmd) === rank) { + if (cweigth > weigth) { + weigth = commands[cmd].weigth; + results = [commands[cmd]]; + } else if (cweigth === weigth) { + results.push(commands[cmd]); + } + } + } else if (commands[cmd].alias != 'undefined' && commands[cmd] != "" && commands[cmd].alias == c) { + // alias direct match + return [commands[cmd]] + } } - } - } - return results; + return results; } } @@ -590,7 +658,10 @@ function command_utility() { sockets: sockets, commands: commands, ranks: ranks, - echo: echo, + echo: echo, + auth2faGenerateSecretKey: auth2faGenerateSecretKey, + auth2faValidateOTP: auth2faValidateOTP, + auth2faBurnBackupCode: auth2faBurnBackupCode, loadCommands: loadCommands, getCmdRank: getCmdRank, setCmdRank: setCmdRank, @@ -662,39 +733,39 @@ function command_utility() { return usersdb.get(name).value(); }, - // returns the username of an "aproximate" user - // read 'getAproxOnlineUser' to understand the difference between - // 'getOnlineUser' and it, same happens here between 'getUser' and - // 'getAproxUser'. - getAproxUser: function getAproxUser(name) { - if (this.getUser(name) !== undefined) return [name]; - var possibilities = []; - for (var key in usersdb.getState()) { - if (name.toLowerCase() === key.toLowerCase().substr(0,name.length) && (name.length < key.length)) { - possibilities.push(key); - } - } - if (possibilities.length === 0) return []; - return possibilities; - }, - - // updates a user in the database - // TODO: argh, we surely don't want this! harden it! - updateUser: function updateUser(username, userObj) { - username = username.toLowerCase().charAt(0).toUpperCase() + username.toLowerCase().slice(1); - usersdb.set(username,userObj).write(); - }, - - // get users list, only insensitive information - getUsersList: function getUsersList() { - var list = []; - for (var key in usersdb.getState()) { - // retrieving username, rank and loginTime. If needed, we can always add stuff later - var val = usersdb.get(key).value(); - list.push({username:key, rank:val.rank, loginTime:val.loginTime}); - } - return list; - }, + // returns the username of an "aproximate" user + // read 'getAproxOnlineUser' to understand the difference between + // 'getOnlineUser' and it, same happens here between 'getUser' and + // 'getAproxUser'. + getAproxUser: function getAproxUser(name) { + if (this.getUser(name) !== undefined) return [name]; + var possibilities = []; + for (var key in usersdb.getState()) { + if (name.toLowerCase() === key.toLowerCase().substr(0,name.length) && (name.length < key.length)) { + possibilities.push(key); + } + } + if (possibilities.length === 0) return []; + return possibilities; + }, + + // updates a user in the database + // TODO: argh, we surely don't want this! harden it! + updateUser: function updateUser(username, userObj) { + username = username.toLowerCase().charAt(0).toUpperCase() + username.toLowerCase().slice(1); + usersdb.set(username,userObj).write(); + }, + + // get users list, only insensitive information + getUsersList: function getUsersList() { + var list = []; + for (var key in usersdb.getState()) { + // retrieving username, rank and loginTime. If needed, we can always add stuff later + var val = usersdb.get(key).value(); + list.push({username:key, rank:val.rank, loginTime:val.loginTime}); + } + return list; + }, // gives a full view of the universe; TODO: we surely don't want this // TODO: in the meantime, we don't need to define a function for this! diff --git a/commands/2fa.js b/commands/2fa.js new file mode 100644 index 0000000..7e3fff7 --- /dev/null +++ b/commands/2fa.js @@ -0,0 +1,179 @@ +exports.command = { + name: "2fa", + alias: "", + autoload: true, + unloadable: true, + min_rank: 0, + display: "Manages 2 factor authentication for your account", + help: "To enroll your account for 2FA: .2fa enroll + .2fa verify \r\n" + + "To generate a new backup code: .2fa newbackupcode\r\n" + + "To check your account 2FA status: .2fa status\r\n" + + "To disable 2FA for your account: .2fa disable \r\n\r\n" + + "For more detailed help use .2fa help", + usage: ".2fa |disable |newbackupcode|status>", + weigth: 0, + + // returns secret key or backup code in a friendly easy-to-read format + formatFriendlyKeyString: function(keyString) { + return keyString.match(/.{1,4}/g).join('-') + }, + + // outputs command extended help with information on 2fa enrollment procedure + outputDetailedHelp: function(socket, command_access) { + var chalk = require('chalk'); + + command_access.sendData(socket, chalk.blue("+-----------------------------------------------------------------------------+\r\n")); + command_access.sendData(socket, " Detailed help for ." + this.name + " command\r\n"); + command_access.sendData(socket, chalk.blue("+-----------------------------------------------------------------------------+\r\n")); + command_access.sendData(socket, " 1. Enrolling into 2 factor authentication on " + chalk.bold(command_access.talkername) + "\r\n\r\n"); + command_access.sendData(socket, " Enrollment starts by using " + chalk.yellow(chalk.bold("." + this.name + " enroll")) + " command.\r\n\r\n"); + command_access.sendData(socket, " This command will generate a secret key that you will need to configure\r\n"); + command_access.sendData(socket, " on your soft token software, to generate a token to authenticate. Check\r\n"); + command_access.sendData(socket, " https://en.wikipedia.org/wiki/Google_Authenticator#Other_authentication_software\r\n"); + command_access.sendData(socket, " for a list of soft woken software you can use for this purpose.\r\n\r\n"); + command_access.sendData(socket, " After configuring your soft token software with the generated secret key\r\n"); + command_access.sendData(socket, " you need to confirm that it's generating correct tokens, by using the\r\n"); + command_access.sendData(socket, " following command: " + chalk.yellow(chalk.bold("." + this.name + " verify ")) + "\r\n\r\n"); + command_access.sendData(socket, " This command will certify that your 2FA software is properly configured\r\n"); + command_access.sendData(socket, " and will enable 2FA for your account. It will also generate a backup code\r\n"); + command_access.sendData(socket, " which you can use if you are unable to authenticate using 2FA. Using this\r\n"); + command_access.sendData(socket, " backup code will require generating a new one using " + chalk.yellow(chalk.bold("." + this.name + " newbackupcode")) + "\r\n\r\n"); + command_access.sendData(socket, " 2. Checking 2FA status for your user account\r\n\r\n"); + command_access.sendData(socket, " To check the status of 2FA for your account use " + chalk.yellow(chalk.bold("." + this.name + " status")) + "\r\n\r\n"); + command_access.sendData(socket, " 3. Disable 2 factor authentication\r\n\r\n"); + command_access.sendData(socket, " You can disable 2FA at any time by using " + chalk.yellow(chalk.bold("." + this.name + " disable ")) + "\r\n"); + command_access.sendData(socket, " To reenable it on your account you will be required to enroll again\r\n"); + command_access.sendData(socket, chalk.blue("+-----------------------------------------------------------------------------+\r\n")); + }, + + // Function to execute the command + execute: function(socket, command, command_access) { + var chalk = require('chalk'); + var operation = command.split(' ')[0]; + var token = command.split(' ')[1]; + switch(operation) + { + case 'enroll': // starts 2fa enrollment (generates secret key and waits for token verification) + if(socket.db.auth2fa_status) + { + command_access.sendData(socket, "2 factor authentication is already enabled for your account!\r\n"); + command_access.sendData(socket, "If you really want to start the enrollment procedure please disable 2FA first.\r\n"); + } else { + if(typeof socket.db.auth2fa_status === 'undefined') + { + // start enrollment + var newSecretKey = command_access.auth2faGenerateSecretKey(); + socket.db.auth2fa_secretKey = newSecretKey; + socket.db.auth2fa_status = false; + command_access.sendData(socket, "Your secret key is: " + chalk.yellow(chalk.bold(this.formatFriendlyKeyString(newSecretKey))) + "\r\n"); + command_access.sendData(socket, "Please add it to your soft token software (dashes are for easy reading only),\r\n"); + command_access.sendData(socket, "and then verify the configuration using " + chalk.yellow(chalk.bold("." + this.name + " verify ")) + "\r\n"); + command_access.sendData(socket, "to complete enrollment.\r\n"); + command_access.updateUser(socket.username, socket.db); + + } else { // enrollment is already ongoing and expects token verification + command_access.sendData(socket, "2 factor authentication enrollment is already taking place for your account!\r\n"); + command_access.sendData(socket, "Either complete token verification with " + chalk.yellow(chalk.bold("." + this.name + " verify ")) + " or disable 2FA.\r\n"); + } + } + break; + case 'verify': // closes 2fa enrollment (verifies token, generates backup code and sets account configuration) + if(socket.db.auth2fa_status) + { + command_access.sendData(socket, "2 factor authentication is already enabled for your account!\r\n"); + command_access.sendData(socket, "If you really want to start the enrollment procedure please disable 2FA first.\r\n"); + } else { + if(typeof socket.db.auth2fa_status === 'undefined') + { + // enrollment has not yet been started + command_access.sendData(socket, "2 factor authentication enrollment has not been started for your account!\r\n"); + command_access.sendData(socket, "To begin enrollment use " + chalk.yellow(chalk.bold("." + this.name + " enroll")) + ".\r\n"); + } else { // verify 2fa configuration and enable it for the account + if(typeof token !== 'undefined' && token.length == 6) + { + if(command_access.auth2faValidateOTP(socket.username,token)) + { // token is valid; enable 2FA for the account + var newBackupCode = command_access.auth2faGenerateSecretKey(); + socket.db.auth2fa_backupCode = newBackupCode; + socket.db.auth2fa_status = true; + command_access.updateUser(socket.username, socket.db); + command_access.sendData(socket, "Your token has been validated. Your account is now enabled for 2FA.\r\n"); + command_access.sendData(socket, "This is your backup code which you can use to login if you are unable to \r\n"); + command_access.sendData(socket, "use 2FA during login: >>>> " + chalk.yellow(chalk.bold(this.formatFriendlyKeyString(newBackupCode))) + " <<<<\r\n"); + command_access.sendData(socket, "Once you use your backup code you will need to generate a new one using.\r\n"); + command_access.sendData(socket, chalk.yellow(chalk.bold("." + this.name + " newbackupcode") + "\r\n")); + } else { + command_access.sendData(socket, chalk.red("Your token failed validation!\r\n")); + command_access.sendData(socket, "Please provide a valid token generated from your secret key!\r\n"); + command_access.sendData(socket, "If you wish to restart the enrollment process please use " + chalk.yellow(chalk.bold("." + this.name + " disable")) + "\r\n"); + } + } else { + command_access.sendData(socket, "Please provide a valid token generated from your secret key!\r\n"); + } + } + } + break; + case 'disable': // disables 2FA when enrollment has taken place + if(typeof socket.db.auth2fa_status !== 'undefined') + { // 2FA is enabled or in the enrollment process + var continueDisable = false; + if(socket.db.auth2fa_status) + { + if(typeof token !== 'undefined' && command_access.auth2faValidateOTP(socket.username,token)) + { + continueDisable = true; + command_access.sendData(socket, "2 factor authentication has been disabled for your account.\r\n"); + } else { + continueDisable = false; + command_access.sendData(socket, "Invalid token!\r\n"); + } + } else { + continueDisable = true; + command_access.sendData(socket, "2 factor authentication enrollment process has been canceled.\r\n"); + } + if(continueDisable) + { + delete socket.db.auth2fa_status; + delete socket.db.auth2fa_secretKey; + delete socket.db.auth2fa_backupCode; + command_access.updateUser(socket.username, socket.db); + } + } else { + command_access.sendData(socket, "You don't have 2 factor authentication enabled for your account!\r\n"); + } + break; + case 'newbackupcode': // generates a new backup code + if(socket.db.auth2fa_status) + { + var newBackupCode = command_access.auth2faGenerateSecretKey(); + socket.db.auth2fa_backupCode = newBackupCode; + command_access.sendData(socket, "Your new backup code is: >>>> " + chalk.yellow(chalk.bold(this.formatFriendlyKeyString(newBackupCode))) + " <<<<\r\n"); + command_access.updateUser(socket.username, socket.db); + } else { + command_access.sendData(socket, "You don't have 2 factor authentication enabled for your account!\r\n"); + } + break; + case 'status': // queries 2fa configuration state + command_access.sendData(socket, "+----------------------------------------------------------------------------+\r\n"); + if(typeof socket.db.auth2fa_status != 'undefined') // 2FA is not configured + { + if(socket.db.auth2fa_status) { // enabled + command_access.sendData(socket, ("+ 2 factor authentication " + chalk.bold("is enabled") + " for your account").padEnd(77) + "+ \r\n"); + if(socket.db.auth2fa_backupCode.substr(0,1) === '!') { + command_access.sendData(socket, ("+ " + chalk.red("Your backup code is burned!") + " Type " + chalk.bold("." + this.name + " newbackupcode") + " to generate a new one.").padEnd(77) + "+ \r\n"); + } + } else { // enrolling + command_access.sendData(socket, ("+ " + chalk.yellow("You are currently enrolling into 2FA")).padEnd(77) + "+\r\n"); + command_access.sendData(socket, ("+ Next step: verify soft token configuration using " + chalk.bold("." + this.name + " verify ")).padEnd(77) + "+\r\n"); + command_access.sendData(socket, ("+ For further help use " + chalk.bold("." + this.name + " help")).padEnd(77) + "+\r\n"); + } + } else { + command_access.sendData(socket, ("+ 2 factor authentication is " + chalk.bold("not enabled") + " for your account").padEnd(77) + "+\r\n"); + } + command_access.sendData(socket, "+----------------------------------------------------------------------------+\r\n"); + break; + default: // handles help command too + this.outputDetailedHelp(socket, command_access); + } + } +} \ No newline at end of file diff --git a/commands/2faadmin.js b/commands/2faadmin.js new file mode 100644 index 0000000..bb8a491 --- /dev/null +++ b/commands/2faadmin.js @@ -0,0 +1,105 @@ +exports.command = { + name: "2faadmin", + alias: "", + autoload: true, + unloadable: true, + min_rank: 7, + display: "Manages 2 factor authentication for users", + help: "To reset 2FA configuration for a user: .2faadmin resetuser \r\n" + + "To burn a backup code for a user: .2faadmin burnbackupcode \r\n" + + "To return the general 2FA stats: .2faadmin status\r\n" + + "To return the 2FA status for a user: .2faadmin status ", + usage: ".2faadmin |burnbackupcode |status []>", + weigth: 10, + + // Function to execute the command + execute: function(socket, command, command_access) { + var chalk = require('chalk'); + var operation = command.split(' ')[0]; + var argument = command.split(' ')[1]; + switch(operation) + { + case 'resetuser': + if(typeof argument !== 'undefined') { + var userRecord = command_access.getUser(argument); + if(typeof userRecord !== 'undefined') { + delete userRecord.auth2fa_status; + delete userRecord.auth2fa_secretKey; + delete userRecord.auth2fa_backupCode; + command_access.updateUser(argument, socket.db); + command_access.sendData(socket, "2FA configuration for user " + chalk.bold(argument) + " has been resetted.\r\n"); + } else { + command_access.sendData(socket, "Username not found!\r\n"); + } + } else { + command_access.sendData(socket, "Please provide a username!\r\n"); + } + break; + case 'burnbackupcode': + if(typeof argument !== 'undefined') { + var userRecord = command_access.getUser(argument); + if(typeof userRecord !== 'undefined') { + if(typeof userRecord.auth2fa_backupCode !== 'undefined') + { + if(userRecord.auth2fa_backupCode.substr(0,1) !== '!') { + command_access.auth2faBurnBackupCode(argument); + command_access.sendData(socket, "Backup code for user " + chalk.bold(argument) + " has been burned. User will need to generate a new one.\r\n"); + } else { + command_access.sendData(socket, "Backup code for user " + chalk.bold(argument) + " is already burned.\r\n"); + } + } else { + command_access.sendData(socket, "User "+ argument +" does not have a backup code! (2FA disabled or enrollment not completed)\r\n"); + } + } else { + command_access.sendData(socket, "Username not found!\r\n"); + } + } else { + command_access.sendData(socket, "Please provide a username!\r\n"); + } + break; + case 'status': + if(typeof argument === 'undefined') { // general status + var userList = command_access.getUsersList(); + var countEnabled = 0, countDisabled = 0, countEnrolling = 0, countBurnedBackupCode = 0; + for (let index = 0; index < userList.length; index++) { + var userRecord = command_access.getUser(userList[index].username); + if(typeof userRecord.auth2fa_status === 'undefined') { + countDisabled++; + } else if(userRecord.auth2fa_status) { + countEnabled++; + if(userRecord.auth2fa_backupCode.substr(0,1) === '!') { + countBurnedBackupCode++; + } + } else { + countEnrolling++; + } + } + command_access.sendData(socket, chalk.green("User accounts with 2FA enabled : " + countEnabled + "\r\n")); + command_access.sendData(socket, chalk.yellow("User accounts with 2FA ongoing enrollment: " + countEnrolling + "\r\n")); + command_access.sendData(socket, chalk.red("User accounts with burned backup codes : " + countBurnedBackupCode + "\r\n")); + command_access.sendData(socket, chalk.blueBright("User accounts with 2FA disabled : " + countDisabled + "\r\n")); + } else { // user status + var userRecord = command_access.getUser(argument); + if(typeof userRecord !== 'undefined') { + if(typeof userRecord.auth2fa_status !== 'undefined') { + if(userRecord.auth2fa_status) { + command_access.sendData(socket, "2 factor authentication " + chalk.bold("is enabled") + " for user " + argument + "\r\n"); + if(userRecord.auth2fa_backupCode.substr(0,1) === '!') { + command_access.sendData(socket, chalk.red("Backup code for user " + argument + " is burned!\r\n")); + } + } else { + command_access.sendData(socket, "2 factor authentication is in " + chalk.bold("enrollment process") + " for user " + argument + "\r\n"); + } + } else { + command_access.sendData(socket, "2 factor authentication is " + chalk.bold("not enabled") + " for user " + argument + "\r\n"); + } + } else { + command_access.sendData(socket, "Username not found!\r\n"); + } + } + break; + default: + command_access.sendData(socket, "Invalid operation for ." + this.name + ". Check syntax using .help 2faadmin\r\n"); + } + } +} \ No newline at end of file diff --git a/commands/template.js b/commands/template.js index da1e8e3..0421c20 100644 --- a/commands/template.js +++ b/commands/template.js @@ -1,5 +1,6 @@ exports.command = { name: "template", // Name of command to be executed (Max 10 chars) + alias: "ctemplate", // Alias for the command (partial match not supported; Max 10 chars) autoload: false, // Should the command be autoloaded at startup unloadable: true, // Can the command be unloaded dynamically min_rank: 0, // Minimum rank to use to execute the command