'use strict'; const config = require('config'); const knex = require('../lib/knex'); const hasher = require('node-object-hash')(); const { enforce, filterObject } = require('../lib/helpers'); const interoperableErrors = require('../shared/interoperable-errors'); const passwordValidator = require('../shared/password-validator')(); const dtHelpers = require('../lib/dt-helpers'); const tools = require('../lib/tools-async'); let crypto = require('crypto'); const settings = require('./settings'); const urllib = require('url'); const _ = require('../lib/translate')._; const bluebird = require('bluebird'); const bcrypt = require('bcrypt-nodejs'); const bcryptHash = bluebird.promisify(bcrypt.hash); const bcryptCompare = bluebird.promisify(bcrypt.compare); const mailer = require('../lib/mailer'); const mailerSendMail = bluebird.promisify(mailer.sendMail); const passport = require('../lib/passport'); const namespaceHelpers = require('../lib/namespace-helpers'); const allowedKeys = new Set(['username', 'name', 'email', 'password', 'namespace', 'role']); const ownAccountAllowedKeys = new Set(['name', 'email', 'password']); const allowedKeysExternal = new Set(['username', 'namespace', 'role']); const hashKeys = new Set(['username', 'name', 'email', 'namespace', 'role']); const shares = require('./shares'); function hash(entity) { return hasher.hash(filterObject(entity, hashKeys)); } async function _getBy(context, key, value, extraColumns) { const columns = ['id', 'username', 'name', 'email', 'namespace', 'role']; if (extraColumns) { columns.push(...extraColumns); } const user = await knex('users').select(columns).where(key, value).first(); if (!user) { if (context) { shares.throwPermissionDenied(); } else { throw new interoperableErrors.NotFoundError(); } } if (context) { await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); } return user; } async function getById(context, id) { return await _getBy(context, 'id', id); } async function serverValidate(context, data, isOwnAccount) { const result = {}; if (!isOwnAccount) { await shares.enforceTypePermission(context, 'namespace', 'manageUsers'); } if (!isOwnAccount && data.username) { const query = knex('users').select(['id']).where('username', data.username); if (data.id) { // Id is not set in entity creation form query.andWhereNot('id', data.id); } const user = await query.first(); result.username = { exists: !!user }; } if (isOwnAccount && data.currentPassword) { const user = await knex('users').select(['id', 'password']).where('id', data.id).first(); result.currentPassword = {}; result.currentPassword.incorrect = !await bcryptCompare(data.currentPassword, user.password); } if (data.email) { const query = knex('users').select(['id']).where('email', data.email); if (data.id) { // Id is not set in entity creation form query.andWhereNot('id', data.id); } const user = await query.first(); result.email = {}; result.email.invalid = await tools.validateEmail(data.email) !== 0; result.email.exists = !!user; } return result; } async function listDTAjax(context, params) { return await dtHelpers.ajaxListWithPermissions( context, [{ entityTypeId: 'namespace', requiredOperations: ['manageUsers'] }], params, builder => builder .from('users') .innerJoin('namespaces', 'namespaces.id', 'users.namespace') .innerJoin('generated_role_names', 'generated_role_names.role', 'users.role') .where('generated_role_names.entity_type', 'global'), [ 'users.id', 'users.username', 'users.name', 'namespaces.name', 'generated_role_names.name' ] ); } async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) { enforce(await tools.validateEmail(user.email) === 0, 'Invalid email'); await namespaceHelpers.validateEntity(tx, user); const otherUserWithSameEmailQuery = tx('users').where('email', user.email); if (user.id) { otherUserWithSameEmailQuery.andWhereNot('id', user.id); } if (await otherUserWithSameEmailQuery.first()) { throw new interoperableErrors.DuplicitEmailError(); } if (!isOwnAccount) { const otherUserWithSameUsernameQuery = tx('users').where('username', user.username); if (user.id) { otherUserWithSameUsernameQuery.andWhereNot('id', user.id); } if (await otherUserWithSameUsernameQuery.first()) { throw new interoperableErrors.DuplicitNameError(); } } enforce(user.role in config.roles.global, 'Unknown role'); enforce(!isCreate || user.password.length > 0, 'Password not set'); if (user.password) { const passwordValidatorResults = passwordValidator.test(user.password); if (passwordValidatorResults.errors.length > 0) { // This is not an interoperable error because this is not supposed to happen unless the client is tampered with. throw new Error('Invalid password'); } user.password = await bcryptHash(user.password, null, null); } else { delete user.password; } } async function create(context, user) { if (context) { // Is also called internally from ldap handling in passport await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); } let id; await knex.transaction(async tx => { if (passport.isAuthMethodLocal) { await _validateAndPreprocess(tx, user, true); const ids = await tx('users').insert(filterObject(user, allowedKeys)); id = ids[0]; } else { const filteredUser = filterObject(user, allowedKeysExternal); enforce(user.role in config.roles.global, 'Unknown role'); await namespaceHelpers.validateEntity(tx, user); const ids = await tx('users').insert(filteredUser); id = ids[0]; } await shares.rebuildPermissions(tx, { userId: id }); }); return id; } async function updateWithConsistencyCheck(context, user, isOwnAccount) { await knex.transaction(async tx => { const existing = await tx('users').where('id', user.id).first(); if (!existing) { shares.throwPermissionDenied(); } const existingHash = hash(existing); if (existingHash !== user.originalHash) { throw new interoperableErrors.ChangedError(); } if (!isOwnAccount) { await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); await shares.enforceEntityPermission(context, 'namespace', existing.namespace, 'manageUsers'); } if (passport.isAuthMethodLocal) { await _validateAndPreprocess(tx, user, false, isOwnAccount); if (isOwnAccount && user.password) { if (!await bcryptCompare(user.currentPassword, existing.password)) { throw new interoperableErrors.IncorrectPasswordError(); } } await tx('users').where('id', user.id).update(filterObject(user, isOwnAccount ? ownAccountAllowedKeys : allowedKeys)); } else { enforce(!isOwnAccount, 'Local user management is required'); enforce(user.role in config.roles.global, 'Unknown role'); await namespaceHelpers.validateEntity(tx, user); await tx('users').where('id', user.id).update(filterObject(user, allowedKeysExternal)); } // Removes the default shares based on the user role and rebuilds permissions. // rebuildPermissions adds the default shares based on the user role, which will reflect the changes // done to the user. if (existing.namespace !== user.namespace || existing.role !== user.role) { await shares.removeDefaultShares(tx, existing); } await shares.rebuildPermissions(tx, { userId: user.id }); }); } async function remove(context, userId) { enforce(userId !== 1, 'Admin cannot be deleted'); enforce(context.user.id !== userId, 'User cannot delete himself/herself'); await knex.transaction(async tx => { const existing = await tx('users').where('id', userId).first(); if (!existing) { shares.throwPermissionDenied(); } await shares.enforceEntityPermission(context, 'namespace', existing.namespace, 'manageUsers'); await tx('users').where('id', userId).del(); }); } async function getByAccessToken(accessToken) { return await _getBy(null, 'access_token', accessToken); } async function getByUsername(username) { return await _getBy(null, 'username', username); } async function getByUsernameIfPasswordMatch(username, password) { try { const user = await _getBy(null, 'username', username, ['password']); if (!await bcryptCompare(password, user.password)) { throw new interoperableErrors.IncorrectPasswordError(); } return user; } catch (err) { if (err instanceof interoperableErrors.NotFoundError) { throw new interoperableErrors.IncorrectPasswordError(); } throw err; } } async function getAccessToken(userId) { const user = await _getBy(null, 'id', userId, ['access_token']); return user.access_token; } async function resetAccessToken(userId) { const token = crypto.randomBytes(20).toString('hex').toLowerCase(); await knex('users').where({id: userId}).update({access_token: token}); return token; } async function sendPasswordReset(usernameOrEmail) { enforce(passport.isAuthMethodLocal, 'Local user management is required'); await knex.transaction(async tx => { const user = await tx('users').where('username', usernameOrEmail).orWhere('email', usernameOrEmail).select(['id', 'username', 'email', 'name']).first(); if (user) { const resetToken = crypto.randomBytes(16).toString('base64').replace(/[^a-z0-9]/gi, ''); await tx('users').where('id', user.id).update({ reset_token: resetToken, reset_expire: new Date(Date.now() + 60 * 60 * 1000) }); const { serviceUrl, adminEmail } = await settings.get(['serviceUrl', 'adminEmail']); await mailerSendMail({ from: { address: adminEmail }, to: { address: user.email }, subject: _('Mailer password change request') }, { html: 'emails/password-reset-html.hbs', text: 'emails/password-reset-text.hbs', data: { title: 'Mailtrain', username: user.username, name: user.name, confirmUrl: urllib.resolve(serviceUrl, `/account/reset/${encodeURIComponent(user.username)}/${encodeURIComponent(resetToken)}`) } }); } // We intentionally silently ignore the situation when user is not found. This is not to reveal if a user exists in the system. }); } async function isPasswordResetTokenValid(username, resetToken) { enforce(passport.isAuthMethodLocal, 'Local user management is required'); const user = await knex('users').select(['id']).where({username, reset_token: resetToken}).andWhere('reset_expire', '>', new Date()).first(); return !!user; } async function resetPassword(username, resetToken, password) { enforce(passport.isAuthMethodLocal, 'Local user management is required'); await knex.transaction(async tx => { const user = await tx('users').select(['id']).where({ username, reset_token: resetToken }).andWhere('reset_expire', '>', new Date()).first(); if (user) { const passwordValidatorResults = passwordValidator.test(password); if (passwordValidatorResults.errors.length > 0) { // This is not an interoperable error because this is not supposed to happen unless the client is tampered with. throw new Error('Invalid password'); } password = await bcryptHash(password, null, null); await tx('users').where({username}).update({ password, reset_token: null, reset_expire: null }); } else { throw new interoperableErrors.InvalidTokenError(); } }); } module.exports = { listDTAjax, remove, updateWithConsistencyCheck, create, hash, getById, serverValidate, getByAccessToken, getByUsername, getByUsernameIfPasswordMatch, getAccessToken, resetAccessToken, sendPasswordReset, isPasswordResetTokenValid, resetPassword };