454 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			454 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict';
 | |
| 
 | |
| const config = require('../lib/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');
 | |
| const crypto = require('crypto');
 | |
| const settings = require('./settings');
 | |
| const {getTrustedUrl} = require('../lib/urls');
 | |
| const { tUI } = require('../lib/translate');
 | |
| const messageSender = require('../lib/message-sender');
 | |
| const {getSystemSendConfigurationId} = require('../../shared/send-configurations');
 | |
| 
 | |
| const bluebird = require('bluebird');
 | |
| 
 | |
| const bcrypt = require('bcrypt-nodejs');
 | |
| const bcryptHash = bluebird.promisify(bcrypt.hash.bind(bcrypt));
 | |
| const bcryptCompare = bluebird.promisify(bcrypt.compare.bind(bcrypt));
 | |
| 
 | |
| 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');
 | |
| const contextHelpers = require('../lib/context-helpers');
 | |
| 
 | |
| function hash(entity) {
 | |
|     return hasher.hash(filterObject(entity, hashKeys));
 | |
| }
 | |
| 
 | |
| async function _getByTx(tx, context, key, value, extraColumns = []) {
 | |
|     const columns = ['id', 'username', 'name', 'email', 'namespace', 'role', ...extraColumns];
 | |
| 
 | |
|     const user = await tx('users').select(columns).where(key, value).first();
 | |
| 
 | |
|     if (!user) {
 | |
|         shares.throwPermissionDenied();
 | |
|     }
 | |
| 
 | |
|     // Note that getRestrictedAccessToken relies to this check to see whether a user may impersonate another. If "manageUsers" here were to be changed to something like "viewUsers", then
 | |
|     // a corresponding check has to be added to getRestrictedAccessToken
 | |
|     await shares.enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers');
 | |
| 
 | |
|     return user;
 | |
| }
 | |
| 
 | |
| async function _getBy(context, key, value, extraColumns = []) {
 | |
|     return await knex.transaction(async tx => {
 | |
|         return await _getByTx(tx, context, key, value, extraColumns);
 | |
|     });
 | |
| }
 | |
| 
 | |
