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"