diff --git a/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS b/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS new file mode 100644 index 00000000..d0d2cd0e Binary files /dev/null and b/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS differ diff --git a/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS b/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS new file mode 100644 index 00000000..b743a3cf Binary files /dev/null and b/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS differ diff --git a/core/config.js b/core/config.js index 3799d538..701c8494 100644 --- a/core/config.js +++ b/core/config.js @@ -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 : { diff --git a/core/enig_error.js b/core/enig_error.js index 01e62c40..78798a4a 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -46,4 +46,8 @@ exports.ErrorReasons = { NoConditionMatch : 'NOCONDMATCH', NotEnabled : 'NOTENABLED', AlreadyLoggedIn : 'ALREADYLOGGEDIN', -}; \ No newline at end of file + TooMany : 'TOOMANY', + Disabled : 'DISABLED', + Inactive : 'INACTIVE', + Locked : 'LOCKED', +}; diff --git a/core/oputil/oputil_config.js b/core/oputil/oputil_config.js index 83ac5232..52c388f7 100644 --- a/core/oputil/oputil_config.js +++ b/core/oputil/oputil_config.js @@ -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', diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index 273bd787..4d7931e0 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -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 `, diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js index 60d3888d..d1f21408 100644 --- a/core/oputil/oputil_user.js +++ b/core/oputil/oputil_user.js @@ -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); }); } \ No newline at end of file diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index 5f0ff05f..f626cb79 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -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(); } diff --git a/core/stat_log.js b/core/stat_log.js index 9e655999..4ee4c53b 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -68,6 +68,7 @@ class StatLog { }; } + // :TODO: fix spelling :) setNonPeristentSystemStat(statName, statValue) { this.systemStats[statName] = statValue; } diff --git a/core/system_menu_method.js b/core/system_menu_method.js index 8485a90f..7c135f22 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -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); } diff --git a/core/tic_file_info.js b/core/tic_file_info.js index 5db24cc2..fd3c7572 100644 --- a/core/tic_file_info.js +++ b/core/tic_file_info.js @@ -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 diff --git a/core/user.js b/core/user.js index 27f4775a..a712829c 100644 --- a/core/user.js +++ b/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'); } } diff --git a/core/user_login.js b/core/user_login.js index d08788f3..be2a99a1 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -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. diff --git a/core/user_property.js b/core/user_property.js new file mode 100644 index 00000000..04f8f0c9 --- /dev/null +++ b/core/user_property.js @@ -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', +}; + diff --git a/core/web_password_reset.js b/core/web_password_reset.js index 6ca916da..06fd7838 100644 --- a/core/web_password_reset.js +++ b/core/web_password_reset.js @@ -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'); diff --git a/docs/configuration/email.md b/docs/configuration/email.md index 5bc4d4c8..eb13ef71 100644 --- a/docs/configuration/email.md +++ b/docs/configuration/email.md @@ -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. diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson index ab1b37b8..489a7cb3 100644 --- a/misc/config_template.in.hjson +++ b/misc/config_template.in.hjson @@ -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 diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 7a0fbcfa..6e1d889d 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -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