793 lines
29 KiB
JavaScript
793 lines
29 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
removePrivateFields,
|
|
removeRestrictedFields,
|
|
|
|
add,
|
|
createOwner,
|
|
isActivated,
|
|
|
|
getAll,
|
|
getAllPaged,
|
|
get,
|
|
getByResetToken,
|
|
getByUsername,
|
|
getByEmail,
|
|
getOwner,
|
|
getAdmins,
|
|
getSuperadmins,
|
|
|
|
verify,
|
|
verifyWithUsername,
|
|
verifyWithEmail,
|
|
|
|
setPassword,
|
|
update,
|
|
|
|
del,
|
|
|
|
createInvite,
|
|
sendInvite,
|
|
|
|
setTwoFactorAuthenticationSecret,
|
|
enableTwoFactorAuthentication,
|
|
disableTwoFactorAuthentication,
|
|
|
|
sendPasswordResetByIdentifier,
|
|
|
|
notifyLoginLocation,
|
|
|
|
setupAccount,
|
|
|
|
getAvatarUrl,
|
|
setAvatar,
|
|
getAvatar,
|
|
|
|
AP_MAIL: 'mail',
|
|
AP_WEBADMIN: 'webadmin',
|
|
|
|
ROLE_ADMIN: 'admin',
|
|
ROLE_USER: 'user',
|
|
ROLE_USER_MANAGER: 'usermanager',
|
|
ROLE_OWNER: 'owner',
|
|
compareRoles,
|
|
};
|
|
|
|
const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ];
|
|
|
|
// the avatar field is special and not added here to reduce response sizes
|
|
const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'resetToken', 'displayName',
|
|
'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson' ].join(',');
|
|
|
|
const appPasswords = require('./apppasswords.js'),
|
|
assert = require('assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
crypto = require('crypto'),
|
|
constants = require('./constants.js'),
|
|
database = require('./database.js'),
|
|
debug = require('debug')('box:user'),
|
|
eventlog = require('./eventlog.js'),
|
|
externalLdap = require('./externalldap.js'),
|
|
hat = require('./hat.js'),
|
|
mailer = require('./mailer.js'),
|
|
mysql = require('mysql'),
|
|
paths = require('./paths.js'),
|
|
qrcode = require('qrcode'),
|
|
safe = require('safetydance'),
|
|
settings = require('./settings.js'),
|
|
speakeasy = require('speakeasy'),
|
|
tokens = require('./tokens.js'),
|
|
uuid = require('uuid'),
|
|
uaParser = require('ua-parser-js'),
|
|
superagent = require('superagent'),
|
|
util = require('util'),
|
|
validator = require('validator'),
|
|
_ = require('underscore');
|
|
|
|
const CRYPTO_SALT_SIZE = 64; // 512-bit salt
|
|
const CRYPTO_ITERATIONS = 10000; // iterations
|
|
const CRYPTO_KEY_LENGTH = 512; // bits
|
|
const CRYPTO_DIGEST = 'sha1'; // used to be the default in node 4.1.1 cannot change since it will affect existing db records
|
|
|
|
const pbkdf2Async = util.promisify(crypto.pbkdf2);
|
|
const randomBytesAsync = util.promisify(crypto.randomBytes);
|
|
const getDirectoryConfigAsync = util.promisify(settings.getDirectoryConfig);
|
|
|
|
function postProcess(result) {
|
|
assert.strictEqual(typeof result, 'object');
|
|
|
|
result.twoFactorAuthenticationEnabled = !!result.twoFactorAuthenticationEnabled;
|
|
result.active = !!result.active;
|
|
|
|
result.loginLocations = safe.JSON.parse(result.loginLocationsJson) || [];
|
|
if (!Array.isArray(result.loginLocations)) result.loginLocations = [];
|
|
delete result.loginLocationsJson;
|
|
|
|
return result;
|
|
}
|
|
|
|
// keep this in sync with validateGroupname and validateAlias
|
|
function validateUsername(username) {
|
|
assert.strictEqual(typeof username, 'string');
|
|
|
|
if (username.length < 1) return new BoxError(BoxError.BAD_FIELD, 'Username must be atleast 1 char');
|
|
if (username.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'Username too long');
|
|
|
|
if (constants.RESERVED_NAMES.indexOf(username) !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username is reserved');
|
|
|
|
// also need to consider valid LDAP characters here (e.g '+' is reserved). apps like openvpn require _ to not be used
|
|
if (/[^a-zA-Z0-9.-]/.test(username)) return new BoxError(BoxError.BAD_FIELD, 'Username can only contain alphanumerals, dot and -');
|
|
|
|
// app emails are sent using the .app suffix
|
|
if (username.indexOf('.app') !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username pattern is reserved for apps');
|
|
|
|
return null;
|
|
}
|
|
|
|
function validateEmail(email) {
|
|
assert.strictEqual(typeof email, 'string');
|
|
|
|
if (!validator.isEmail(email)) return new BoxError(BoxError.BAD_FIELD, 'Invalid email');
|
|
|
|
return null;
|
|
}
|
|
|
|
function validateResetToken(token) {
|
|
assert.strictEqual(typeof token, 'string');
|
|
|
|
if (token.length !== 64) return new BoxError(BoxError.BAD_FIELD, 'Invalid token'); // 256-bit hex coded token
|
|
|
|
return null;
|
|
}
|
|
|
|
function validateDisplayName(name) {
|
|
assert.strictEqual(typeof name, 'string');
|
|
|
|
return null;
|
|
}
|
|
|
|
function validatePassword(password) {
|
|
assert.strictEqual(typeof password, 'string');
|
|
|
|
if (password.length < 8) return new BoxError(BoxError.BAD_FIELD, 'Password must be atleast 8 characters');
|
|
if (password.length > 256) return new BoxError(BoxError.BAD_FIELD, 'Password cannot be more than 256 characters');
|
|
|
|
return null;
|
|
}
|
|
|
|
// remove all fields that should never be sent out via REST API
|
|
function removePrivateFields(user) {
|
|
return _.pick(user, 'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt', 'twoFactorAuthenticationEnabled');
|
|
}
|
|
|
|
// remove all fields that Non-privileged users must not see
|
|
function removeRestrictedFields(user) {
|
|
return _.pick(user, 'id', 'username', 'email', 'displayName', 'active');
|
|
}
|
|
|
|
async function add(email, data, auditSource) {
|
|
assert.strictEqual(typeof email, 'string');
|
|
assert(data && typeof data === 'object');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
assert(data.username === null || typeof data.username === 'string');
|
|
assert(data.password === null || typeof data.password === 'string');
|
|
assert.strictEqual(typeof data.displayName, 'string');
|
|
|
|
let { username, password, displayName } = data;
|
|
const source = data.source || ''; // empty is local user
|
|
const role = data.role || exports.ROLE_USER;
|
|
|
|
let error;
|
|
|
|
if (username !== null) {
|
|
username = username.toLowerCase();
|
|
error = validateUsername(username);
|
|
if (error) throw error;
|
|
}
|
|
|
|
if (password !== null) {
|
|
error = validatePassword(password);
|
|
if (error) throw error;
|
|
} else {
|
|
password = hat(8 * 8);
|
|
}
|
|
|
|
email = email.toLowerCase();
|
|
error = validateEmail(email);
|
|
if (error) throw error;
|
|
|
|
error = validateDisplayName(displayName);
|
|
if (error) throw error;
|
|
|
|
error = validateRole(role);
|
|
if (error) throw error;
|
|
|
|
let salt, derivedKey;
|
|
|
|
[error, salt] = await safe(randomBytesAsync(CRYPTO_SALT_SIZE));
|
|
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
|
|
|
|
[error, derivedKey] = await safe(pbkdf2Async(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
|
|
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
|
|
|
|
const user = {
|
|
id: 'uid-' + uuid.v4(),
|
|
username: username,
|
|
email: email,
|
|
fallbackEmail: email,
|
|
password: Buffer.from(derivedKey, 'binary').toString('hex'),
|
|
salt: salt.toString('hex'),
|
|
resetToken: '',
|
|
displayName: displayName,
|
|
source: source,
|
|
role: role,
|
|
avatar: constants.AVATAR_NONE
|
|
};
|
|
|
|
const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, displayName, source, role, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
|
const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.displayName, user.source, user.role, user.avatar ];
|
|
|
|
[error] = await safe(database.query(query, args));
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_username') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'username already exists');
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('PRIMARY') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'id already exists');
|
|
if (error) throw error;
|
|
|
|
// when this is used to create the owner, then we have to patch the auditSource to contain himself
|
|
if (!auditSource.userId) auditSource.userId = user.id;
|
|
if (!auditSource.username) auditSource.username= user.username;
|
|
|
|
eventlog.add(eventlog.ACTION_USER_ADD, auditSource, { userId: user.id, email: user.email, user: removePrivateFields(user) });
|
|
|
|
return user.id;
|
|
}
|
|
|
|
// returns true if ghost user was matched
|
|
function verifyGhost(username, password) {
|
|
assert.strictEqual(typeof username, 'string');
|
|
assert.strictEqual(typeof password, 'string');
|
|
|
|
var ghostData = safe.JSON.parse(safe.fs.readFileSync(paths.GHOST_USER_FILE, 'utf8'));
|
|
if (!ghostData) return false;
|
|
|
|
if (username in ghostData && ghostData[username] === password) {
|
|
debug('verifyGhost: matched ghost user');
|
|
safe.fs.unlinkSync(paths.GHOST_USER_FILE);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function verifyAppPassword(userId, password, identifier) {
|
|
assert.strictEqual(typeof userId, 'string');
|
|
assert.strictEqual(typeof password, 'string');
|
|
assert.strictEqual(typeof identifier, 'string');
|
|
|
|
const results = await appPasswords.list(userId);
|
|
|
|
const hashedPasswords = results.filter(r => r.identifier === identifier).map(r => r.hashedPassword);
|
|
let hash = crypto.createHash('sha256').update(password).digest('base64');
|
|
|
|
if (hashedPasswords.includes(hash)) return;
|
|
|
|
throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
|
}
|
|
|
|
async function verify(userId, password, identifier) {
|
|
assert.strictEqual(typeof userId, 'string');
|
|
assert.strictEqual(typeof password, 'string');
|
|
assert.strictEqual(typeof identifier, 'string');
|
|
|
|
const user = await get(userId);
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
if (!user.active) throw new BoxError(BoxError.NOT_FOUND, 'User not active');
|
|
|
|
// for just invited users the username may be still null
|
|
if (user.username && verifyGhost(user.username, password)) {
|
|
user.ghost = true;
|
|
return user;
|
|
}
|
|
|
|
const [error] = await safe(verifyAppPassword(user.id, password, identifier));
|
|
if (!error) { // matched app password
|
|
user.appPassword = true;
|
|
return user;
|
|
}
|
|
|
|
if (user.source === 'ldap') {
|
|
await externalLdap.verifyPassword(user, password);
|
|
} else {
|
|
const saltBinary = Buffer.from(user.salt, 'hex');
|
|
const [error, derivedKey] = await safe(pbkdf2Async(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
|
|
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
|
|
|
|
const derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex');
|
|
if (derivedKeyHex !== user.password) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
async function verifyWithUsername(username, password, identifier) {
|
|
assert.strictEqual(typeof username, 'string');
|
|
assert.strictEqual(typeof password, 'string');
|
|
assert.strictEqual(typeof identifier, 'string');
|
|
|
|
const user = await getByUsername(username.toLowerCase());
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
return await verify(user.id, password, identifier);
|
|
}
|
|
|
|
async function verifyWithEmail(email, password, identifier) {
|
|
assert.strictEqual(typeof email, 'string');
|
|
assert.strictEqual(typeof password, 'string');
|
|
assert.strictEqual(typeof identifier, 'string');
|
|
|
|
const user = await getByEmail(email.toLowerCase());
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
return await verify(user.id, password, identifier);
|
|
}
|
|
|
|
async function del(user, auditSource) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
|
|
|
const queries = [];
|
|
queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ user.id ] });
|
|
queries.push({ query: 'DELETE FROM tokens WHERE identifier = ?', args: [ user.id ] });
|
|
queries.push({ query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ user.id ] });
|
|
queries.push({ query: 'DELETE FROM users WHERE id = ?', args: [ user.id ] });
|
|
|
|
const [error, result] = await safe(database.transaction(queries));
|
|
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, error);
|
|
if (error) throw error;
|
|
if (result[3].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
await safe(eventlog.add(eventlog.ACTION_USER_REMOVE, auditSource, { userId: user.id, user: removePrivateFields(user) }));
|
|
}
|
|
|
|
async function getAll() {
|
|
const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
|
|
' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
|
|
' GROUP BY users.id ORDER BY users.username');
|
|
|
|
results.forEach(function (result) {
|
|
result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
|
|
});
|
|
|
|
results.forEach(postProcess);
|
|
|
|
return results;
|
|
}
|
|
|
|
async function getAllPaged(search, page, perPage) {
|
|
assert(typeof search === 'string' || search === null);
|
|
assert.strictEqual(typeof page, 'number');
|
|
assert.strictEqual(typeof perPage, 'number');
|
|
|
|
let query = `SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId `;
|
|
|
|
if (search) {
|
|
query += ' WHERE ';
|
|
query += '(LOWER(users.username) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
|
|
query += ' OR ';
|
|
query += '(LOWER(users.email) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
|
|
query += ' OR ';
|
|
query += '(LOWER(users.displayName) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
|
|
}
|
|
|
|
query += ` GROUP BY users.id ORDER BY users.username ASC LIMIT ${(page-1)*perPage},${perPage} `;
|
|
|
|
const results = await database.query(query);
|
|
|
|
results.forEach(function (result) {
|
|
result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
|
|
});
|
|
|
|
results.forEach(postProcess);
|
|
|
|
return results;
|
|
}
|
|
|
|
async function isActivated() {
|
|
const result = await database.query('SELECT COUNT(*) AS total FROM users');
|
|
|
|
return result[0].total !== 0;
|
|
}
|
|
|
|
async function get(userId) {
|
|
assert.strictEqual(typeof userId, 'string');
|
|
|
|
const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
|
|
' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
|
|
' GROUP BY users.id HAVING users.id = ?', [ userId ]);
|
|
|
|
if (results.length === 0) return null;
|
|
|
|
results[0].groupIds = results[0].groupIds ? results[0].groupIds.split(',') : [ ];
|
|
|
|
return postProcess(results[0]);
|
|
}
|
|
|
|
async function getByEmail(email) {
|
|
assert.strictEqual(typeof email, 'string');
|
|
|
|
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE email = ?`, [ email ]);
|
|
if (result.length === 0) return null;
|
|
|
|
return postProcess(result[0]);
|
|
}
|
|
|
|
async function getByRole(role) {
|
|
assert.strictEqual(typeof role, 'string');
|
|
|
|
// the mailer code relies on the first object being the 'owner' (thus the ORDER)
|
|
const results = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE role=? ORDER BY creationTime`, [ role ]);
|
|
|
|
results.forEach(postProcess);
|
|
|
|
return results;
|
|
}
|
|
|
|
async function getByResetToken(resetToken) {
|
|
assert.strictEqual(typeof resetToken, 'string');
|
|
|
|
let error = validateResetToken(resetToken);
|
|
if (error) throw error;
|
|
|
|
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE resetToken=?`, [ resetToken ]);
|
|
if (result.length === 0) return null;
|
|
|
|
return postProcess(result[0]);
|
|
}
|
|
|
|
async function getByUsername(username) {
|
|
assert.strictEqual(typeof username, 'string');
|
|
|
|
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE username = ?`, [ username ]);
|
|
if (result.length === 0) return null;
|
|
|
|
return postProcess(result[0]);
|
|
}
|
|
|
|
async function update(user, data, auditSource) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert.strictEqual(typeof data, 'object');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
assert(!('twoFactorAuthenticationEnabled' in data) || (typeof data.twoFactorAuthenticationEnabled === 'boolean'));
|
|
assert(!('active' in data) || (typeof data.active === 'boolean'));
|
|
assert(!('loginLocations' in data) || (Array.isArray(data.loginLocations)));
|
|
|
|
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
|
|
|
let error, result;
|
|
|
|
if (_.isEmpty(data)) return;
|
|
|
|
if (data.username) {
|
|
data.username = data.username.toLowerCase();
|
|
error = validateUsername(data.username);
|
|
if (error) throw error;
|
|
}
|
|
|
|
if (data.email) {
|
|
data.email = data.email.toLowerCase();
|
|
error = validateEmail(data.email);
|
|
if (error) throw error;
|
|
}
|
|
|
|
if (data.fallbackEmail) {
|
|
data.fallbackEmail = data.fallbackEmail.toLowerCase();
|
|
error = validateEmail(data.fallbackEmail);
|
|
if (error) throw error;
|
|
}
|
|
|
|
if (data.role) {
|
|
error = validateRole(data.role);
|
|
if (error) throw error;
|
|
}
|
|
|
|
let args = [ ];
|
|
let fields = [ ];
|
|
for (const k in data) {
|
|
if (k === 'twoFactorAuthenticationEnabled' || k === 'active') {
|
|
fields.push(k + ' = ?');
|
|
args.push(data[k] ? 1 : 0);
|
|
} else if (k === 'loginLocations') {
|
|
fields.push('loginLocationsJson = ?');
|
|
args.push(JSON.stringify(data[k]));
|
|
} else {
|
|
fields.push(k + ' = ?');
|
|
args.push(data[k]);
|
|
}
|
|
}
|
|
args.push(user.id);
|
|
|
|
[error, result] = await safe(database.query('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', args));
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_username') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'username already exists');
|
|
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
|
|
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
const newUser = _.extend({}, user, data);
|
|
|
|
eventlog.add(eventlog.ACTION_USER_UPDATE, auditSource, {
|
|
userId: user.id,
|
|
user: removePrivateFields(newUser),
|
|
roleChanged: newUser.role !== user.role,
|
|
activeStatusChanged: ((newUser.active && !user.active) || (!newUser.active && user.active))
|
|
});
|
|
}
|
|
|
|
async function getOwner() {
|
|
const owners = await getByRole(exports.ROLE_OWNER);
|
|
if (owners.length === 0) return null;
|
|
return owners[0];
|
|
}
|
|
|
|
async function getAdmins() {
|
|
const owners = await getByRole(exports.ROLE_OWNER);
|
|
const admins = await getByRole(exports.ROLE_ADMIN);
|
|
|
|
return owners.concat(admins);
|
|
}
|
|
|
|
async function getSuperadmins() {
|
|
return await getByRole(exports.ROLE_OWNER);
|
|
}
|
|
|
|
async function sendPasswordResetByIdentifier(identifier, auditSource) {
|
|
assert.strictEqual(typeof identifier, 'string');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
let resetToken = hat(256), resetTokenCreationTime = new Date();
|
|
user.resetToken = resetToken;
|
|
user.resetTokenCreationTime = resetTokenCreationTime;
|
|
|
|
await update(user, { resetToken, resetTokenCreationTime }, auditSource);
|
|
|
|
mailer.passwordReset(user);
|
|
}
|
|
|
|
async function notifyLoginLocation(user, ip, userAgent, auditSource) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert.strictEqual(typeof ip, 'string');
|
|
assert.strictEqual(typeof userAgent, 'string');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
debug(`notifyLoginLocation: ${user.id} ${ip} ${userAgent}`);
|
|
|
|
if (constants.TEST && ip === '127.0.0.1') return;
|
|
|
|
const response = await superagent.get('https://geolocation.cloudron.io/json').query({ ip }).ok(() => true);
|
|
if (response.statusCode !== 200) return console.error(`Failed to get geoip info. statusCode: ${response.statusCode}`);
|
|
|
|
const country = safe.query(response.body, 'country.names.en', '');
|
|
const city = safe.query(response.body, 'city.names.en', '');
|
|
|
|
if (!city || !country) return;
|
|
|
|
const ua = uaParser(userAgent);
|
|
const simplifiedUserAgent = ua.browser.name ? `${ua.browser.name} - ${ua.os.name}` : userAgent;
|
|
|
|
const knownLogin = user.loginLocations.find(function (l) {
|
|
return l.userAgent === simplifiedUserAgent && l.country === country && l.city === city;
|
|
});
|
|
|
|
if (knownLogin) return;
|
|
|
|
// purge potentially old locations where ts > now() - 6 months
|
|
const sixMonthsBack = Date.now() - 6 * 30 * 24 * 60 * 60 * 1000;
|
|
const newLoginLocation = { ts: Date.now(), ip, userAgent: simplifiedUserAgent, country, city };
|
|
let loginLocations = user.loginLocations.filter(function (l) { return l.ts > sixMonthsBack; });
|
|
|
|
// only stash if we have a real useragent, otherwise warn the user every time
|
|
if (simplifiedUserAgent) loginLocations.push(newLoginLocation);
|
|
|
|
await update(user, { loginLocations }, auditSource);
|
|
|
|
mailer.sendNewLoginLocation(user, newLoginLocation);
|
|
}
|
|
|
|
async function setPassword(user, newPassword, auditSource) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert.strictEqual(typeof newPassword, 'string');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
let error = validatePassword(newPassword);
|
|
if (error) throw error;
|
|
|
|
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
|
if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
|
|
|
|
let salt, derivedKey;
|
|
[error, salt] = await safe(randomBytesAsync(CRYPTO_SALT_SIZE));
|
|
|
|
[error, derivedKey] = await safe(pbkdf2Async(newPassword, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
|
|
if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error);
|
|
|
|
const data = {
|
|
salt: salt.toString('hex'),
|
|
password: Buffer.from(derivedKey, 'binary').toString('hex'),
|
|
resetToken: ''
|
|
};
|
|
|
|
await update(user, data, auditSource);
|
|
}
|
|
|
|
async function createOwner(email, username, password, displayName, auditSource) {
|
|
assert.strictEqual(typeof email, 'string');
|
|
assert.strictEqual(typeof username, 'string');
|
|
assert.strictEqual(typeof password, 'string');
|
|
assert.strictEqual(typeof displayName, 'string');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
// This is only not allowed for the owner. reset of username validation happens in add()
|
|
if (username === '') throw new BoxError(BoxError.BAD_FIELD, 'Username cannot be empty');
|
|
|
|
const activated = await isActivated();
|
|
if (activated) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cloudron already activated');
|
|
|
|
return await add(email, { username, password, displayName, role: exports.ROLE_OWNER }, auditSource);
|
|
}
|
|
|
|
function inviteLink(user, directoryConfig) {
|
|
let link = `${settings.dashboardOrigin()}/setupaccount.html?resetToken=${user.resetToken}&email=${encodeURIComponent(user.email)}`;
|
|
|
|
if (user.username) link += `&username=${encodeURIComponent(user.username)}`;
|
|
if (user.displayName) link += `&displayName=${encodeURIComponent(user.displayName)}`;
|
|
if (directoryConfig.lockUserProfiles) link += '&profileLocked=true';
|
|
|
|
return link;
|
|
}
|
|
|
|
async function createInvite(user, auditSource) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
|
|
|
|
const resetToken = hat(256), resetTokenCreationTime = new Date();
|
|
|
|
const directoryConfig = await getDirectoryConfigAsync();
|
|
|
|
await update(user, { resetToken, resetTokenCreationTime }, auditSource);
|
|
|
|
user.resetToken = resetToken;
|
|
|
|
return { resetToken, inviteLink: inviteLink(user, directoryConfig) };
|
|
}
|
|
|
|
async function sendInvite(user, options) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
|
|
if (!user.resetToken) throw new BoxError(BoxError.CONFLICT, 'Must generate resetToken to send invitation');
|
|
|
|
const directoryConfig = await getDirectoryConfigAsync();
|
|
|
|
mailer.sendInvite(user, options.invitor || null, inviteLink(user, directoryConfig));
|
|
}
|
|
|
|
async function setupAccount(user, data, auditSource) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
assert.strictEqual(typeof data, 'object');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
const directoryConfig = await getDirectoryConfigAsync();
|
|
if (directoryConfig.lockUserProfiles) return;
|
|
|
|
await update(user, _.pick(data, 'username', 'displayName'), auditSource);
|
|
|
|
await setPassword(user, data.password, auditSource); // setPassword clears the resetToken
|
|
|
|
const token = { clientId: tokens.ID_WEBADMIN, identifier: user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS };
|
|
const result = await tokens.add(token);
|
|
return result.accessToken;
|
|
}
|
|
|
|
async function setTwoFactorAuthenticationSecret(userId, auditSource) {
|
|
assert.strictEqual(typeof userId, 'string');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
const user = await get(userId);
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
if (settings.isDemo() && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
|
|
|
if (user.twoFactorAuthenticationEnabled) throw new BoxError(BoxError.ALREADY_EXISTS);
|
|
|
|
const secret = speakeasy.generateSecret({ name: `Cloudron ${settings.dashboardFqdn()} (${user.username})` });
|
|
|
|
await update(user, { twoFactorAuthenticationSecret: secret.base32, twoFactorAuthenticationEnabled: false }, auditSource);
|
|
|
|
const [error, dataUrl] = await safe(qrcode.toDataURL(secret.otpauth_url));
|
|
if (error) throw new BoxError(BoxError.INTERNAL_ERROR, error);
|
|
|
|
return { secret: secret.base32, qrcode: dataUrl };
|
|
}
|
|
|
|
async function enableTwoFactorAuthentication(userId, totpToken, auditSource) {
|
|
assert.strictEqual(typeof userId, 'string');
|
|
assert.strictEqual(typeof totpToken, 'string');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
const user = await get(userId);
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
|
|
const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 });
|
|
if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
|
|
|
if (user.twoFactorAuthenticationEnabled) throw new BoxError(BoxError.ALREADY_EXISTS);
|
|
|
|
await update(user, { twoFactorAuthenticationEnabled: true }, auditSource);
|
|
}
|
|
|
|
async function disableTwoFactorAuthentication(userId, auditSource) {
|
|
assert.strictEqual(typeof userId, 'string');
|
|
assert(auditSource && typeof auditSource === 'object');
|
|
|
|
const user = await get(userId);
|
|
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
await update(user, { twoFactorAuthenticationEnabled: false, twoFactorAuthenticationSecret: '' }, auditSource);
|
|
}
|
|
|
|
function validateRole(role) {
|
|
assert.strictEqual(typeof role, 'string');
|
|
|
|
if (ORDERED_ROLES.indexOf(role) !== -1) return null;
|
|
|
|
return new BoxError(BoxError.BAD_FIELD, `Invalid role '${role}'`);
|
|
}
|
|
|
|
function compareRoles(role1, role2) {
|
|
assert.strictEqual(typeof role1, 'string');
|
|
assert.strictEqual(typeof role2, 'string');
|
|
|
|
let roleInt1 = ORDERED_ROLES.indexOf(role1);
|
|
let roleInt2 = ORDERED_ROLES.indexOf(role2);
|
|
|
|
return roleInt1 - roleInt2;
|
|
}
|
|
|
|
async function getAvatarUrl(user) {
|
|
assert.strictEqual(typeof user, 'object');
|
|
|
|
const fallbackUrl = `${settings.dashboardOrigin()}/img/avatar-default-symbolic.svg`;
|
|
|
|
const result = await getAvatar(user.id);
|
|
|
|
if (result.equals(constants.AVATAR_NONE)) return fallbackUrl;
|
|
else if (result.equals(constants.AVATAR_GRAVATAR)) return `https://www.gravatar.com/avatar/${require('crypto').createHash('md5').update(user.email).digest('hex')}.jpg`;
|
|
else if (result) return `${settings.dashboardOrigin()}/api/v1/profile/avatar/${user.id}`;
|
|
else return fallbackUrl;
|
|
}
|
|
|
|
async function getAvatar(id) {
|
|
assert.strictEqual(typeof id, 'string');
|
|
|
|
const result = await database.query('SELECT avatar FROM users WHERE id = ?', [ id ]);
|
|
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
return result[0].avatar;
|
|
}
|
|
|
|
async function setAvatar(id, avatar) {
|
|
assert.strictEqual(typeof id, 'string');
|
|
assert(Buffer.isBuffer(avatar));
|
|
|
|
const result = await database.query('UPDATE users SET avatar=? WHERE id = ?', [ avatar, id ]);
|
|
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
|
}
|