'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 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; const randomBytes = util.promisify(crypto.randomBytes); let salt, derivedKey; [error, salt] = await safe(randomBytes(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'); const saltBuffer = Buffer.from(user.salt, 'hex'); let derivedKey; [error, derivedKey] = await safe(pbkdf2Async(newPassword, saltBuffer, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST)); if (error) throw new BoxError(BoxError.CRYPTO_ERROR, error); const data = { 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); // 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'); }