743 lines
24 KiB
JavaScript
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);
|
|
});
|
|
}
|