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:
Bryan Ashby 2018-11-22 23:07:37 -07:00
parent f18b023652
commit df2bf4477e
18 changed files with 401 additions and 100 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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 : {

View File

@ -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',
};

View File

@ -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',

View File

@ -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
`, `,

View File

@ -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);
}); });
} }

View File

@ -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();
} }

View File

@ -68,6 +68,7 @@ class StatLog {
}; };
} }
// :TODO: fix spelling :)
setNonPeristentSystemStat(statName, statValue) { setNonPeristentSystemStat(statName, statValue) {
this.systemStats[statName] = statValue; this.systemStats[statName] = statValue;
} }

View File

@ -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);
} }

View File

@ -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

View File

@ -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');
} }
} }

View File

@ -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.

25
core/user_property.js Normal file
View File

@ -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',
};

View File

@ -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');

View File

@ -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.

View File

@ -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

View File

@ -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