enigma-bbs/core/oputil/oputil_user.js

743 lines
24 KiB
JavaScript

/* 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);
});
}