mirror of
https://github.com/NuSkooler/enigma-bbs.git
synced 2025-07-28 05:26:10 +02:00
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
18 changed files with 401 additions and 100 deletions
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue