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{}?
|
||||
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
|
||||
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
|
||||
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 : {
|
||||
|
|
|
@ -46,4 +46,8 @@ exports.ErrorReasons = {
|
|||
NoConditionMatch : 'NOCONDMATCH',
|
||||
NotEnabled : 'NOTENABLED',
|
||||
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
|
||||
};
|
||||
TooMany : 'TOOMANY',
|
||||
Disabled : 'DISABLED',
|
||||
Inactive : 'INACTIVE',
|
||||
Locked : 'LOCKED',
|
||||
};
|
||||
|
|
|
@ -38,7 +38,7 @@ function getAnswers(questions, cb) {
|
|||
const ConfigIncludeKeys = [
|
||||
'theme',
|
||||
'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds',
|
||||
'users.newUserNames',
|
||||
'users.newUserNames', 'users.failedLogin', 'users.unlockAtEmailPwReset',
|
||||
'paths.logs',
|
||||
'loginServers',
|
||||
'contentServers',
|
||||
|
|
|
@ -30,6 +30,7 @@ actions:
|
|||
activate USERNAME sets USERNAME's status to active
|
||||
deactivate USERNAME sets USERNAME's status to inactive
|
||||
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
|
||||
`,
|
||||
|
||||
|
|
|
@ -55,6 +55,14 @@ function setAccountStatus(user, status) {
|
|||
}
|
||||
|
||||
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];
|
||||
user.persistProperty('account_status', status, 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 errUsage() {
|
||||
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
|
||||
|
@ -195,11 +188,12 @@ function handleUserCommand() {
|
|||
del : removeUser,
|
||||
delete : removeUser,
|
||||
|
||||
activate : activateUser,
|
||||
deactivate : deactivateUser,
|
||||
disable : disableUser,
|
||||
activate : setAccountStatus,
|
||||
deactivate : setAccountStatus,
|
||||
disable : setAccountStatus,
|
||||
lock : setAccountStatus,
|
||||
|
||||
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 theme = require('../../theme.js');
|
||||
const stringFormat = require('../../string_format.js');
|
||||
const { ErrorReasons } = require('../../enig_error.js');
|
||||
const {
|
||||
Errors,
|
||||
ErrorReasons
|
||||
} = require('../../enig_error.js');
|
||||
|
||||
// deps
|
||||
const ssh2 = require('ssh2');
|
||||
|
@ -37,8 +40,6 @@ function SSHClient(clientConn) {
|
|||
|
||||
const self = this;
|
||||
|
||||
let loginAttempts = 0;
|
||||
|
||||
clientConn.on('authentication', function authAttempt(ctx) {
|
||||
const username = ctx.username || '';
|
||||
const password = ctx.password || '';
|
||||
|
@ -53,26 +54,56 @@ function SSHClient(clientConn) {
|
|||
return clientConn.end();
|
||||
}
|
||||
|
||||
function alreadyLoggedIn(username) {
|
||||
ctx.prompt(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`);
|
||||
function promptAndTerm(msg) {
|
||||
if('keyboard-interactive' === ctx.method) {
|
||||
ctx.prompt(msg);
|
||||
}
|
||||
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
|
||||
// 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) {
|
||||
return ctx.accept();
|
||||
}
|
||||
|
||||
if(username.length > 0 && password.length > 0) {
|
||||
loginAttempts += 1;
|
||||
|
||||
userLogin(self, ctx.username, ctx.password, function authResult(err) {
|
||||
if(err) {
|
||||
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode) {
|
||||
return alreadyLoggedIn(username);
|
||||
if(isSpecialHandleError(err)) {
|
||||
return handleSpecialError(err, username);
|
||||
}
|
||||
|
||||
return ctx.reject(SSHClient.ValidAuthMethods);
|
||||
|
@ -93,15 +124,13 @@ function SSHClient(clientConn) {
|
|||
const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false };
|
||||
|
||||
ctx.prompt(interactivePrompt, function retryPrompt(answers) {
|
||||
loginAttempts += 1;
|
||||
|
||||
userLogin(self, username, (answers[0] || ''), err => {
|
||||
if(err) {
|
||||
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode) {
|
||||
return alreadyLoggedIn(username);
|
||||
if(isSpecialHandleError(err)) {
|
||||
return handleSpecialError(err, username);
|
||||
}
|
||||
|
||||
if(loginAttempts >= config.general.loginAttempts) {
|
||||
if(Errors.BadLogin().code === err.code) {
|
||||
return terminateConnection();
|
||||
}
|
||||
|
||||
|
|
|
@ -68,6 +68,7 @@ class StatLog {
|
|||
};
|
||||
}
|
||||
|
||||
// :TODO: fix spelling :)
|
||||
setNonPeristentSystemStat(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 => {
|
||||
if(err) {
|
||||
// login failure
|
||||
// already logged in with this user?
|
||||
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode &&
|
||||
_.has(callingMenu, 'menuConfig.config.tooNodeMenu'))
|
||||
{
|
||||
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
|
||||
return callingMenu.prevMenu(cb);
|
||||
}
|
||||
|
|
|
@ -187,10 +187,10 @@ module.exports = class TicFileInfo {
|
|||
// send the file to be distributed and the accompanying TIC file.
|
||||
// Some File processors (Allfix) only insert a line with this
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Example: To 292/854
|
||||
|
|
247
core/user.js
247
core/user.js
|
@ -1,11 +1,18 @@
|
|||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const userDb = require('./database.js').dbs.user;
|
||||
const Config = require('./config.js').get;
|
||||
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 UserProps = require('./user_property.js');
|
||||
const Log = require('./logger.js').log;
|
||||
const StatLog = require('./stat_log.js');
|
||||
|
||||
// deps
|
||||
const crypto = require('crypto');
|
||||
|
@ -39,7 +46,7 @@ module.exports = class User {
|
|||
|
||||
static get StandardPropertyGroups() {
|
||||
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() {
|
||||
return true === this.authenticated;
|
||||
}
|
||||
|
@ -61,16 +80,21 @@ module.exports = class User {
|
|||
return false;
|
||||
}
|
||||
|
||||
return this.hasValidPassword();
|
||||
return this.hasValidPasswordProperties();
|
||||
}
|
||||
|
||||
hasValidPassword() {
|
||||
if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) {
|
||||
hasValidPasswordProperties() {
|
||||
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 ((this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2) &&
|
||||
(this.properties.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2));
|
||||
return true;
|
||||
}
|
||||
|
||||
isRoot() {
|
||||
|
@ -102,24 +126,77 @@ module.exports = class User {
|
|||
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) {
|
||||
const self = this;
|
||||
const cachedInfo = {};
|
||||
const tempAuthInfo = {};
|
||||
|
||||
async.waterfall(
|
||||
[
|
||||
function fetchUserId(callback) {
|
||||
// get user ID
|
||||
User.getUserIdAndName(username, (err, uid, un) => {
|
||||
cachedInfo.userId = uid;
|
||||
cachedInfo.username = un;
|
||||
tempAuthInfo.userId = uid;
|
||||
tempAuthInfo.username = un;
|
||||
|
||||
return callback(err);
|
||||
});
|
||||
},
|
||||
function getRequiredAuthProperties(callback) {
|
||||
// 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);
|
||||
});
|
||||
},
|
||||
|
@ -136,30 +213,53 @@ module.exports = class User {
|
|||
const passDkBuf = Buffer.from(passDk, 'hex');
|
||||
const propsDkBuf = Buffer.from(propsDk, 'hex');
|
||||
|
||||
if(passDkBuf.length !== propsDkBuf.length) {
|
||||
return callback(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'));
|
||||
return callback(User.isSamePasswordSlowCompare(passDkBuf, propsDkBuf) ?
|
||||
null :
|
||||
Errors.AccessDenied('Invalid password')
|
||||
);
|
||||
},
|
||||
function initProps(callback) {
|
||||
User.loadProperties(cachedInfo.userId, (err, allProps) => {
|
||||
User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
|
||||
if(!err) {
|
||||
cachedInfo.properties = allProps;
|
||||
tempAuthInfo.properties = allProps;
|
||||
}
|
||||
|
||||
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) {
|
||||
userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => {
|
||||
userGroup.getGroupsForUser(tempAuthInfo.userId, (err, groups) => {
|
||||
if(!err) {
|
||||
cachedInfo.groups = groups;
|
||||
tempAuthInfo.groups = groups;
|
||||
}
|
||||
|
||||
return callback(err);
|
||||
|
@ -167,15 +267,44 @@ module.exports = class User {
|
|||
}
|
||||
],
|
||||
err => {
|
||||
if(!err) {
|
||||
self.userId = cachedInfo.userId;
|
||||
self.username = cachedInfo.username;
|
||||
self.properties = cachedInfo.properties;
|
||||
self.groups = cachedInfo.groups;
|
||||
if(err) {
|
||||
//
|
||||
// If we failed login due to something besides an inactive or disabled account,
|
||||
// we need to update failure status and possibly lock the account.
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// :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(
|
||||
[
|
||||
|
@ -212,7 +341,7 @@ module.exports = class User {
|
|||
|
||||
// Do not require activation for userId 1 (root/admin)
|
||||
if(User.RootUserID === self.userId) {
|
||||
self.properties.account_status = User.AccountStatus.active;
|
||||
self.properties[UserProps.AccountStatus] = User.AccountStatus.active;
|
||||
}
|
||||
|
||||
return callback(null, trans);
|
||||
|
@ -225,8 +354,8 @@ module.exports = class User {
|
|||
return callback(err);
|
||||
}
|
||||
|
||||
self.properties.pw_pbkdf2_salt = info.salt;
|
||||
self.properties.pw_pbkdf2_dk = info.dk;
|
||||
self.properties[UserProps.PassPbkdf2Salt] = info.salt;
|
||||
self.properties[UserProps.PassPbkdf2Dk] = info.dk;
|
||||
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) {
|
||||
// update live props
|
||||
this.properties[propName] = propValue;
|
||||
|
||||
userDb.run(
|
||||
`REPLACE INTO user_property (user_id, prop_name, prop_value)
|
||||
VALUES (?, ?, ?);`,
|
||||
[ this.userId, propName, propValue ],
|
||||
err => {
|
||||
if(cb) {
|
||||
return cb(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
return User.persistPropertyByUserId(this.userId, propName, propValue, 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) {
|
||||
if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
|
||||
cb = transOrDb;
|
||||
|
@ -372,8 +522,9 @@ module.exports = class User {
|
|||
}
|
||||
|
||||
getAge() {
|
||||
if(_.has(this.properties, 'birthdate')) {
|
||||
return moment().diff(this.properties.birthdate, 'years');
|
||||
const birthdate = this.getProperty(UserProps.Birthdate);
|
||||
if(birthdate) {
|
||||
return moment().diff(birthdate, 'years');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,20 +15,30 @@ const {
|
|||
|
||||
// deps
|
||||
const async = require('async');
|
||||
const _ = require('lodash');
|
||||
|
||||
exports.userLogin = userLogin;
|
||||
|
||||
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) {
|
||||
client.log.info( { username : username, error : err.message }, 'Failed login attempt');
|
||||
|
||||
// :TODO: if username exists, record failed login attempt to properties
|
||||
// :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true
|
||||
client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1;
|
||||
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);
|
||||
}
|
||||
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.
|
||||
|
|
|
@ -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 getISOTimestampString = require('./database.js').getISOTimestampString;
|
||||
const Log = require('./logger.js').log;
|
||||
const UserProps = require('./user_property.js');
|
||||
|
||||
// deps
|
||||
const async = require('async');
|
||||
|
@ -17,6 +18,7 @@ const crypto = require('crypto');
|
|||
const fs = require('graceful-fs');
|
||||
const url = require('url');
|
||||
const querystring = require('querystring');
|
||||
const _ = require('lodash');
|
||||
|
||||
const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT =
|
||||
`%USERNAME%:
|
||||
|
@ -283,8 +285,11 @@ class WebPasswordReset {
|
|||
}
|
||||
|
||||
// delete assoc properties - no need to wait for completion
|
||||
user.removeProperty('email_password_reset_token');
|
||||
user.removeProperty('email_password_reset_token_ts');
|
||||
user.removeProperties([ UserProps.EmailPwResetToken, UserProps.EmailPwResetTokenTs ]);
|
||||
|
||||
if(true === _.get(config, 'users.unlockAtEmailPwReset')) {
|
||||
user.unlockAccount( () => { /* dummy */ } );
|
||||
}
|
||||
|
||||
resp.writeHead(200);
|
||||
return resp.end('Password changed successfully');
|
||||
|
|
|
@ -2,16 +2,18 @@
|
|||
layout: page
|
||||
title: Email
|
||||
---
|
||||
ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid SMTP
|
||||
config in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %})
|
||||
## Email Support
|
||||
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
|
||||
service.
|
||||
## Services
|
||||
|
||||
## 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
|
||||
email: {
|
||||
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
|
||||
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
|
||||
|
|
|
@ -145,6 +145,9 @@
|
|||
next: fullLoginSequenceLoginArt
|
||||
config: {
|
||||
tooNodeMenu: loginAttemptTooNode
|
||||
inactive: loginAttemptAccountInactive
|
||||
disabled: loginAttemptAccountDisabled
|
||||
locked: loginAttemptAccountLocked
|
||||
}
|
||||
form: {
|
||||
0: {
|
||||
|
@ -188,6 +191,33 @@
|
|||
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: {
|
||||
desc: Forgot password
|
||||
prompt: forgotPasswordPrompt
|
||||
|
|
Loading…
Reference in New Issue