diff --git a/.eslintrc.json b/.eslintrc.json index 612da123..53bd1287 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,9 @@ "es6": true, "node": true }, - "extends": "eslint:recommended", + "extends": [ + "eslint:recommended" + ], "rules": { "indent": [ "error", diff --git a/WHATSNEW.md b/WHATSNEW.md index 7137038a..d4a5bc39 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -4,6 +4,8 @@ This document attempts to track **major** changes and additions in ENiGMA½. For ## 0.0.10-alpha + `oputil.js user rename USERNAME NEWNAME` + `my_messages.js` module (defaulted to "m" at the message menu) to list public messages addressed to the currently logged in user. Takes into account their username and `real_name` property. ++ SSH Public Key Authentication has been added. The system uses a OpenSSH style public key set on the `ssh_public_key` user property. ++ ## 0.0.9-alpha diff --git a/core/acs_parser.js b/core/acs_parser.js index d4084b95..3763c8e7 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -846,6 +846,7 @@ function peg$parse(input, options) { const UserProps = require('./user_property.js'); const Log = require('./logger.js').log; + const User = require('./user.js'); const _ = require('lodash'); const moment = require('moment'); @@ -982,6 +983,22 @@ function peg$parse(input, options) { SC : function isSecureConnection() { return _.get(client, 'session.isSecure', false); }, + AF : function currentAuthFactor() { + if(!user) { + return false; + } + return !isNaN(value) && user.authFactor >= value; + }, + AR : function authFactorRequired() { + if(!user) { + return false; + } + switch(value) { + case 1 : return user.authFactor >= User.AuthFactors.Factor1; + case 2 : return user.authFactor >= User.AuthFActors.Factor2; + default : return false; + } + }, ML : function minutesLeft() { // :TODO: implement me! return false; diff --git a/core/config.js b/core/config.js index d6cdadba..14f258d8 100644 --- a/core/config.js +++ b/core/config.js @@ -224,6 +224,10 @@ function getDefaultConfig() { autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes. }, unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts + + twoFactorAuth : { + method : 'googleAuth', + } }, theme : { diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index 3e486bbd..6156fa3a 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -107,7 +107,7 @@ function SSHClient(clientConn) { }; const authWithPasswordOrPubKey = (authType) => { - if(User.AuthFactor1Types.PubKey !== authType || !self.user.isAuthenticated() || !ctx.signature) { + if(User.AuthFactor1Types.SSHPubKey !== authType || !self.user.isAuthenticated() || !ctx.signature) { // step 1: login/auth using PubKey userLogin(self, ctx.username, ctx.password, { authType, ctx }, (err) => { if(err) { @@ -126,7 +126,7 @@ function SSHClient(clientConn) { }); } else { // step 2: verify signature - const pubKeyActual = ssh2.utils.parseKey(self.user.getProperty(UserProps.LoginPubKey)); + const pubKeyActual = ssh2.utils.parseKey(self.user.getProperty(UserProps.AuthPubKey)); if(!pubKeyActual || !pubKeyActual.verify(ctx.blob, ctx.signature)) { return slowTerminateConnection(); } @@ -191,7 +191,7 @@ function SSHClient(clientConn) { //return authWithPassword(); case 'publickey' : - return authWithPasswordOrPubKey(User.AuthFactor1Types.PubKey); + return authWithPasswordOrPubKey(User.AuthFactor1Types.SSHPubKey); //return authWithPubKey(); case 'keyboard-interactive' : diff --git a/core/system_menu_method.js b/core/system_menu_method.js index ea2cbc09..b83bea80 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -8,12 +8,14 @@ const { userLogin } = require('./user_login.js'); const messageArea = require('./message_area.js'); const { ErrorReasons } = require('./enig_error.js'); const UserProps = require('./user_property.js'); +const { user2FA_OTP } = require('./user_2fa_otp.js'); // deps const _ = require('lodash'); const iconv = require('iconv-lite'); exports.login = login; +exports.login2FA_OTP = login2FA_OTP; exports.logoff = logoff; exports.prevMenu = prevMenu; exports.nextMenu = nextMenu; @@ -23,32 +25,47 @@ exports.prevArea = prevArea; exports.nextArea = nextArea; exports.sendForgotPasswordEmail = sendForgotPasswordEmail; +const handleAuthFailures = (callingMenu, err, cb) => { + // already logged in with this user? + if(ErrorReasons.AlreadyLoggedIn === err.reasonCode && + _.has(callingMenu, 'menuConfig.config.tooNodeMenu')) + { + return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb); + } + + // banned username results in disconnect + if(ErrorReasons.NotAllowed === err.reasonCode) { + return logoff(callingMenu, {}, {}, cb); + } + + const ReasonsMenus = [ + ErrorReasons.TooMany, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked + ]; + if(ReasonsMenus.includes(err.reasonCode)) { + const menu = _.get(callingMenu, [ 'menuConfig', 'config', err.reasonCode.toLowerCase() ]); + return menu ? callingMenu.gotoMenu(menu, cb) : logoff(callingMenu, {}, {}, cb); + } + + // Other error + return callingMenu.prevMenu(cb); +}; + function login(callingMenu, formData, extraArgs, cb) { userLogin(callingMenu.client, formData.value.username, formData.value.password, err => { if(err) { - // already logged in with this user? - if(ErrorReasons.AlreadyLoggedIn === err.reasonCode && - _.has(callingMenu, 'menuConfig.config.tooNodeMenu')) - { - return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb); - } + return handleAuthFailures(callingMenu, err, cb); + } - // banned username results in disconnect - if(ErrorReasons.NotAllowed === err.reasonCode) { - return logoff(callingMenu, {}, {}, cb); - } + // success! + return callingMenu.nextMenu(cb); + }); +} - const ReasonsMenus = [ - ErrorReasons.TooMany, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked - ]; - if(ReasonsMenus.includes(err.reasonCode)) { - const menu = _.get(callingMenu, [ 'menuConfig', 'config', err.reasonCode.toLowerCase() ]); - return menu ? callingMenu.gotoMenu(menu, cb) : logoff(callingMenu, {}, {}, cb); - } - - // Other error - return callingMenu.prevMenu(cb); +function login2FA_OTP(callingMenu, formData, extraArgs, cb) { + user2FA_OTP(callingMenu.client, formData.value.token, err => { + if(err) { + return handleAuthFailures(callingMenu, err, cb); } // success! diff --git a/core/user.js b/core/user.js index 3b20aa76..2d4883d7 100644 --- a/core/user.js +++ b/core/user.js @@ -27,10 +27,11 @@ exports.isRootUserId = function(id) { return 1 === id; }; module.exports = class User { constructor() { - this.userId = 0; - this.username = ''; - this.properties = {}; // name:value - this.groups = []; // group membership(s) + this.userId = 0; + this.username = ''; + this.properties = {}; // name:value + this.groups = []; // group membership(s) + this.authFactor = User.AuthFactors.None; } // static property accessors @@ -38,6 +39,14 @@ module.exports = class User { return 1; } + static get AuthFactors() { + return { + None : 0, // Not yet authenticated in any way + Factor1 : 1, // username + password/pubkey/etc. checked out + Factor2 : 2, // validated with 2FA of some sort such as OTP + }; + } + static get PBKDF2() { return { iterations : 1000, @@ -50,7 +59,7 @@ module.exports = class User { return { auth : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk, - UserProps.LoginPubKey, UserProps.LoginPubKeyFingerprintSHA256, + UserProps.AuthPubKey, ], }; } @@ -180,8 +189,9 @@ module.exports = class User { static get AuthFactor1Types() { return { - PubKey : 'pubKey', + SSHPubKey : 'sshPubKey', Password : 'password', + TLSClient : 'tlsClientAuth', }; } @@ -210,7 +220,7 @@ module.exports = class User { }; const validatePubKey = (props, callback) => { - const pubKeyActual = ssh2.utils.parseKey(props[UserProps.LoginPubKey]); + const pubKeyActual = ssh2.utils.parseKey(props[UserProps.AuthPubKey]); if(!pubKeyActual) { return callback(Errors.AccessDenied('Invalid public key')); } @@ -242,7 +252,7 @@ module.exports = class User { }); }, function validatePassOrPubKey(props, callback) { - if(User.AuthFactor1Types.PubKey === authInfo.type) { + if(User.AuthFactor1Types.SSHPubKey === authInfo.type) { return validatePubKey(props, callback); } return validatePassword(props, callback); @@ -323,7 +333,12 @@ module.exports = class User { self.username = tempAuthInfo.username; self.properties = tempAuthInfo.properties; self.groups = tempAuthInfo.groups; - self.authenticated = true; + self.authFactor = User.AuthFactors.Factor1; + + // + // If 2FA/OTP is required, this user is not quite authenticated yet. + // + self.authenticated = !(self.getProperty(UserProps.AuthFactor2OTP) ? true : false); self.removeProperty(UserProps.FailedLoginAttempts); @@ -604,7 +619,10 @@ module.exports = class User { user.username = userName; user.properties = properties; user.groups = groups; - user.authenticated = false; // this is NOT an authenticated user! + + // explicitly NOT an authenticated user! + user.authenticated = false; + user.authFactor = User.AuthFactors.None; return cb(err, user); } diff --git a/core/user_property.js b/core/user_property.js index c3aabcd0..f3f3b652 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -58,7 +58,10 @@ module.exports = { MinutesOnlineTotalCount : 'minutes_online_total_count', - LoginPubKey : 'login_public_key', // OpenSSL format - //LoginPubKeyFingerprintSHA256 : 'login_public_key_fp_sha256', // hint: ssh-kegen -lf id_rsa.pub + SSHPubKey : 'ssh_public_key', // OpenSSH format (ssh-keygen, etc.) + AuthFactor1Types : 'auth_factor1_types', // List of User.AuthFactor1Types value(s) + AuthFactor2OTP : 'auth_factor2_otp', // If present, OTP type for 2FA + AuthFactor2OTPSecret : 'auth_factor2_otp_secret', // Secret used in conjunction with OTP 2FA + AuthFactor2OTPScratchCodes : 'auth_factor2_otp_scratch', // JSON array style codes ["code1", "code2", ...] }; diff --git a/docs/configuration/acs.md b/docs/configuration/acs.md index d0a45d06..64d67648 100644 --- a/docs/configuration/acs.md +++ b/docs/configuration/acs.md @@ -37,8 +37,8 @@ The following are ACS codes available as of this writing: | MMminutes | It is currently >= _minutes_ past midnight (system time) | | ACachievementCount | User has >= _achievementCount_ achievements | | APachievementPoints | User has >= _achievementPoints_ achievement points | - -\* Many more ACS codes are planned for the near future. +| AFauthFactor | User's current *Authentication Factor* is >= _authFactor_. Authentication factor 1 refers to username + password (or PubKey) while factor 2 refers to 2FA such as One-Time-Password authentication. | +| ARauthFactorReq | Current users **requires** an Authentication Factor >= _authFactorReq_ | ## ACS Strings ACS strings are one or more ACS codes in addition to some basic language semantics. diff --git a/docs/configuration/menu-hjson.md b/docs/configuration/menu-hjson.md index a59e5b24..9294c08c 100644 --- a/docs/configuration/menu-hjson.md +++ b/docs/configuration/menu-hjson.md @@ -86,6 +86,7 @@ Many built in global/system methods exist. Below are a few. See [system_menu_met | Method | Description | |--------|-------------| | `login` | Performs a standard login. | +| `login2FA_OTP` | Performs a 2-Factor Authentication (2FA) One-Time Password (OTP) check, if configured for the user. | | `logoff` | Performs a standard system logoff. | | `prevMenu` | Goes to the previous menu. | | `nextMenu` | Goes to the next menu (as set by `next`) | diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index 8a39deea..060344a8 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -2,6 +2,7 @@ { const UserProps = require('./user_property.js'); const Log = require('./logger.js').log; + const User = require('./user.js'); const _ = require('lodash'); const moment = require('moment'); @@ -138,6 +139,22 @@ SC : function isSecureConnection() { return _.get(client, 'session.isSecure', false); }, + AF : function currentAuthFactor() { + if(!user) { + return false; + } + return !isNaN(value) && user.authFactor >= value; + }, + AR : function authFactorRequired() { + if(!user) { + return false; + } + switch(value) { + case 1 : return user.authFactor >= User.AuthFactors.Factor1; + case 2 : return user.authFactor >= User.AuthFActors.Factor2; + default : return false; + } + }, ML : function minutesLeft() { // :TODO: implement me! return false; diff --git a/package.json b/package.json index 299074d6..4a700f1d 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "uuid-parse": "^1.0.0", "ws": "^6.1.3", "xxhash": "^0.2.4", - "yazl": "^2.5.1" + "yazl": "^2.5.1", + "otplib": "^10.0.1" }, "devDependencies": {}, "engines": { diff --git a/yarn.lock b/yarn.lock index 6a922b8f..09bf3e98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,6 +1478,13 @@ osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" +otplib@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/otplib/-/otplib-10.0.1.tgz#d37fcd13203298c0b94937d55c5a3527ed877875" + integrity sha512-FtbKelYtio2af5LDBWz3bWS6T03taHJAIv3evMrXuvoM50z5jbWoEMabPCk0A0JqiLGBzAIDJWfR9gSsvRYZHA== + dependencies: + thirty-two "1.0.2" + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" @@ -2026,6 +2033,11 @@ temptmp@^1.1.0: dependencies: del "^3.0.0" +thirty-two@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a" + integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno= + through2@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"