/* jslint node: true */ /* eslint-disable no-console */ 'use strict'; const { printUsageAndSetExitCode, getAnswers, ExitCodes, argv, initConfigAndDatabases, } = require('./oputil_common.js'); const getHelpFor = require('./oputil_help.js').getHelpFor; const Errors = require('../enig_error.js').Errors; const UserProps = require('../user_property.js'); // deps const async = require('async'); const _ = require('lodash'); const moment = require('moment'); const fs = require('fs-extra'); const Table = require('easy-table'); exports.handleUserCommand = handleUserCommand; function initAndGetUser(userName, cb) { async.waterfall( [ function init(callback) { initConfigAndDatabases(callback); }, function getUserObject(callback) { const User = require('../../core/user.js'); User.getUserIdAndName(userName, (err, userId) => { if (err) { // try user ID if number was supplied if (_.isNumber(userName)) { return User.getUser(parseInt(userName), callback); } return callback(err); } return User.getUser(userId, callback); }); }, ], (err, user) => { return cb(err, user); } ); } function setAccountStatus(user, status) { if (argv._.length < 3) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } 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]; async.series( [ callback => { return user.persistProperty(UserProps.AccountStatus, status, callback); }, callback => { if (AccountStatus.active !== status) { return callback(null); } return user.unlockAccount(callback); }, ], err => { if (err) { process.exitCode = ExitCodes.ERROR; console.error(err.message); } else { console.info(`User status set to ${statusDesc}`); } } ); } function setUserPassword(user) { if (argv._.length < 4) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } async.waterfall( [ function validate(callback) { // :TODO: prompt if no password provided (more secure, no history, etc.) const password = argv._[argv._.length - 1]; if (0 === password.length) { return callback(Errors.Invalid('Invalid password')); } return callback(null, password); }, function set(password, callback) { user.setNewAuthCredentials(password, err => { if (err) { process.exitCode = ExitCodes.BAD_ARGS; } return callback(err); }); }, ], err => { if (err) { console.error(err.message); } else { console.info('New password set'); } } ); } function removeUserRecordsFromDbAndTable(dbName, tableName, userId, col, cb) { const db = require('../../core/database.js').dbs[dbName]; db.run( `DELETE FROM ${tableName} WHERE ${col} = ?;`, [userId], err => { return cb(err); } ); } function removeUser(user) { async.series( [ callback => { if (user.isRoot()) { return callback(Errors.Invalid('Cannot delete root/SysOp user!')); } return callback(null); }, callback => { if (false === argv.prompt) { return callback(null); } console.info('About to permanently delete the following user:'); console.info(`Username : ${user.username}`); console.info( `Real name: ${user.properties[UserProps.RealName] || 'N/A'}` ); console.info(`User ID : ${user.userId}`); console.info('WARNING: This cannot be undone!'); getAnswers( [ { name: 'proceed', message: `Proceed in deleting ${user.username}?`, type: 'confirm', }, ], answers => { if (answers.proceed) { return callback(null); } return callback(Errors.General('User canceled')); } ); }, callback => { // op has confirmed they are wanting ready to proceed (or passed --no-prompt) const DeleteFrom = { message: ['user_message_area_last_read'], system: ['user_event_log'], user: ['user_group_member', 'user'], file: ['file_user_rating'], }; async.eachSeries( Object.keys(DeleteFrom), (dbName, nextDbName) => { const tables = DeleteFrom[dbName]; async.eachSeries( tables, (tableName, nextTableName) => { const col = 'user' === dbName && 'user' === tableName ? 'id' : 'user_id'; removeUserRecordsFromDbAndTable( dbName, tableName, user.userId, col, err => { return nextTableName(err); } ); }, err => { return nextDbName(err); } ); }, err => { return callback(err); } ); }, callback => { // // Clean up *private* messages *to* this user // const Message = require('../../core/message.js'); const MsgDb = require('../../core/database.js').dbs.message; const filter = { resultType: 'id', privateTagUserId: user.userId, }; Message.findMessages(filter, (err, ids) => { if (err) { return callback(err); } async.eachSeries( ids, (messageId, nextMessageId) => { MsgDb.run( `DELETE FROM message WHERE message_id = ?;`, [messageId], err => { return nextMessageId(err); } ); }, err => { return callback(err); } ); }); }, ], err => { if (err) { return console.error(err.reason ? err.reason : err.message); } console.info('User has been deleted.'); } ); } function renameUser(user) { if (argv._.length < 3) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } const newUserName = argv._[argv._.length - 1]; async.series( [ callback => { const { validateUserNameAvail, } = require('../../core/system_view_validate.js'); return validateUserNameAvail(newUserName, callback); }, callback => { const userDb = require('../../core/database.js').dbs.user; userDb.run( `UPDATE user SET user_name = ? WHERE id = ?;`, [newUserName, user.userId], err => { return callback(err); } ); }, ], err => { if (err) { return console.error(err.reason ? err.reason : err.message); } return console.info(`User "${user.username}" renamed to "${newUserName}"`); } ); } function modUserGroups(user) { if (argv._.length < 3) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } let groupName = argv._[argv._.length - 1].toString().replace(/["']/g, ''); // remove any quotes - necessary to allow "-foo" let action = groupName[0]; // + or - if ('-' === action || '+' === action || '~' === action) { groupName = groupName.substr(1); } action = action || '+'; if (0 === groupName.length) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } // // Groups are currently arbitrary, so do a slight validation // if (!/[A-Za-z0-9]+/.test(groupName)) { process.exitCode = ExitCodes.BAD_ARGS; return console.error('Bad group name'); } function done(err) { if (err) { process.exitCode = ExitCodes.BAD_ARGS; console.error(err.message); } else { console.info('User groups modified'); } } const UserGroup = require('../../core/user_group.js'); if ('-' === action || '~' === action) { UserGroup.removeUserFromGroup(user.userId, groupName, done); } else { UserGroup.addUserToGroup(user.userId, groupName, done); } } function showUserInfo(user) { const User = require('../user'); const ActivityPubSettings = require('../activitypub/settings'); const { OTPTypes } = require('../user_2fa_otp'); const statusDesc = () => { const status = user.properties[UserProps.AccountStatus]; return _.invert(User.AccountStatus)[status] || 'N/A'; }; const created = () => { const ac = user.properties[UserProps.AccountCreated]; return ac ? moment(ac).format() : 'N/A'; }; const lastLogin = () => { const ll = user.properties[UserProps.LastLoginTs]; return ll ? moment(ll).format() : 'N/A'; }; const propOrNA = p => { return user.properties[p] || 'N/A'; }; const currentTheme = () => { return user.properties[UserProps.ThemeId]; }; const apSettings = ActivityPubSettings.fromUser(user); let infoDump = `User information: Username : ${user.username}${user.isRoot() ? ' (root/SysOp)' : ''} Real name : ${propOrNA(UserProps.RealName)} ID : ${user.userId} Status : ${statusDesc()} Groups : ${user.groups.join(', ')} Theme ID : ${currentTheme()} Created : ${created()} Last login : ${lastLogin()} Login count : ${propOrNA(UserProps.LoginCount)} Email : ${propOrNA(UserProps.EmailAddress)} Location : ${propOrNA(UserProps.Location)} Affiliations : ${propOrNA(UserProps.Affiliations)} ActivityPub : ${apSettings.enabled ? 'enabled' : 'disabled'}`; const otp = user.getProperty(UserProps.AuthFactor2OTP); const oppDesc = { [OTPTypes.RFC6238_TOTP]: 'RFC6238 TOTP', [OTPTypes.RFC4266_HOTP]: 'rfc4266 HOTP', [OTPTypes.GoogleAuthenticator]: 'GoogleAuth', }[otp] || 'disabled'; infoDump += `\n2FA OTP : ${oppDesc}`; if (argv.security && otp) { const backupCodesOrNa = () => { try { return JSON.parse( user.getProperty(UserProps.AuthFactor2OTPBackupCodes) ).join(', '); } catch (e) { return 'N/A'; } }; infoDump += `\nOTP secret : ${ user.getProperty(UserProps.AuthFactor2OTPSecret) || 'N/A' } OTP Backup : ${backupCodesOrNa()}`; } console.info(infoDump); } function twoFactorAuthOTP(user) { if (argv._.length < 4) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } const { OTPTypes, prepareOTP, createBackupCodes, } = require('../../core/user_2fa_otp.js'); let otpType = argv._[argv._.length - 1]; // shortcut for removal if ('disable' === otpType) { const props = [ UserProps.AuthFactor2OTP, UserProps.AuthFactor2OTPSecret, UserProps.AuthFactor2OTPBackupCodes, ]; return user.removeProperties(props, err => { if (err) { console.error(err.message); } else { console.info(`2FA OTP disabled for ${user.username}`); } }); } async.waterfall( [ function validate(callback) { // :TODO: Prompt for if not supplied // allow aliases for OTP types otpType = { google: OTPTypes.GoogleAuthenticator, hotp: OTPTypes.RFC4266_HOTP, totp: OTPTypes.RFC6238_TOTP, }[otpType] || otpType; otpType = _.find(OTPTypes, t => { return t.toLowerCase() === otpType.toLowerCase(); }); if (!otpType) { return callback(Errors.Invalid('Invalid OTP type')); } return callback(null, otpType); }, function prepare(otpType, callback) { const otpOpts = { username: user.username, qrType: argv['qr-type'] || 'ascii', }; prepareOTP(otpType, otpOpts, (err, otpInfo) => { return callback( err, Object.assign(otpInfo, { otpType, backupCodes: createBackupCodes(), }) ); }); }, function storeOrDisplayQR(otpInfo, callback) { if (!argv.out || !otpInfo.qr) { return callback(null, otpInfo); } fs.writeFile(argv.out, otpInfo.qr, 'utf8', err => { return callback(err, otpInfo); }); }, function persist(otpInfo, callback) { const props = { [UserProps.AuthFactor2OTP]: otpInfo.otpType, [UserProps.AuthFactor2OTPSecret]: otpInfo.secret, [UserProps.AuthFactor2OTPBackupCodes]: JSON.stringify( otpInfo.backupCodes ), }; user.persistProperties(props, err => { return callback(err, otpInfo); }); }, ], (err, otpInfo) => { if (err) { console.error(err.message); } else { console.info(`OTP enabled for : ${user.username}`); console.info(`Secret : ${otpInfo.secret}`); console.info(`Backup codes : ${otpInfo.backupCodes.join(', ')}`); if (otpInfo.qr) { if (!argv.out) { console.info('--- Begin QR ---'); console.info(otpInfo.qr); console.info('--- End QR ---'); } else { console.info(`QR code saved to ${argv.out}`); } } } } ); } function listUsers() { // oputil user list [disabled|inactive|active|locked|all] // :TODO: --created-since SPEC and --last-called SPEC // --created-since SPEC // SPEC can be TIMESTAMP or e.g. "-1hour" or "-90days" let listWhat; if (argv._.length > 2) { listWhat = argv._[argv._.length - 1]; } else { listWhat = 'all'; } const sortBy = (argv.sort || 'id').toLowerCase(); const User = require('../../core/user'); if (!['all'].concat(Object.keys(User.AccountStatus)).includes(listWhat)) { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } async.waterfall( [ callback => { const UserProps = require('../../core/user_property'); const userListOpts = { properties: [ UserProps.RealName, UserProps.AccountStatus, UserProps.AccountCreated, UserProps.LastLoginTs, UserProps.LoginCount, ], }; User.getUserList(userListOpts, (err, userList) => { if (err) { return callback(err); } if ('all' === listWhat) { return callback(null, userList); } const accountStatusFilter = User.AccountStatus[listWhat].toString(); return callback( null, userList.filter(user => { return user[UserProps.AccountStatus] === accountStatusFilter; }) ); }); }, (userList, callback) => { // default sort: by ID const sortById = (left, right) => { return left.userId - right.userId; }; const sortByLogin = prop => (left, right) => { return parseInt(right[prop]) - parseInt(left[prop]); }; const sortByString = prop => (left, right) => { return left[prop].localeCompare(right[prop], { sensitivity: false, numeric: true, }); }; const sortByTimestamp = prop => (left, right) => { return moment(right[prop]) - moment(left[prop]); }; let sorter; switch (sortBy) { case 'username': sorter = sortByString('userName'); break; case 'realname': sorter = sortByString(UserProps.RealName); break; case 'status': sorter = sortByString(UserProps.AccountStatus); break; case 'created': sorter = sortByTimestamp(UserProps.AccountCreated); break; case 'lastlogin': sorter = sortByTimestamp(UserProps.LastLoginTs); break; case 'logins': sorter = sortByLogin(UserProps.LoginCount); break; case 'id': default: sorter = sortById; break; } userList.sort(sorter); const StatusNames = _.invert(User.AccountStatus); const propOrNA = (user, prop) => { return user[prop] || 'N/A'; }; const timestampOrNA = (user, prop, format) => { let ts = user[prop]; return ts ? moment(ts).format(format) : 'N/A'; }; const makeAccountStatus = status => { return StatusNames[status] || 'N/A'; }; const table = new Table(); userList.forEach(user => { table.cell('ID', user.userId); table.cell('Username', user.userName); table.cell('Real Name', user[UserProps.RealName]); table.cell( 'Status', makeAccountStatus(user[UserProps.AccountStatus]) ); table.cell( 'Created', timestampOrNA(user, UserProps.AccountCreated, 'YYYY-MM-DD') ); table.cell( 'Last Login', timestampOrNA(user, UserProps.LastLoginTs, 'YYYY-MM-DD HH::mm') ); table.cell('Logins', propOrNA(user, UserProps.LoginCount)); table.newRow(); }); console.info(table.toString()); return callback(null); }, ], err => { if (err) { return console.error(err.reason ? err.reason : err.message); } } ); } function handleUserCommand() { function errUsage() { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); } if (true === argv.help) { return errUsage(); } const action = argv._[1]; const userRequired = !['list'].includes(action); let userName; if (userRequired) { const usernameIdx = [ 'pw', 'pass', 'passwd', 'password', 'group', 'mv', 'rename', '2fa-otp', 'otp', ].includes(action) ? argv._.length - 2 : argv._.length - 1; userName = argv._[usernameIdx]; } if (!userName && userRequired) { return errUsage(); } initAndGetUser(userName, (err, user) => { if (userName && err) { process.exitCode = ExitCodes.ERROR; return console.error(err.message); } return ( { pw: setUserPassword, passwd: setUserPassword, password: setUserPassword, rm: removeUser, remove: removeUser, del: removeUser, delete: removeUser, mv: renameUser, rename: renameUser, activate: setAccountStatus, deactivate: setAccountStatus, disable: setAccountStatus, lock: setAccountStatus, group: modUserGroups, info: showUserInfo, '2fa-otp': twoFactorAuthOTP, otp: twoFactorAuthOTP, list: listUsers, }[action] || errUsage )(user, action); }); }