| 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, entity, isCreate, isOwnAccount) {
 | |
|     enforce(await tools.validateEmail(entity.email) === 0, 'Invalid email');
 | |
| 
 | |
|     const otherUserWithSameEmailQuery = tx('users').where('email', entity.email);
 | |
|     if (entity.id) {
 | |
|         otherUserWithSameEmailQuery.andWhereNot('id', entity.id);
 | |
|     }
 | |
| 
 | |
|     if (await otherUserWithSameEmailQuery.first()) {
 | |
|         throw new interoperableErrors.DuplicitEmailError();
 | |
|     }
 | |
| 
 | |
| 
 | |
|     if (!isOwnAccount) {
 | |
|         await namespaceHelpers.validateEntity(tx, entity);
 | |
|         enforce(entity.role in config.roles.global, 'Unknown role');
 | |
| 
 | |
|         const otherUserWithSameUsernameQuery = tx('users').where('username', entity.username);
 | |
|         if (!isCreate) {
 | |
|             otherUserWithSameUsernameQuery.andWhereNot('id', entity.id);
 | |
|         }
 | |
| 
 | |
|         if (await otherUserWithSameUsernameQuery.first()) {
 | |
|             throw new interoperableErrors.DuplicitNameError();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     enforce(!isCreate || entity.password.length > 0, 'Password not set');
 | |
| 
 | |
|     if (entity.password) {
 | |
|         const passwordValidatorResults = passwordValidator.test(entity.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');
 | |
|         }
 | |
| 
 | |
|         entity.password = await bcryptHash(entity.password, null, null);
 | |
|     } else {
 | |
|         delete entity.password;
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function create(context, user) {
 | |
|     let id;
 | |
|     await knex.transaction(async tx => {
 | |
|         await shares.enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers');
 | |
| 
 | |
|         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.rebuildPermissionsTx(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.enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers');
 | |
|             await shares.enforceEntityPermissionTx(tx, 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.rebuildPermissionsTx(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.enforceEntityPermissionTx(tx, context, 'namespace', existing.namespace, 'manageUsers');
 | |
| 
 | |
|         await tx('users').where('id', userId).del();
 | |
|     });
 | |
| }
 | |
| 
 | |
| async function getByAccessToken(accessToken) {
 | |
|     return await _getBy(contextHelpers.getAdminContext(), 'access_token', accessToken);
 | |
| }
 | |
| 
 | |
| async function getByUsername(username) {
 | |
|     try{
 | |
|         return await _getBy(contextHelpers.getAdminContext(), 'username', username);
 | |
|     }catch(err){
 | |
|         throw new interoperableErrors.NotFoundError();
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function getByUsernameIfPasswordMatch(context, username, password) {
 | |
|     try {
 | |
|         const user = await _getBy(context, 'username', username, ['password']);
 | |
| 
 | |
|         if (!await bcryptCompare(password, user.password)) {
 | |
|             throw new interoperableErrors.IncorrectPasswordError();
 | |
|         }
 | |
| 
 | |
|         delete user.password;
 | |
| 
 | |
|         return user;
 | |
| 
 | |
|     } catch (err) {
 | |
|         if (err instanceof interoperableErrors.NotFoundError) {
 | |
|             throw new interoperableErrors.IncorrectPasswordError();
 | |
|         }
 | |
| 
 | |
|         throw err;
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function getAccessToken(userId) {
 | |
|     const user = await _getBy(contextHelpers.getAdminContext(), '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(locale, usernameOrEmail) {
 | |
|     enforce(passport.isAuthMethodLocal, 'Local user management is required');
 | |
| 
 | |
|     const resetToken = crypto.randomBytes(16).toString('base64').replace(/[^a-z0-9]/gi, '');
 | |
|     let user;
 | |
| 
 | |
|     await knex.transaction(async tx => {
 | |
|         user = await tx('users').where('username', usernameOrEmail).orWhere('email', usernameOrEmail).select(['id', 'username', 'email', 'name']).forUpdate().first();
 | |
| 
 | |
|         if (user) {
 | |
|             await tx('users').where('id', user.id).update({
 | |
|                 reset_token: resetToken,
 | |
|                 reset_expire: new Date(Date.now() + 60 * 60 * 1000)
 | |
|             });
 | |
|         }
 | |
|         // We intentionally silently ignore the situation when user is not found. This is not to reveal if a user exists in the system.
 | |
| 
 | |
|     });
 | |
| 
 | |
|     if (user) {
 | |
|         await messageSender.queueSubscriptionMessage(
 | |
|             getSystemSendConfigurationId(),
 | |
|             {
 | |
|                 address: user.email
 | |
|             },
 | |
|             tUI('mailerPasswordChangeRequest', locale),
 | |
|             null,
 | |
|             {
 | |
|                 html: 'users/password-reset-html.hbs',
 | |
|                 text: 'users/password-reset-text.hbs',
 | |
|                 locale,
 | |
|                 data: {
 | |
|                     title: tUI('mailtrain', locale),
 | |
|                     username: user.username,
 | |
|                     name: user.name,
 | |
|                     confirmUrl: getTrustedUrl(`login/reset/${encodeURIComponent(user.username)}/${encodeURIComponent(resetToken)}`)
 | |
|                 }
 | |
|             }
 | |
|         );
 | |
|     }
 | |
| }
 | |
| 
 | |
| 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();
 | |
|         }
 | |
|     });
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| const restrictedAccessTokenMethods = {};
 | |
| const restrictedAccessTokens = new Map();
 | |
| 
 | |
| function registerRestrictedAccessTokenMethod(method, getHandlerFromParams) {
 | |
|     restrictedAccessTokenMethods[method] = getHandlerFromParams;
 | |
| }
 | |
| 
 | |
| async function getRestrictedAccessToken(context, method, params) {
 | |
|     const token = crypto.randomBytes(24).toString('hex').toLowerCase();
 | |
|     const tokenEntry = {
 | |
|         token,
 | |
|         userId: context.user.id,
 | |
|         handler: await restrictedAccessTokenMethods[method](params),
 | |
|         expires: Date.now() + 120 * 1000
 | |
|     };
 | |
| 
 | |
|     restrictedAccessTokens.set(token, tokenEntry);
 | |
| 
 | |
|     return token;
 | |
| }
 | |
| 
 | |
| async function refreshRestrictedAccessToken(context, token) {
 | |
|     const tokenEntry = restrictedAccessTokens.get(token);
 | |
| 
 | |
|     if (tokenEntry && tokenEntry.userId === context.user.id) {
 | |
|         tokenEntry.expires = Date.now() + 120 * 1000
 | |
|     } else {
 | |
|         shares.throwPermissionDenied();
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function getByRestrictedAccessToken(token) {
 | |
|     const now = Date.now();
 | |
|     for (const entry of restrictedAccessTokens.values()) {
 | |
|         if (entry.expires < now) {
 | |
|             restrictedAccessTokens.delete(entry.token);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     const tokenEntry = restrictedAccessTokens.get(token);
 | |
| 
 | |
|     if (tokenEntry) {
 | |
|         const user = await getById(contextHelpers.getAdminContext(), tokenEntry.userId);
 | |
|         user.restrictedAccessMethod = tokenEntry.method;
 | |
|         user.restrictedAccessHandler = tokenEntry.handler;
 | |
|         user.restrictedAccessToken = tokenEntry.token;
 | |
|         user.restrictedAccessParams = tokenEntry.params;
 | |
| 
 | |
|         return user;
 | |
| 
 | |
|     } else {
 | |
|         shares.throwPermissionDenied();
 | |
|     }
 | |
| }
 | |
| 
 | |
| 
 | |
| module.exports.listDTAjax = listDTAjax;
 | |
| module.exports.remove = remove;
 | |
| module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
 | |
| module.exports.create = create;
 | |
| module.exports.hash = hash;
 | |
| module.exports.getById = getById;
 | |
| module.exports.serverValidate = serverValidate;
 | |
| module.exports.getByAccessToken = getByAccessToken;
 | |
| module.exports.getByUsername = getByUsername;
 | |
| module.exports.getByUsernameIfPasswordMatch = getByUsernameIfPasswordMatch;
 | |
| module.exports.getAccessToken = getAccessToken;
 | |
| module.exports.resetAccessToken = resetAccessToken;
 | |
| module.exports.sendPasswordReset = sendPasswordReset;
 | |
| module.exports.isPasswordResetTokenValid = isPasswordResetTokenValid;
 | |
| module.exports.resetPassword = resetPassword;
 | |
| module.exports.getByRestrictedAccessToken = getByRestrictedAccessToken;
 | |
| module.exports.getRestrictedAccessToken = getRestrictedAccessToken;
 | |
| module.exports.refreshRestrictedAccessToken = refreshRestrictedAccessToken;
 | |
| module.exports.registerRestrictedAccessTokenMethod = registerRestrictedAccessTokenMethod;
 |