'use strict'; exports = module.exports = { removePrivateFields, removeRestrictedFields, 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, 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 field is 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'), 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; } // 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'); 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; 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) { 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) { 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; } 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 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 = _.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'); const resetToken = hat(256); const resetTokenCreationTime = new Date(); user.resetToken = resetToken; user.resetTokenCreationTime = resetTokenCreationTime; await update(user, { resetToken,resetTokenCreationTime }, auditSource); const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${user.resetToken}`; await mailer.passwordReset(user, user.fallbackEmail || user.email, resetLink); } 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()}/login.html?resetToken=${resetToken}`; return 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; 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 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); 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(); var 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'); }