SECURITY UPDATE
* Handle failed login attempts via Telnet * New lockout features for >= N failed attempts * New auto-unlock over email feature * New auto-unlock after N minutes feature * Code cleanup in users * Add user_property.js - start using consts for user properties. Clean up over time. * Update email docs
This commit is contained in:
parent
f18b023652
commit
df2bf4477e
Binary file not shown.
Binary file not shown.
|
@ -172,7 +172,6 @@ function getDefaultConfig() {
|
||||||
|
|
||||||
// :TODO: closedSystem and loginAttemps prob belong under users{}?
|
// :TODO: closedSystem and loginAttemps prob belong under users{}?
|
||||||
closedSystem : false, // is the system closed to new users?
|
closedSystem : false, // is the system closed to new users?
|
||||||
loginAttempts : 3,
|
|
||||||
|
|
||||||
menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
|
menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
|
||||||
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
|
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
|
||||||
|
@ -217,6 +216,13 @@ function getDefaultConfig() {
|
||||||
|
|
||||||
preAuthIdleLogoutSeconds : 60 * 3, // 3m
|
preAuthIdleLogoutSeconds : 60 * 3, // 3m
|
||||||
idleLogoutSeconds : 60 * 6, // 6m
|
idleLogoutSeconds : 60 * 6, // 6m
|
||||||
|
|
||||||
|
failedLogin : {
|
||||||
|
disconnect : 3, // 0=disabled
|
||||||
|
lockAccount : 9, // 0=disabled; Mark user status as "locked" if >= N
|
||||||
|
autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes.
|
||||||
|
},
|
||||||
|
unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts
|
||||||
},
|
},
|
||||||
|
|
||||||
theme : {
|
theme : {
|
||||||
|
|
|
@ -46,4 +46,8 @@ exports.ErrorReasons = {
|
||||||
NoConditionMatch : 'NOCONDMATCH',
|
NoConditionMatch : 'NOCONDMATCH',
|
||||||
NotEnabled : 'NOTENABLED',
|
NotEnabled : 'NOTENABLED',
|
||||||
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
|
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
|
||||||
};
|
TooMany : 'TOOMANY',
|
||||||
|
Disabled : 'DISABLED',
|
||||||
|
Inactive : 'INACTIVE',
|
||||||
|
Locked : 'LOCKED',
|
||||||
|
};
|
||||||
|
|
|
@ -38,7 +38,7 @@ function getAnswers(questions, cb) {
|
||||||
const ConfigIncludeKeys = [
|
const ConfigIncludeKeys = [
|
||||||
'theme',
|
'theme',
|
||||||
'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds',
|
'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds',
|
||||||
'users.newUserNames',
|
'users.newUserNames', 'users.failedLogin', 'users.unlockAtEmailPwReset',
|
||||||
'paths.logs',
|
'paths.logs',
|
||||||
'loginServers',
|
'loginServers',
|
||||||
'contentServers',
|
'contentServers',
|
||||||
|
|
|
@ -30,6 +30,7 @@ actions:
|
||||||
activate USERNAME sets USERNAME's status to active
|
activate USERNAME sets USERNAME's status to active
|
||||||
deactivate USERNAME sets USERNAME's status to inactive
|
deactivate USERNAME sets USERNAME's status to inactive
|
||||||
disable USERNAME sets USERNAME's status to disabled
|
disable USERNAME sets USERNAME's status to disabled
|
||||||
|
lock USERNAME sets USERNAME's status to locked
|
||||||
group USERNAME [+|-]GROUP adds (+) or removes (-) user from GROUP
|
group USERNAME [+|-]GROUP adds (+) or removes (-) user from GROUP
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,14 @@ function setAccountStatus(user, status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountStatus = require('../../core/user.js').AccountStatus;
|
const AccountStatus = require('../../core/user.js').AccountStatus;
|
||||||
|
|
||||||
|
status = {
|
||||||
|
activate : AccountStatus.active,
|
||||||
|
deactivate : AccountStatus.inactive,
|
||||||
|
disable : AccountStatus.disabled,
|
||||||
|
lock : AccountStatus.locked,
|
||||||
|
}[status];
|
||||||
|
|
||||||
const statusDesc = _.invert(AccountStatus)[status];
|
const statusDesc = _.invert(AccountStatus)[status];
|
||||||
user.persistProperty('account_status', status, err => {
|
user.persistProperty('account_status', status, err => {
|
||||||
if(err) {
|
if(err) {
|
||||||
|
@ -147,21 +155,6 @@ function modUserGroups(user) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateUser(user) {
|
|
||||||
const AccountStatus = require('../../core/user.js').AccountStatus;
|
|
||||||
return setAccountStatus(user, AccountStatus.active);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deactivateUser(user) {
|
|
||||||
const AccountStatus = require('../../core/user.js').AccountStatus;
|
|
||||||
return setAccountStatus(user, AccountStatus.inactive);
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableUser(user) {
|
|
||||||
const AccountStatus = require('../../core/user.js').AccountStatus;
|
|
||||||
return setAccountStatus(user, AccountStatus.disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUserCommand() {
|
function handleUserCommand() {
|
||||||
function errUsage() {
|
function errUsage() {
|
||||||
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
|
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
|
||||||
|
@ -195,11 +188,12 @@ function handleUserCommand() {
|
||||||
del : removeUser,
|
del : removeUser,
|
||||||
delete : removeUser,
|
delete : removeUser,
|
||||||
|
|
||||||
activate : activateUser,
|
activate : setAccountStatus,
|
||||||
deactivate : deactivateUser,
|
deactivate : setAccountStatus,
|
||||||
disable : disableUser,
|
disable : setAccountStatus,
|
||||||
|
lock : setAccountStatus,
|
||||||
|
|
||||||
group : modUserGroups,
|
group : modUserGroups,
|
||||||
}[action] || errUsage)(user);
|
}[action] || errUsage)(user, action);
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -10,7 +10,10 @@ const userLogin = require('../../user_login.js').userLogin;
|
||||||
const enigVersion = require('../../../package.json').version;
|
const enigVersion = require('../../../package.json').version;
|
||||||
const theme = require('../../theme.js');
|
const theme = require('../../theme.js');
|
||||||
const stringFormat = require('../../string_format.js');
|
const stringFormat = require('../../string_format.js');
|
||||||
const { ErrorReasons } = require('../../enig_error.js');
|
const {
|
||||||
|
Errors,
|
||||||
|
ErrorReasons
|
||||||
|
} = require('../../enig_error.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const ssh2 = require('ssh2');
|
const ssh2 = require('ssh2');
|
||||||
|
@ -37,8 +40,6 @@ function SSHClient(clientConn) {
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
let loginAttempts = 0;
|
|
||||||
|
|
||||||
clientConn.on('authentication', function authAttempt(ctx) {
|
clientConn.on('authentication', function authAttempt(ctx) {
|
||||||
const username = ctx.username || '';
|
const username = ctx.username || '';
|
||||||
const password = ctx.password || '';
|
const password = ctx.password || '';
|
||||||
|
@ -53,26 +54,56 @@ function SSHClient(clientConn) {
|
||||||
return clientConn.end();
|
return clientConn.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
function alreadyLoggedIn(username) {
|
function promptAndTerm(msg) {
|
||||||
ctx.prompt(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`);
|
if('keyboard-interactive' === ctx.method) {
|
||||||
|
ctx.prompt(msg);
|
||||||
|
}
|
||||||
return terminateConnection();
|
return terminateConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function accountAlreadyLoggedIn(username) {
|
||||||
|
return promptAndTerm(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountDisabled(username) {
|
||||||
|
return promptAndTerm(`${username} is disabled.\n(Press any key to continue)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountInactive(username) {
|
||||||
|
return promptAndTerm(`${username} is waiting for +op activation.\n(Press any key to continue)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLocked(username) {
|
||||||
|
return promptAndTerm(`${username} is locked.\n(Press any key to continue)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpecialHandleError(err) {
|
||||||
|
return [ ErrorReasons.AlreadyLoggedIn, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked ].includes(err.reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSpecialError(err, username) {
|
||||||
|
switch(err.reasonCode) {
|
||||||
|
case ErrorReasons.AlreadyLoggedIn : return accountAlreadyLoggedIn(username);
|
||||||
|
case ErrorReasons.Inactive : return accountInactive(username);
|
||||||
|
case ErrorReasons.Disabled : return accountDisabled(username);
|
||||||
|
case ErrorReasons.Locked : return accountLocked(username);
|
||||||
|
default : return terminateConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// If the system is open and |isNewUser| is true, the login
|
// If the system is open and |isNewUser| is true, the login
|
||||||
// sequence is hijacked in order to start the applicaiton process.
|
// sequence is hijacked in order to start the application process.
|
||||||
//
|
//
|
||||||
if(false === config.general.closedSystem && self.isNewUser) {
|
if(false === config.general.closedSystem && self.isNewUser) {
|
||||||
return ctx.accept();
|
return ctx.accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
if(username.length > 0 && password.length > 0) {
|
if(username.length > 0 && password.length > 0) {
|
||||||
loginAttempts += 1;
|
|
||||||
|
|
||||||
userLogin(self, ctx.username, ctx.password, function authResult(err) {
|
userLogin(self, ctx.username, ctx.password, function authResult(err) {
|
||||||
if(err) {
|
if(err) {
|
||||||
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode) {
|
if(isSpecialHandleError(err)) {
|
||||||
return alreadyLoggedIn(username);
|
return handleSpecialError(err, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.reject(SSHClient.ValidAuthMethods);
|
return ctx.reject(SSHClient.ValidAuthMethods);
|
||||||
|
@ -93,15 +124,13 @@ function SSHClient(clientConn) {
|
||||||
const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false };
|
const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false };
|
||||||
|
|
||||||
ctx.prompt(interactivePrompt, function retryPrompt(answers) {
|
ctx.prompt(interactivePrompt, function retryPrompt(answers) {
|
||||||
loginAttempts += 1;
|
|
||||||
|
|
||||||
userLogin(self, username, (answers[0] || ''), err => {
|
userLogin(self, username, (answers[0] || ''), err => {
|
||||||
if(err) {
|
if(err) {
|
||||||
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode) {
|
if(isSpecialHandleError(err)) {
|
||||||
return alreadyLoggedIn(username);
|
return handleSpecialError(err, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(loginAttempts >= config.general.loginAttempts) {
|
if(Errors.BadLogin().code === err.code) {
|
||||||
return terminateConnection();
|
return terminateConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -68,6 +68,7 @@ class StatLog {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// :TODO: fix spelling :)
|
||||||
setNonPeristentSystemStat(statName, statValue) {
|
setNonPeristentSystemStat(statName, statValue) {
|
||||||
this.systemStats[statName] = statValue;
|
this.systemStats[statName] = statValue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,13 +26,21 @@ function login(callingMenu, formData, extraArgs, cb) {
|
||||||
|
|
||||||
userLogin(callingMenu.client, formData.value.username, formData.value.password, err => {
|
userLogin(callingMenu.client, formData.value.username, formData.value.password, err => {
|
||||||
if(err) {
|
if(err) {
|
||||||
// login failure
|
// already logged in with this user?
|
||||||
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode &&
|
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode &&
|
||||||
_.has(callingMenu, 'menuConfig.config.tooNodeMenu'))
|
_.has(callingMenu, 'menuConfig.config.tooNodeMenu'))
|
||||||
{
|
{
|
||||||
return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb);
|
return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, 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
|
// Other error
|
||||||
return callingMenu.prevMenu(cb);
|
return callingMenu.prevMenu(cb);
|
||||||
}
|
}
|
||||||
|
|
|
@ -187,10 +187,10 @@ module.exports = class TicFileInfo {
|
||||||
// send the file to be distributed and the accompanying TIC file.
|
// send the file to be distributed and the accompanying TIC file.
|
||||||
// Some File processors (Allfix) only insert a line with this
|
// Some File processors (Allfix) only insert a line with this
|
||||||
// keyword when the file and the associated TIC file are to be
|
// keyword when the file and the associated TIC file are to be
|
||||||
// file routed through a third sysem instead of being processed
|
// file routed through a third system instead of being processed
|
||||||
// by a file processor on that system. Others always insert it.
|
// by a file processor on that system. Others always insert it.
|
||||||
// Note that the To keyword may cause problems when the TIC file
|
// Note that the To keyword may cause problems when the TIC file
|
||||||
// is proecessed by software that does not recognise it and
|
// is processed by software that does not recognize it and
|
||||||
// passes the line "as is" to other systems.
|
// passes the line "as is" to other systems.
|
||||||
//
|
//
|
||||||
// Example: To 292/854
|
// Example: To 292/854
|
||||||
|
|
247
core/user.js
247
core/user.js
|
@ -1,11 +1,18 @@
|
||||||
/* jslint node: true */
|
/* jslint node: true */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// ENiGMA½
|
||||||
const userDb = require('./database.js').dbs.user;
|
const userDb = require('./database.js').dbs.user;
|
||||||
const Config = require('./config.js').get;
|
const Config = require('./config.js').get;
|
||||||
const userGroup = require('./user_group.js');
|
const userGroup = require('./user_group.js');
|
||||||
const Errors = require('./enig_error.js').Errors;
|
const {
|
||||||
|
Errors,
|
||||||
|
ErrorReasons
|
||||||
|
} = require('./enig_error.js');
|
||||||
const Events = require('./events.js');
|
const Events = require('./events.js');
|
||||||
|
const UserProps = require('./user_property.js');
|
||||||
|
const Log = require('./logger.js').log;
|
||||||
|
const StatLog = require('./stat_log.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
@ -39,7 +46,7 @@ module.exports = class User {
|
||||||
|
|
||||||
static get StandardPropertyGroups() {
|
static get StandardPropertyGroups() {
|
||||||
return {
|
return {
|
||||||
password : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ],
|
password : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk ],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +59,18 @@ module.exports = class User {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isSamePasswordSlowCompare(passBuf1, passBuf2) {
|
||||||
|
if(passBuf1.length !== passBuf2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = 0;
|
||||||
|
for(let i = 0; i < passBuf1.length; i++) {
|
||||||
|
c |= passBuf1[i] ^ passBuf2[i];
|
||||||
|
}
|
||||||
|
return 0 === c;
|
||||||
|
}
|
||||||
|
|
||||||
isAuthenticated() {
|
isAuthenticated() {
|
||||||
return true === this.authenticated;
|
return true === this.authenticated;
|
||||||
}
|
}
|
||||||
|
@ -61,16 +80,21 @@ module.exports = class User {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.hasValidPassword();
|
return this.hasValidPasswordProperties();
|
||||||
}
|
}
|
||||||
|
|
||||||
hasValidPassword() {
|
hasValidPasswordProperties() {
|
||||||
if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) {
|
const salt = this.getProperty(UserProps.PassPbkdf2Salt);
|
||||||
|
const dk = this.getProperty(UserProps.PassPbkdf2Dk);
|
||||||
|
|
||||||
|
if(!salt || !dk ||
|
||||||
|
(salt.length !== User.PBKDF2.saltLen * 2) ||
|
||||||
|
(dk.length !== User.PBKDF2.keyLen * 2))
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ((this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2) &&
|
return true;
|
||||||
(this.properties.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isRoot() {
|
isRoot() {
|
||||||
|
@ -102,24 +126,77 @@ module.exports = class User {
|
||||||
return 10; // :TODO: Is this what we want?
|
return 10; // :TODO: Is this what we want?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processFailedLogin(userId, cb) {
|
||||||
|
async.waterfall(
|
||||||
|
[
|
||||||
|
(callback) => {
|
||||||
|
return User.getUser(userId, callback);
|
||||||
|
},
|
||||||
|
(tempUser, callback) => {
|
||||||
|
return StatLog.incrementUserStat(
|
||||||
|
tempUser,
|
||||||
|
UserProps.FailedLoginAttempts,
|
||||||
|
1,
|
||||||
|
(err, failedAttempts) => {
|
||||||
|
return callback(null, tempUser, failedAttempts);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(tempUser, failedAttempts, callback) => {
|
||||||
|
const lockAccount = _.get(Config(), 'users.failedLogin.lockAccount');
|
||||||
|
if(lockAccount > 0 && failedAttempts >= lockAccount) {
|
||||||
|
const props = {
|
||||||
|
[ UserProps.AccountStatus ] : User.AccountStatus.locked,
|
||||||
|
[ UserProps.AccountLockedTs ] : StatLog.now,
|
||||||
|
};
|
||||||
|
if(!_.has(tempUser.properties, UserProps.AccountLockedPrevStatus)) {
|
||||||
|
props[UserProps.AccountLockedPrevStatus] = tempUser.getProperty(UserProps.AccountStatus);
|
||||||
|
}
|
||||||
|
return tempUser.persistProperties(props, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cb(null);
|
||||||
|
}
|
||||||
|
],
|
||||||
|
err => {
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockAccount(cb) {
|
||||||
|
const prevStatus = this.getProperty(UserProps.AccountLockedPrevStatus);
|
||||||
|
if(!prevStatus) {
|
||||||
|
return cb(null); // nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistProperty(UserProps.AccountStatus, prevStatus, err => {
|
||||||
|
if(err) {
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.removeProperties( [ UserProps.AccountLockedPrevStatus, UserProps.AccountLockedTs ], cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
authenticate(username, password, cb) {
|
authenticate(username, password, cb) {
|
||||||
const self = this;
|
const self = this;
|
||||||
const cachedInfo = {};
|
const tempAuthInfo = {};
|
||||||
|
|
||||||
async.waterfall(
|
async.waterfall(
|
||||||
[
|
[
|
||||||
function fetchUserId(callback) {
|
function fetchUserId(callback) {
|
||||||
// get user ID
|
// get user ID
|
||||||
User.getUserIdAndName(username, (err, uid, un) => {
|
User.getUserIdAndName(username, (err, uid, un) => {
|
||||||
cachedInfo.userId = uid;
|
tempAuthInfo.userId = uid;
|
||||||
cachedInfo.username = un;
|
tempAuthInfo.username = un;
|
||||||
|
|
||||||
return callback(err);
|
return callback(err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
function getRequiredAuthProperties(callback) {
|
function getRequiredAuthProperties(callback) {
|
||||||
// fetch properties required for authentication
|
// fetch properties required for authentication
|
||||||
User.loadProperties(cachedInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
|
User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
|
||||||
return callback(err, props);
|
return callback(err, props);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -136,30 +213,53 @@ module.exports = class User {
|
||||||
const passDkBuf = Buffer.from(passDk, 'hex');
|
const passDkBuf = Buffer.from(passDk, 'hex');
|
||||||
const propsDkBuf = Buffer.from(propsDk, 'hex');
|
const propsDkBuf = Buffer.from(propsDk, 'hex');
|
||||||
|
|
||||||
if(passDkBuf.length !== propsDkBuf.length) {
|
return callback(User.isSamePasswordSlowCompare(passDkBuf, propsDkBuf) ?
|
||||||
return callback(Errors.AccessDenied('Invalid password'));
|
null :
|
||||||
}
|
Errors.AccessDenied('Invalid password')
|
||||||
|
);
|
||||||
let c = 0;
|
|
||||||
for(let i = 0; i < passDkBuf.length; i++) {
|
|
||||||
c |= passDkBuf[i] ^ propsDkBuf[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return callback(0 === c ? null : Errors.AccessDenied('Invalid password'));
|
|
||||||
},
|
},
|
||||||
function initProps(callback) {
|
function initProps(callback) {
|
||||||
User.loadProperties(cachedInfo.userId, (err, allProps) => {
|
User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
|
||||||
if(!err) {
|
if(!err) {
|
||||||
cachedInfo.properties = allProps;
|
tempAuthInfo.properties = allProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
return callback(err);
|
return callback(err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
function checkAccountStatus(callback) {
|
||||||
|
const accountStatus = parseInt(tempAuthInfo.properties[UserProps.AccountStatus], 10);
|
||||||
|
if(User.AccountStatus.disabled === accountStatus) {
|
||||||
|
return callback(Errors.AccessDenied('Account disabled', ErrorReasons.Disabled));
|
||||||
|
}
|
||||||
|
if(User.AccountStatus.inactive === accountStatus) {
|
||||||
|
return callback(Errors.AccessDenied('Account inactive', ErrorReasons.Inactive));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(User.AccountStatus.locked === accountStatus) {
|
||||||
|
const autoUnlockMinutes = _.get(Config(), 'users.failedLogin.autoUnlockMinutes');
|
||||||
|
const lockedTs = moment(tempAuthInfo.properties[UserProps.AccountLockedTs]);
|
||||||
|
if(autoUnlockMinutes && lockedTs.isValid()) {
|
||||||
|
const minutesSinceLocked = moment().diff(lockedTs, 'minutes');
|
||||||
|
if(minutesSinceLocked >= autoUnlockMinutes) {
|
||||||
|
// allow the login - we will clear any lock there
|
||||||
|
return callback(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return callback(Errors.AccessDenied('Account is locked', ErrorReasons.Locked));
|
||||||
|
}
|
||||||
|
|
||||||
|
// anything else besides active is still not allowed
|
||||||
|
if(User.AccountStatus.active !== accountStatus) {
|
||||||
|
return callback(Errors.AccessDenied('Account is not active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(null);
|
||||||
|
},
|
||||||
function initGroups(callback) {
|
function initGroups(callback) {
|
||||||
userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => {
|
userGroup.getGroupsForUser(tempAuthInfo.userId, (err, groups) => {
|
||||||
if(!err) {
|
if(!err) {
|
||||||
cachedInfo.groups = groups;
|
tempAuthInfo.groups = groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
return callback(err);
|
return callback(err);
|
||||||
|
@ -167,15 +267,44 @@ module.exports = class User {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
err => {
|
err => {
|
||||||
if(!err) {
|
if(err) {
|
||||||
self.userId = cachedInfo.userId;
|
//
|
||||||
self.username = cachedInfo.username;
|
// If we failed login due to something besides an inactive or disabled account,
|
||||||
self.properties = cachedInfo.properties;
|
// we need to update failure status and possibly lock the account.
|
||||||
self.groups = cachedInfo.groups;
|
//
|
||||||
|
// If locked already, update the lock timestamp -- ie, extend the lockout period.
|
||||||
|
//
|
||||||
|
if(![ErrorReasons.Disabled, ErrorReasons.Inactive].includes(err.reasonCode) && tempAuthInfo.userId) {
|
||||||
|
self.processFailedLogin(tempAuthInfo.userId, persistErr => {
|
||||||
|
if(persistErr) {
|
||||||
|
Log.warn( { error : persistErr.message }, 'Failed to persist failed login information');
|
||||||
|
}
|
||||||
|
return cb(err); // pass along original error
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// everything checks out - load up info
|
||||||
|
self.userId = tempAuthInfo.userId;
|
||||||
|
self.username = tempAuthInfo.username;
|
||||||
|
self.properties = tempAuthInfo.properties;
|
||||||
|
self.groups = tempAuthInfo.groups;
|
||||||
self.authenticated = true;
|
self.authenticated = true;
|
||||||
}
|
|
||||||
|
|
||||||
return cb(err);
|
self.removeProperty(UserProps.FailedLoginAttempts);
|
||||||
|
|
||||||
|
//
|
||||||
|
// We need to *revert* any locked status back to
|
||||||
|
// the user's previous status & clean up props.
|
||||||
|
//
|
||||||
|
self.unlockAccount(unlockErr => {
|
||||||
|
if(unlockErr) {
|
||||||
|
Log.warn( { error : unlockErr.message }, 'Failed to unlock account');
|
||||||
|
}
|
||||||
|
return cb(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -191,7 +320,7 @@ module.exports = class User {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
// :TODO: set various defaults, e.g. default activation status, etc.
|
// :TODO: set various defaults, e.g. default activation status, etc.
|
||||||
self.properties.account_status = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;
|
self.properties[UserProps.AccountStatus] = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;
|
||||||
|
|
||||||
async.waterfall(
|
async.waterfall(
|
||||||
[
|
[
|
||||||
|
@ -212,7 +341,7 @@ module.exports = class User {
|
||||||
|
|
||||||
// Do not require activation for userId 1 (root/admin)
|
// Do not require activation for userId 1 (root/admin)
|
||||||
if(User.RootUserID === self.userId) {
|
if(User.RootUserID === self.userId) {
|
||||||
self.properties.account_status = User.AccountStatus.active;
|
self.properties[UserProps.AccountStatus] = User.AccountStatus.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
return callback(null, trans);
|
return callback(null, trans);
|
||||||
|
@ -225,8 +354,8 @@ module.exports = class User {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.properties.pw_pbkdf2_salt = info.salt;
|
self.properties[UserProps.PassPbkdf2Salt] = info.salt;
|
||||||
self.properties.pw_pbkdf2_dk = info.dk;
|
self.properties[UserProps.PassPbkdf2Dk] = info.dk;
|
||||||
return callback(null, trans);
|
return callback(null, trans);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -290,20 +419,32 @@ module.exports = class User {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static persistPropertyByUserId(userId, propName, propValue, cb) {
|
||||||
|
userDb.run(
|
||||||
|
`REPLACE INTO user_property (user_id, prop_name, prop_value)
|
||||||
|
VALUES (?, ?, ?);`,
|
||||||
|
[ userId, propName, propValue ],
|
||||||
|
err => {
|
||||||
|
if(cb) {
|
||||||
|
return cb(err, propValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProperty(propName) {
|
||||||
|
return this.properties[propName];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPropertyAsNumber(propName) {
|
||||||
|
return parseInt(this.getProperty(propName), 10);
|
||||||
|
}
|
||||||
|
|
||||||
persistProperty(propName, propValue, cb) {
|
persistProperty(propName, propValue, cb) {
|
||||||
// update live props
|
// update live props
|
||||||
this.properties[propName] = propValue;
|
this.properties[propName] = propValue;
|
||||||
|
|
||||||
userDb.run(
|
return User.persistPropertyByUserId(this.userId, propName, propValue, cb);
|
||||||
`REPLACE INTO user_property (user_id, prop_name, prop_value)
|
|
||||||
VALUES (?, ?, ?);`,
|
|
||||||
[ this.userId, propName, propValue ],
|
|
||||||
err => {
|
|
||||||
if(cb) {
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeProperty(propName, cb) {
|
removeProperty(propName, cb) {
|
||||||
|
@ -322,6 +463,15 @@ module.exports = class User {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeProperties(propNames, cb) {
|
||||||
|
async.each(propNames, (name, next) => {
|
||||||
|
return this.removeProperty(name, next);
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
return cb(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
persistProperties(properties, transOrDb, cb) {
|
persistProperties(properties, transOrDb, cb) {
|
||||||
if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
|
if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
|
||||||
cb = transOrDb;
|
cb = transOrDb;
|
||||||
|
@ -372,8 +522,9 @@ module.exports = class User {
|
||||||
}
|
}
|
||||||
|
|
||||||
getAge() {
|
getAge() {
|
||||||
if(_.has(this.properties, 'birthdate')) {
|
const birthdate = this.getProperty(UserProps.Birthdate);
|
||||||
return moment().diff(this.properties.birthdate, 'years');
|
if(birthdate) {
|
||||||
|
return moment().diff(birthdate, 'years');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,20 +15,30 @@ const {
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
exports.userLogin = userLogin;
|
exports.userLogin = userLogin;
|
||||||
|
|
||||||
function userLogin(client, username, password, cb) {
|
function userLogin(client, username, password, cb) {
|
||||||
client.user.authenticate(username, password, function authenticated(err) {
|
client.user.authenticate(username, password, err => {
|
||||||
|
const config = Config();
|
||||||
|
|
||||||
if(err) {
|
if(err) {
|
||||||
client.log.info( { username : username, error : err.message }, 'Failed login attempt');
|
client.log.info( { username : username, error : err.message }, 'Failed login attempt');
|
||||||
|
|
||||||
// :TODO: if username exists, record failed login attempt to properties
|
client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1;
|
||||||
// :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true
|
const disconnect = config.users.failedLogin.disconnect;
|
||||||
|
if(disconnect > 0 && client.user.sessionFailedLoginAttempts >= disconnect) {
|
||||||
|
return cb(Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany));
|
||||||
|
}
|
||||||
|
|
||||||
return cb(err);
|
return cb(err);
|
||||||
}
|
}
|
||||||
const user = client.user;
|
|
||||||
|
const user = client.user;
|
||||||
|
|
||||||
|
// Good login; reset any failed attempts
|
||||||
|
delete user.sessionFailedLoginAttempts;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Ensure this user is not already logged in.
|
// Ensure this user is not already logged in.
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
/* jslint node: true */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Common user properties used throughout the system.
|
||||||
|
//
|
||||||
|
// This IS NOT a full list. For example, custom modules
|
||||||
|
// can utilize their own properties as well!
|
||||||
|
//
|
||||||
|
module.exports = {
|
||||||
|
PassPbkdf2Salt : 'pw_pbkdf2_salt',
|
||||||
|
PassPbkdf2Dk : 'pw_pbkdf2_dk',
|
||||||
|
|
||||||
|
AccountStatus : 'account_status',
|
||||||
|
|
||||||
|
Birthdate : 'birthdate',
|
||||||
|
|
||||||
|
FailedLoginAttempts : 'failed_login_attempts',
|
||||||
|
AccountLockedTs : 'account_locked_timestamp',
|
||||||
|
AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status
|
||||||
|
|
||||||
|
EmailPwResetToken : 'email_password_reset_token',
|
||||||
|
EmailPwResetTokenTs : 'email_password_reset_token_ts',
|
||||||
|
};
|
||||||
|
|
|
@ -10,6 +10,7 @@ const User = require('./user.js');
|
||||||
const userDb = require('./database.js').dbs.user;
|
const userDb = require('./database.js').dbs.user;
|
||||||
const getISOTimestampString = require('./database.js').getISOTimestampString;
|
const getISOTimestampString = require('./database.js').getISOTimestampString;
|
||||||
const Log = require('./logger.js').log;
|
const Log = require('./logger.js').log;
|
||||||
|
const UserProps = require('./user_property.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
|
@ -17,6 +18,7 @@ const crypto = require('crypto');
|
||||||
const fs = require('graceful-fs');
|
const fs = require('graceful-fs');
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
const querystring = require('querystring');
|
const querystring = require('querystring');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT =
|
const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT =
|
||||||
`%USERNAME%:
|
`%USERNAME%:
|
||||||
|
@ -283,8 +285,11 @@ class WebPasswordReset {
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete assoc properties - no need to wait for completion
|
// delete assoc properties - no need to wait for completion
|
||||||
user.removeProperty('email_password_reset_token');
|
user.removeProperties([ UserProps.EmailPwResetToken, UserProps.EmailPwResetTokenTs ]);
|
||||||
user.removeProperty('email_password_reset_token_ts');
|
|
||||||
|
if(true === _.get(config, 'users.unlockAtEmailPwReset')) {
|
||||||
|
user.unlockAccount( () => { /* dummy */ } );
|
||||||
|
}
|
||||||
|
|
||||||
resp.writeHead(200);
|
resp.writeHead(200);
|
||||||
return resp.end('Password changed successfully');
|
return resp.end('Password changed successfully');
|
||||||
|
|
|
@ -2,16 +2,18 @@
|
||||||
layout: page
|
layout: page
|
||||||
title: Email
|
title: Email
|
||||||
---
|
---
|
||||||
ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid SMTP
|
## Email Support
|
||||||
config in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %})
|
ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid [Nodemailer](https://nodemailer.com/about/) compatible `email` block in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}). Nodemailer supports SMTP in addition to many pre-defined services for ease of use. The `transport` block within `email` must be Nodemailer compatible.
|
||||||
|
|
||||||
## SMTP Services
|
Additional email support will come in the near future.
|
||||||
|
|
||||||
If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) provide a reliable free
|
## Services
|
||||||
service.
|
|
||||||
|
|
||||||
## Example SMTP Configuration
|
If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) and [Zoho](https://www.zoho.com/mail/) both provide reliable and free services.
|
||||||
|
|
||||||
|
## Example Configurations
|
||||||
|
|
||||||
|
Example 1 - SMTP:
|
||||||
```hjson
|
```hjson
|
||||||
email: {
|
email: {
|
||||||
defaultFrom: sysop@bbs.awesome.com
|
defaultFrom: sysop@bbs.awesome.com
|
||||||
|
@ -27,3 +29,21 @@ email: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example 2 - Zoho
|
||||||
|
```hjson
|
||||||
|
email: {
|
||||||
|
defaultFrom: sysop@bbs.awesome.com
|
||||||
|
|
||||||
|
transport: {
|
||||||
|
service: Zoho
|
||||||
|
auth: {
|
||||||
|
user: noreply@bbs.awesome.com
|
||||||
|
pass: yuspymypass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lockout Reset
|
||||||
|
If email is available on your system and you allow email-driven password resets, you may elect to allow unlocking accounts at the time of a password reset. This is controlled by the `users.unlockAtEmailPwReset` configuration option. If an account is locked due to too many failed login attempts, a user may reset their password to remedy the situation themselves.
|
||||||
|
|
|
@ -352,6 +352,23 @@
|
||||||
// Usernames reserved for applying to your system
|
// Usernames reserved for applying to your system
|
||||||
newUserNames: []
|
newUserNames: []
|
||||||
|
|
||||||
|
// Handling of failed logins
|
||||||
|
failedLogin : {
|
||||||
|
// disconnect after N failed attempts. 0=disabled.
|
||||||
|
disconnect : XXXXX
|
||||||
|
|
||||||
|
// Lock the user out after N failed attempts. 0=disabled.
|
||||||
|
lockAccount : XXXXX
|
||||||
|
|
||||||
|
//
|
||||||
|
// If locked out, how long until the user can login again?
|
||||||
|
// Set to 0 to disable auto-unlock
|
||||||
|
//
|
||||||
|
autoUnlockMinutes : XXXXX
|
||||||
|
},
|
||||||
|
|
||||||
|
// Allow email driven password resets to unlock accounts?
|
||||||
|
unlockAtEmailPwReset : XXXXX
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive files and related
|
// Archive files and related
|
||||||
|
|
|
@ -145,6 +145,9 @@
|
||||||
next: fullLoginSequenceLoginArt
|
next: fullLoginSequenceLoginArt
|
||||||
config: {
|
config: {
|
||||||
tooNodeMenu: loginAttemptTooNode
|
tooNodeMenu: loginAttemptTooNode
|
||||||
|
inactive: loginAttemptAccountInactive
|
||||||
|
disabled: loginAttemptAccountDisabled
|
||||||
|
locked: loginAttemptAccountLocked
|
||||||
}
|
}
|
||||||
form: {
|
form: {
|
||||||
0: {
|
0: {
|
||||||
|
@ -188,6 +191,33 @@
|
||||||
next: logoff
|
next: logoff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginAttemptAccountLocked: {
|
||||||
|
art: ACCOUNTLOCKED
|
||||||
|
config: {
|
||||||
|
cls: true
|
||||||
|
nextTimeout: 2000
|
||||||
|
}
|
||||||
|
next: logoff
|
||||||
|
}
|
||||||
|
|
||||||
|
loginAttemptAccountDisabled: {
|
||||||
|
art: ACCOUNTDISABLED
|
||||||
|
config: {
|
||||||
|
cls: true
|
||||||
|
nextTimeout: 2000
|
||||||
|
}
|
||||||
|
next: logoff
|
||||||
|
}
|
||||||
|
|
||||||
|
loginAttemptAccountInactive: {
|
||||||
|
art: ACCOUNTINACTIVE
|
||||||
|
config: {
|
||||||
|
cls: true
|
||||||
|
nextTimeout: 2000
|
||||||
|
}
|
||||||
|
next: logoff
|
||||||
|
}
|
||||||
|
|
||||||
forgotPassword: {
|
forgotPassword: {
|
||||||
desc: Forgot password
|
desc: Forgot password
|
||||||
prompt: forgotPasswordPrompt
|
prompt: forgotPasswordPrompt
|
||||||
|
|
Loading…
Reference in New Issue