/* jslint node: true */ 'use strict'; 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 Events = require('./events.js'); // deps const crypto = require('crypto'); const assert = require('assert'); const async = require('async'); const _ = require('lodash'); const moment = require('moment'); exports.isRootUserId = function(id) { return 1 === id; }; module.exports = class User { constructor() { this.userId = 0; this.username = ''; this.properties = {}; // name:value this.groups = []; // group membership(s) } // static property accessors static get RootUserID() { return 1; } static get PBKDF2() { return { iterations : 1000, keyLen : 128, saltLen : 32, }; } static get StandardPropertyGroups() { return { password : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ], }; } static get AccountStatus() { return { disabled : 0, inactive : 1, active : 2, }; } isAuthenticated() { return true === this.authenticated; } isValid() { if(this.userId <= 0 || this.username.length < Config().users.usernameMin) { return false; } return this.hasValidPassword(); } hasValidPassword() { if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) { return false; } return ((this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2) && (this.properties.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2)); } isRoot() { return User.isRootUserId(this.userId); } isSysOp() { // alias to isRoot() return this.isRoot(); } isGroupMember(groupNames) { if(_.isString(groupNames)) { groupNames = [ groupNames ]; } const isMember = groupNames.some(gn => (-1 !== this.groups.indexOf(gn))); return isMember; } getLegacySecurityLevel() { if(this.isRoot() || this.isGroupMember('sysops')) { return 100; } if(this.isGroupMember('users')) { return 30; } return 10; // :TODO: Is this what we want? } authenticate(username, password, cb) { const self = this; const cachedInfo = {}; async.waterfall( [ function fetchUserId(callback) { // get user ID User.getUserIdAndName(username, (err, uid, un) => { cachedInfo.userId = uid; cachedInfo.username = un; return callback(err); }); }, function getRequiredAuthProperties(callback) { // fetch properties required for authentication User.loadProperties(cachedInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => { return callback(err, props); }); }, function getDkWithSalt(props, callback) { // get DK from stored salt and password provided User.generatePasswordDerivedKey(password, props.pw_pbkdf2_salt, (err, dk) => { return callback(err, dk, props.pw_pbkdf2_dk); }); }, function validateAuth(passDk, propsDk, callback) { // // Use constant time comparison here for security feel-goods // 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')); }, function initProps(callback) { User.loadProperties(cachedInfo.userId, (err, allProps) => { if(!err) { cachedInfo.properties = allProps; } return callback(err); }); }, function initGroups(callback) { userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => { if(!err) { cachedInfo.groups = groups; } return callback(err); }); } ], err => { if(!err) { self.userId = cachedInfo.userId; self.username = cachedInfo.username; self.properties = cachedInfo.properties; self.groups = cachedInfo.groups; self.authenticated = true; } return cb(err); } ); } create(createUserInfo , cb) { assert(0 === this.userId); const config = Config(); if(this.username.length < config.users.usernameMin || this.username.length > config.users.usernameMax) { return cb(Errors.Invalid('Invalid username length')); } 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; async.waterfall( [ function beginTransaction(callback) { return userDb.beginTransaction(callback); }, function createUserRec(trans, callback) { trans.run( `INSERT INTO user (user_name) VALUES (?);`, [ self.username ], function inserted(err) { // use classic function for |this| if(err) { return callback(err); } self.userId = this.lastID; // Do not require activation for userId 1 (root/admin) if(User.RootUserID === self.userId) { self.properties.account_status = User.AccountStatus.active; } return callback(null, trans); } ); }, function genAuthCredentials(trans, callback) { User.generatePasswordDerivedKeyAndSalt(createUserInfo.password, (err, info) => { if(err) { return callback(err); } self.properties.pw_pbkdf2_salt = info.salt; self.properties.pw_pbkdf2_dk = info.dk; return callback(null, trans); }); }, function setInitialGroupMembership(trans, callback) { self.groups = config.users.defaultGroups; if(User.RootUserID === self.userId) { // root/SysOp? self.groups.push('sysops'); } return callback(null, trans); }, function saveAll(trans, callback) { self.persistWithTransaction(trans, err => { return callback(err, trans); }); }, function sendEvent(trans, callback) { Events.emit( Events.getSystemEvents().NewUser, { user : Object.assign({}, self, { sessionId : createUserInfo.sessionId } ) } ); return callback(null, trans); } ], (err, trans) => { if(trans) { trans[err ? 'rollback' : 'commit'](transErr => { return cb(err ? err : transErr); }); } else { return cb(err); } } ); } persistWithTransaction(trans, cb) { assert(this.userId > 0); const self = this; async.series( [ function saveProps(callback) { self.persistProperties(self.properties, trans, err => { return callback(err); }); }, function saveGroups(callback) { userGroup.addUserToGroups(self.userId, self.groups, trans, err => { return callback(err); }); } ], err => { return cb(err); } ); } 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); } } ); } removeProperty(propName, cb) { // update live delete this.properties[propName]; userDb.run( `DELETE FROM user_property WHERE user_id = ? AND prop_name = ?;`, [ this.userId, propName ], err => { if(cb) { return cb(err); } } ); } persistProperties(properties, transOrDb, cb) { if(!_.isFunction(cb) && _.isFunction(transOrDb)) { cb = transOrDb; transOrDb = userDb; } const self = this; // update live props _.merge(this.properties, properties); const stmt = transOrDb.prepare( `REPLACE INTO user_property (user_id, prop_name, prop_value) VALUES (?, ?, ?);` ); async.each(Object.keys(properties), (propName, nextProp) => { stmt.run(self.userId, propName, properties[propName], err => { return nextProp(err); }); }, err => { if(err) { return cb(err); } stmt.finalize( () => { return cb(null); }); }); } setNewAuthCredentials(password, cb) { User.generatePasswordDerivedKeyAndSalt(password, (err, info) => { if(err) { return cb(err); } const newProperties = { pw_pbkdf2_salt : info.salt, pw_pbkdf2_dk : info.dk, }; this.persistProperties(newProperties, err => { return cb(err); }); }); } getAge() { if(_.has(this.properties, 'birthdate')) { return moment().diff(this.properties.birthdate, 'years'); } } static getUser(userId, cb) { async.waterfall( [ function fetchUserId(callback) { User.getUserName(userId, (err, userName) => { return callback(null, userName); }); }, function initProps(userName, callback) { User.loadProperties(userId, (err, properties) => { return callback(err, userName, properties); }); }, function initGroups(userName, properties, callback) { userGroup.getGroupsForUser(userId, (err, groups) => { return callback(null, userName, properties, groups); }); } ], (err, userName, properties, groups) => { const user = new User(); user.userId = userId; user.username = userName; user.properties = properties; user.groups = groups; user.authenticated = false; // this is NOT an authenticated user! return cb(err, user); } ); } static isRootUserId(userId) { return (User.RootUserID === userId); } static getUserIdAndName(username, cb) { userDb.get( `SELECT id, user_name FROM user WHERE user_name LIKE ?;`, [ username ], (err, row) => { if(err) { return cb(err); } if(row) { return cb(null, row.id, row.user_name); } return cb(Errors.DoesNotExist('No matching username')); } ); } static getUserIdAndNameByRealName(realName, cb) { userDb.get( `SELECT id, user_name FROM user WHERE id = ( SELECT user_id FROM user_property WHERE prop_name='real_name' AND prop_value LIKE ? );`, [ realName ], (err, row) => { if(err) { return cb(err); } if(row) { return cb(null, row.id, row.user_name); } return cb(Errors.DoesNotExist('No matching real name')); } ); } static getUserIdAndNameByLookup(lookup, cb) { User.getUserIdAndName(lookup, (err, userId, userName) => { if(err) { User.getUserIdAndNameByRealName(lookup, (err, userId, userName) => { return cb(err, userId, userName); }); } else { return cb(null, userId, userName); } }); } static getUserName(userId, cb) { userDb.get( `SELECT user_name FROM user WHERE id = ?;`, [ userId ], (err, row) => { if(err) { return cb(err); } if(row) { return cb(null, row.user_name); } return cb(Errors.DoesNotExist('No matching user ID')); } ); } static loadProperties(userId, options, cb) { if(!cb && _.isFunction(options)) { cb = options; options = {}; } let sql = `SELECT prop_name, prop_value FROM user_property WHERE user_id = ?`; if(options.names) { sql += ` AND prop_name IN("${options.names.join('","')}");`; } else { sql += ';'; } let properties = {}; userDb.each(sql, [ userId ], (err, row) => { if(err) { return cb(err); } properties[row.prop_name] = row.prop_value; }, (err) => { return cb(err, err ? null : properties); }); } // :TODO: make this much more flexible - propValue should allow for case-insensitive compare, etc. static getUserIdsWithProperty(propName, propValue, cb) { let userIds = []; userDb.each( `SELECT user_id FROM user_property WHERE prop_name = ? AND prop_value = ?;`, [ propName, propValue ], (err, row) => { if(row) { userIds.push(row.user_id); } }, () => { return cb(null, userIds); } ); } static getUserList(options, cb) { let userList = []; let orderClause = 'ORDER BY ' + (options.order || 'user_name'); userDb.each( `SELECT id, user_name FROM user ${orderClause};`, (err, row) => { if(row) { userList.push({ userId : row.id, userName : row.user_name, }); } }, () => { options.properties = options.properties || []; async.map(userList, (user, nextUser) => { userDb.each( `SELECT prop_name, prop_value FROM user_property WHERE user_id = ? AND prop_name IN ("${options.properties.join('","')}");`, [ user.userId ], (err, row) => { if(row) { user[row.prop_name] = row.prop_value; } }, err => { return nextUser(err, user); } ); }, (err, transformed) => { return cb(err, transformed); }); } ); } static generatePasswordDerivedKeyAndSalt(password, cb) { async.waterfall( [ function getSalt(callback) { User.generatePasswordDerivedKeySalt( (err, salt) => { return callback(err, salt); }); }, function getDk(salt, callback) { User.generatePasswordDerivedKey(password, salt, (err, dk) => { return callback(err, salt, dk); }); } ], (err, salt, dk) => { return cb(err, { salt : salt, dk : dk } ); } ); } static generatePasswordDerivedKeySalt(cb) { crypto.randomBytes(User.PBKDF2.saltLen, (err, salt) => { if(err) { return cb(err); } return cb(null, salt.toString('hex')); }); } static generatePasswordDerivedKey(password, salt, cb) { password = Buffer.from(password).toString('hex'); crypto.pbkdf2(password, salt, User.PBKDF2.iterations, User.PBKDF2.keyLen, 'sha1', (err, dk) => { if(err) { return cb(err); } return cb(null, dk.toString('hex')); }); } };