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{}?
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 : {

View File

@ -46,4 +46,8 @@ exports.ErrorReasons = {
NoConditionMatch : 'NOCONDMATCH',
NotEnabled : 'NOTENABLED',
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
TooMany : 'TOOMANY',
Disabled : 'DISABLED',
Inactive : 'INACTIVE',
Locked : 'LOCKED',
};

View File

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

View File

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

View File

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

View File

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

View File

@ -68,6 +68,7 @@ class StatLog {
};
}
// :TODO: fix spelling :)
setNonPeristentSystemStat(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 => {
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);
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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