Files
cloudron-box/src/users.js
2023-06-15 16:34:58 +02:00

951 lines
36 KiB
JavaScript

'use strict';
exports = module.exports = {
removePrivateFields,
add,
createOwner,
isActivated,
list,
listPaged,
get,
getByInviteToken,
getByResetToken,
getByUsername,
getByEmail,
getOwner,
getAdmins,
getSuperadmins,
verify,
verifyWithUsername,
verifyWithEmail,
setPassword,
setGhost,
update,
del,
setTwoFactorAuthenticationSecret,
enableTwoFactorAuthentication,
disableTwoFactorAuthentication,
sendPasswordResetByIdentifier,
getPasswordResetLink,
sendPasswordResetEmail,
getInviteLink,
sendInviteEmail,
notifyLoginLocation,
setupAccount,
getAvatarUrl,
setAvatar,
getAvatar,
getBackgroundImage,
setBackgroundImage,
AP_MAIL: 'mail',
AP_WEBADMIN: 'webadmin',
ROLE_ADMIN: 'admin',
ROLE_USER: 'user',
ROLE_USER_MANAGER: 'usermanager',
ROLE_MAIL_MANAGER: 'mailmanager',
ROLE_OWNER: 'owner',
compareRoles,
};
const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_MAIL_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ];
// the avatar and backgroundImage fields are special and not added here to reduce response sizes
const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName',
'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson' ].join(',');
const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours
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'),
mail = require('./mail.js'),
mailer = require('./mailer.js'),
mysql = require('mysql'),
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);
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 validateToken(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) {
const result = _.pick(user, 'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt', 'twoFactorAuthenticationEnabled');
// invite status indicator
result.inviteAccepted = !user.inviteToken;
return result;
}
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');
if ('fallbackEmail' in data) assert.strictEqual(typeof data.fallbackEmail, 'string');
let { username, password, displayName } = data;
let fallbackEmail = data.fallbackEmail || '';
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;
fallbackEmail = fallbackEmail.toLowerCase();
if (fallbackEmail) {
let error = validateEmail(fallbackEmail);
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: fallbackEmail,
password: Buffer.from(derivedKey, 'binary').toString('hex'),
salt: salt.toString('hex'),
resetToken: '',
inviteToken: hat(256), // new users start out with invite tokens
displayName: displayName,
source: source,
role: role,
avatar: constants.AVATAR_NONE
};
const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, inviteToken, displayName, source, role, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.inviteToken, 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;
await eventlog.add(eventlog.ACTION_USER_ADD, auditSource, { userId: user.id, email: user.email, user: removePrivateFields(user) });
return user.id;
}
async function setGhost(user, password, expiresAt) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof expiresAt, 'number');
if (!user.username) throw new BoxError(BoxError.BAD_STATE, 'user has no username yet');
expiresAt = expiresAt || (Date.now() + DEFAULT_GHOST_LIFETIME);
debug(`setGhost: ${user.username} expiresAt ${expiresAt}`);
const ghostData = await settings.getGhosts();
ghostData[user.username] = { password, expiresAt };
await settings.setGhosts(ghostData);
}
// returns true if ghost user was matched
async function verifyGhost(username, password) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
const ghostData = await settings.getGhosts();
// either the username is an object with { password, expiresAt } or a string with the password which will expire on first match
if (username in ghostData) {
if (typeof ghostData[username] === 'object') {
if (ghostData[username].expiresAt < Date.now()) {
debug('verifyGhost: password expired');
delete ghostData[username];
await settings.setGhosts(ghostData);
return false;
} else if (ghostData[username].password === password) {
debug('verifyGhost: matched ghost user');
return true;
} else {
return false;
}
} else if(ghostData[username] === password) {
debug('verifyGhost: matched ghost user');
delete ghostData[username];
await settings.setGhosts(ghostData);
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);
}
// identifier is only used to check if password is valid for a specific app
async function verify(userId, password, identifier, options) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
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) {
const valid = await verifyGhost(user.username, password);
if (valid) {
user.ghost = true;
return user;
}
}
const [error] = await safe(verifyAppPassword(user.id, password, identifier));
if (!error) { // matched app password
user.appPassword = true;
return user;
}
const relaxedTotpCheck = !!options.relaxedTotpCheck; // will enforce totp only if totpToken is valid
const totpToken = options.totpToken || null;
if (user.source === 'ldap') {
await externalLdap.verifyPassword(user, password, totpToken);
} 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, 'Username and password does not match');
if (user.twoFactorAuthenticationEnabled) {
if (totpToken) {
const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 });
if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid totpToken');
} else if (!relaxedTotpCheck) {
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'A totpToken must be provided');
}
}
}
return user;
}
async function verifyWithUsername(username, password, identifier, options) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await getByUsername(username.toLowerCase());
if (user) return await verify(user.id, password, identifier, options);
const [error, newUserId] = await safe(externalLdap.maybeCreateUser(username.toLowerCase()));
if (error && error.reason === BoxError.BAD_STATE) throw new BoxError(BoxError.NOT_FOUND, 'User not found'); // no external ldap or no auto create
if (error) {
debug(`verifyWithUsername: failed to auto create user ${username}. %o`, error);
throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}
return await verify(newUserId, password, identifier, options);
}
async function verifyWithEmail(email, password, identifier, options) {
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof password, 'string');
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof options, 'object');
const user = await getByEmail(email.toLowerCase());
if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
return await verify(user.id, password, identifier, options);
}
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 eventlog.add(eventlog.ACTION_USER_REMOVE, auditSource, { userId: user.id, user: removePrivateFields(user) });
}
async function list() {
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;
}
// if active is null then both active and inactive users are listed
async function listPaged(search, active, page, perPage) {
assert(typeof search === 'string' || search === null);
assert(typeof active === 'boolean' || active === 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 += '(';
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 += ')';
}
if (active !== null) {
if (search) query += ' AND ';
else query += ' WHERE ';
query += 'users.active' + (!active ? ' IS NOT ' : ' IS ') + 'TRUE';
}
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 = validateToken(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 getByInviteToken(inviteToken) {
assert.strictEqual(typeof inviteToken, 'string');
let error = validateToken(inviteToken);
if (error) throw error;
const result = await database.query(`SELECT ${USERS_FIELDS} FROM users WHERE inviteToken=?`, [ inviteToken ]);
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) {
// regardless of "account setup", username cannot be changed because admin could have logged in with temp password and apps
// already know about it
if (user.username) throw new BoxError(BoxError.CONFLICT, 'Username cannot be changed');
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 = Object.assign({}, user, data);
await 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 getPasswordResetLink(user, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof auditSource, 'object');
let resetToken = user.resetToken;
let resetTokenCreationTime = user.resetTokenCreationTime || 0;
if (!resetToken || (Date.now() - resetTokenCreationTime > 7 * 24 * 60 * 60 * 1000)) {
resetToken = hat(256);
resetTokenCreationTime = new Date();
await update(user, { resetToken, resetTokenCreationTime }, auditSource);
}
const resetLink = `${settings.dashboardOrigin()}/passwordreset.html?resetToken=${resetToken}`;
return resetLink;
}
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');
const email = user.fallbackEmail || user.email;
// security measure to prevent a mail manager or admin resetting the superadmin's password
const mailDomains = await mail.listDomains();
if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');
const resetLink = await getPasswordResetLink(user, auditSource);
await mailer.passwordReset(user, email, resetLink);
}
async function sendPasswordResetEmail(user, email, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof auditSource, 'object');
const error = validateEmail(email);
if (error) throw error;
// security measure to prevent a mail manager or admin resetting the superadmin's password
const mailDomains = await mail.listDomains();
if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');
const resetLink = await getPasswordResetLink(user, auditSource);
await mailer.passwordReset(user, email, resetLink);
}
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 (settings.isDemo()) return;
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 debug(`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);
await 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, fallbackEmail: '', displayName, role: exports.ROLE_OWNER }, auditSource);
}
async function getInviteLink(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');
if (!user.inviteToken) throw new BoxError(BoxError.BAD_STATE, 'User already used invite link');
const directoryConfig = await settings.getProfileConfig();
let inviteLink = `${settings.dashboardOrigin()}/setupaccount.html?inviteToken=${user.inviteToken}&email=${encodeURIComponent(user.email)}`;
if (user.username) inviteLink += `&username=${encodeURIComponent(user.username)}`;
if (user.displayName) inviteLink += `&displayName=${encodeURIComponent(user.displayName)}`;
if (directoryConfig.lockUserProfiles) inviteLink += '&profileLocked=true';
return inviteLink;
}
async function sendInviteEmail(user, email, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof email, 'string');
assert.strictEqual(typeof auditSource, 'object');
const error = validateEmail(email);
if (error) throw error;
const inviteLink = await getInviteLink(user, auditSource);
await mailer.sendInvite(user, null /* invitor */, email, inviteLink);
}
async function setupAccount(user, data, auditSource) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof data, 'object');
assert(auditSource && typeof auditSource === 'object');
const profileConfig = await settings.getProfileConfig();
const tmp = { inviteToken: '' };
if (profileConfig.lockUserProfiles) {
if (!user.username) throw new BoxError(BoxError.CONFLICT, 'Account cannot be setup without a username'); // error out if admin has not provided a username
} else {
if (data.username) tmp.username = data.username;
if (data.displayName) tmp.displayName = data.displayName;
}
await update(user, tmp, auditSource);
await setPassword(user, data.password, auditSource);
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');
}
async function getBackgroundImage(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query('SELECT backgroundImage FROM users WHERE id = ?', [ id ]);
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
return result[0].backgroundImage;
}
async function setBackgroundImage(id, backgroundImage) {
assert.strictEqual(typeof id, 'string');
assert(Buffer.isBuffer(backgroundImage) || backgroundImage === null);
const result = await database.query('UPDATE users SET backgroundImage=? WHERE id = ?', [ backgroundImage, id ]);
if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}