UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

523 lines (522 loc) 21.8 kB
import { useEnv } from '@directus/env'; import { ForbiddenError, InvalidPayloadError, RecordNotUniqueError } from '@directus/errors'; import { UserIntegrityCheckFlag } from '@directus/types'; import { getSimpleHash, toArray, validatePayload } from '@directus/utils'; import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from '@directus/validation'; import Joi from 'joi'; import jwt from 'jsonwebtoken'; import { isEmpty } from 'lodash-es'; import { performance } from 'perf_hooks'; import { clearSystemCache } from '../cache.js'; import getDatabase from '../database/index.js'; import { useLogger } from '../logger/index.js'; import { validateRemainingAdminUsers } from '../permissions/modules/validate-remaining-admin/validate-remaining-admin-users.js'; import { createDefaultAccountability } from '../permissions/utils/create-default-accountability.js'; import { getSecret } from '../utils/get-secret.js'; import isUrlAllowed from '../utils/is-url-allowed.js'; import { verifyJWT } from '../utils/jwt.js'; import { stall } from '../utils/stall.js'; import { Url } from '../utils/url.js'; import { ItemsService } from './items.js'; import { MailService } from './mail/index.js'; import { SettingsService } from './settings.js'; const env = useEnv(); const logger = useLogger(); export class UsersService extends ItemsService { constructor(options) { super('directus_users', options); this.knex = options.knex || getDatabase(); this.accountability = options.accountability || null; this.schema = options.schema; } /** * User email has to be unique case-insensitive. This is an additional check to make sure that * the email is unique regardless of casing */ async checkUniqueEmails(emails, excludeKey) { emails = emails.map((email) => email.toLowerCase()); const duplicates = emails.filter((value, index, array) => array.indexOf(value) !== index); if (duplicates.length) { throw new RecordNotUniqueError({ collection: 'directus_users', field: 'email', value: '[' + String(duplicates) + ']', }); } const query = this.knex .select('email') .from('directus_users') .whereRaw(`LOWER(??) IN (${emails.map(() => '?')})`, ['email', ...emails]); if (excludeKey) { query.whereNot('id', excludeKey); } const results = await query; if (results.length) { throw new RecordNotUniqueError({ collection: 'directus_users', field: 'email', value: '[' + String(emails) + ']', }); } } /** * Check if the provided password matches the strictness as configured in * directus_settings.auth_password_policy */ async checkPasswordPolicy(passwords) { const settingsService = new SettingsService({ schema: this.schema, knex: this.knex, }); const { auth_password_policy: policyRegExString } = await settingsService.readSingleton({ fields: ['auth_password_policy'], }); if (!policyRegExString) { return; } const wrapped = policyRegExString.startsWith('/') && policyRegExString.endsWith('/'); const regex = new RegExp(wrapped ? policyRegExString.slice(1, -1) : policyRegExString); for (const password of passwords) { if (!regex.test(password)) { throw new FailedValidationError(joiValidationErrorItemToErrorExtensions({ message: `Provided password doesn't match password policy`, path: ['password'], type: 'custom.pattern.base', context: { value: password, }, })); } } } /** * Clear users' sessions to log them out */ async clearUserSessions(userKeys, excludeSession) { if (excludeSession) { await this.knex .from('directus_sessions') .whereIn('user', userKeys) .andWhereNot('token', '=', excludeSession) .delete(); } else { await this.knex.from('directus_sessions').whereIn('user', userKeys).delete(); } } /** * Get basic information of user identified by email */ async getUserByEmail(email) { return this.knex .select('id', 'role', 'status', 'password', 'email') .from('directus_users') .whereRaw(`LOWER(??) = ?`, ['email', email.toLowerCase()]) .first(); } /** * Create URL for inviting users */ inviteUrl(email, url) { const payload = { email, scope: 'invite' }; const token = jwt.sign(payload, getSecret(), { expiresIn: env['USER_INVITE_TOKEN_TTL'], issuer: 'directus', }); return (url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite')) .setQuery('token', token) .toString(); } /** * Validate array of emails. Intended to be used with create/update users */ validateEmail(input) { const emails = Array.isArray(input) ? input : [input]; const schema = Joi.string().email().required(); for (const email of emails) { const { error } = schema.validate(email); if (error) { throw new FailedValidationError({ field: 'email', type: 'email', path: [], }); } } } /** * Create a new user */ async createOne(data, opts = {}) { try { if ('email' in data && data['email'] !== undefined) { this.validateEmail(data['email']); await this.checkUniqueEmails([data['email']]); } if ('password' in data) { await this.checkPasswordPolicy([data['password']]); } } catch (err) { opts.preMutationError = err; } if (!('status' in data) || data['status'] === 'active') { // Creating a user only requires checking user limits if the user is active, no need to care about the role opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits; opts.onRequireUserIntegrityCheck?.(opts.userIntegrityCheckFlags); } return await super.createOne(data, opts); } /** * Create multiple new users */ async createMany(data, opts = {}) { const emails = data.map((payload) => payload['email']).filter((email) => email); const passwords = data.map((payload) => payload['password']).filter((password) => password); const someActive = data.some((payload) => !('status' in payload) || payload['status'] === 'active'); try { if (emails.length) { this.validateEmail(emails); await this.checkUniqueEmails(emails); } if (passwords.length) { await this.checkPasswordPolicy(passwords); } } catch (err) { opts.preMutationError = err; } if (someActive) { // Creating users only requires checking user limits if the users are active, no need to care about the role opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits; opts.onRequireUserIntegrityCheck?.(opts.userIntegrityCheckFlags); } // Use generic ItemsService to avoid calling `UserService.createOne` to avoid additional work of validating emails, // as this requires one query per email if done in `createOne` const itemsService = new ItemsService(this.collection, { schema: this.schema, accountability: this.accountability, knex: this.knex, }); return await itemsService.createMany(data, opts); } /** * Update many users by primary key */ async updateMany(keys, data, opts = {}) { try { if (data['email']) { if (keys.length > 1) { throw new RecordNotUniqueError({ collection: 'directus_users', field: 'email', value: data['email'], }); } this.validateEmail(data['email']); await this.checkUniqueEmails([data['email']], keys[0]); } if (data['password']) { await this.checkPasswordPolicy([data['password']]); } if (data['tfa_secret'] !== undefined) { throw new InvalidPayloadError({ reason: `You can't change the "tfa_secret" value manually` }); } if (data['provider'] !== undefined) { if (this.accountability && this.accountability.admin !== true) { throw new InvalidPayloadError({ reason: `You can't change the "provider" value manually` }); } data['auth_data'] = null; } if (data['external_identifier'] !== undefined) { if (this.accountability && this.accountability.admin !== true) { throw new InvalidPayloadError({ reason: `You can't change the "external_identifier" value manually` }); } data['auth_data'] = null; } } catch (err) { opts.preMutationError = err; } if ('role' in data) { opts.userIntegrityCheckFlags = UserIntegrityCheckFlag.All; } if ('status' in data) { if (data['status'] === 'active') { // User are being activated, no need to check if there are enough admins opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits; } else { opts.userIntegrityCheckFlags = UserIntegrityCheckFlag.All; } } if (opts.userIntegrityCheckFlags) { opts.onRequireUserIntegrityCheck?.(opts.userIntegrityCheckFlags); } const result = await super.updateMany(keys, data, opts); if (data['status'] !== undefined && data['status'] !== 'active') { await this.clearUserSessions(keys); } else if (data['password'] !== undefined || data['email'] !== undefined) { await this.clearUserSessions(keys, this.accountability?.session); } // Only clear the caches if the role has been updated if ('role' in data) { await this.clearCaches(opts); } return result; } /** * Delete multiple users by primary key */ async deleteMany(keys, opts = {}) { if (opts?.onRequireUserIntegrityCheck) { opts.onRequireUserIntegrityCheck(opts?.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None); } else { try { await validateRemainingAdminUsers({ excludeUsers: keys }, { knex: this.knex, schema: this.schema }); } catch (err) { opts.preMutationError = err; } } // Manual constraint, see https://github.com/directus/directus/pull/19912 await this.knex('directus_comments').update({ user_updated: null }).whereIn('user_updated', keys); await this.knex('directus_notifications').update({ sender: null }).whereIn('sender', keys); await this.knex('directus_versions').update({ user_updated: null }).whereIn('user_updated', keys); await super.deleteMany(keys, opts); await this.clearUserSessions(keys); return keys; } async inviteUser(email, role, url, subject) { const opts = {}; try { if (url && isUrlAllowed(url, env['USER_INVITE_URL_ALLOW_LIST']) === false) { throw new InvalidPayloadError({ reason: `URL "${url}" can't be used to invite users` }); } } catch (err) { opts.preMutationError = err; } const emails = toArray(email); const mailService = new MailService({ schema: this.schema, accountability: this.accountability, }); for (const email of emails) { // Check if user is known const user = await this.getUserByEmail(email); // Create user first to verify uniqueness if unknown if (isEmpty(user)) { await this.createOne({ email, role, status: 'invited' }, opts); // For known users update role if changed } else if (user.status === 'invited' && user.role !== role) { await this.updateOne(user.id, { role }, opts); } // Send invite for new and already invited users if (isEmpty(user) || user.status === 'invited') { const subjectLine = subject ?? "You've been invited"; mailService .send({ to: user?.email ?? email, subject: subjectLine, template: { name: 'user-invitation', data: { url: this.inviteUrl(user?.email ?? email, url), email: user?.email ?? email, }, }, }) .catch((error) => { logger.error(error, `Could not send user invitation mail`); }); } } } async acceptInvite(token, password) { const { email, scope } = verifyJWT(token, getSecret()); if (scope !== 'invite') throw new ForbiddenError(); const user = await this.getUserByEmail(email); if (user?.status !== 'invited') { throw new InvalidPayloadError({ reason: `Email address ${email} hasn't been invited` }); } // Allow unauthenticated update const service = new UsersService({ knex: this.knex, schema: this.schema, }); await service.updateOne(user.id, { password, status: 'active' }); } async registerUser(input) { if (input.verification_url && isUrlAllowed(input.verification_url, env['USER_REGISTER_URL_ALLOW_LIST']) === false) { throw new InvalidPayloadError({ reason: `URL "${input.verification_url}" can't be used to verify registered users`, }); } const STALL_TIME = env['REGISTER_STALL_TIME']; const timeStart = performance.now(); const serviceOptions = { accountability: this.accountability, schema: this.schema }; const settingsService = new SettingsService(serviceOptions); const settings = await settingsService.readSingleton({ fields: [ 'public_registration', 'public_registration_verify_email', 'public_registration_role', 'public_registration_email_filter', ], }); if (settings?.['public_registration'] == false) { throw new ForbiddenError(); } const publicRegistrationRole = settings?.['public_registration_role'] ?? null; const hasEmailVerification = settings?.['public_registration_verify_email']; const emailFilter = settings?.['public_registration_email_filter']; const first_name = input.first_name ?? null; const last_name = input.last_name ?? null; const partialUser = { // Required fields email: input.email, password: input.password, role: publicRegistrationRole, status: hasEmailVerification ? 'unverified' : 'active', // Optional fields first_name, last_name, }; if (emailFilter && validatePayload(emailFilter, { email: input.email }).length !== 0) { await stall(STALL_TIME, timeStart); throw new ForbiddenError(); } const user = await this.getUserByEmail(input.email); if (isEmpty(user)) { await this.createOne(partialUser); } // We want to be able to re-send the verification email else if (user.status !== 'unverified') { // To avoid giving attackers infos about registered emails we dont fail for violated unique constraints await stall(STALL_TIME, timeStart); return; } if (hasEmailVerification) { const mailService = new MailService(serviceOptions); const payload = { email: input.email, scope: 'pending-registration' }; const token = jwt.sign(payload, env['SECRET'], { expiresIn: env['EMAIL_VERIFICATION_TOKEN_TTL'], issuer: 'directus', }); const verificationUrl = (input.verification_url ? new Url(input.verification_url) : new Url(env['PUBLIC_URL']).addPath('users', 'register', 'verify-email')) .setQuery('token', token) .toString(); mailService .send({ to: input.email, subject: 'Verify your email address', // TODO: translate after theres support for internationalized emails template: { name: 'user-registration', data: { url: verificationUrl, email: input.email, first_name, last_name, }, }, }) .catch((error) => { logger.error(error, 'Could not send email verification mail'); }); } await stall(STALL_TIME, timeStart); } async verifyRegistration(token) { const { email, scope } = verifyJWT(token, env['SECRET']); if (scope !== 'pending-registration') throw new ForbiddenError(); const user = await this.getUserByEmail(email); if (user?.status !== 'unverified') { throw new InvalidPayloadError({ reason: 'Invalid verification code' }); } await this.updateOne(user.id, { status: 'active' }); return user.id; } async requestPasswordReset(email, url, subject) { const STALL_TIME = 500; const timeStart = performance.now(); const user = await this.getUserByEmail(email); if (user?.status !== 'active') { await stall(STALL_TIME, timeStart); throw new ForbiddenError(); } if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) { throw new InvalidPayloadError({ reason: `URL "${url}" can't be used to reset passwords` }); } const mailService = new MailService({ schema: this.schema, knex: this.knex, accountability: this.accountability, }); const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) }; const token = jwt.sign(payload, getSecret(), { expiresIn: '1d', issuer: 'directus' }); const acceptUrl = (url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password')) .setQuery('token', token) .toString(); const subjectLine = subject ? subject : 'Password Reset Request'; mailService .send({ to: user.email, subject: subjectLine, template: { name: 'password-reset', data: { url: acceptUrl, email: user.email, }, }, }) .catch((error) => { logger.error(error, `Could not send password reset mail`); }); await stall(STALL_TIME, timeStart); } async resetPassword(token, password) { const { email, scope, hash } = verifyJWT(token, getSecret()); if (scope !== 'password-reset' || !hash) throw new ForbiddenError(); const opts = {}; try { await this.checkPasswordPolicy([password]); } catch (err) { opts.preMutationError = err; } const user = await this.getUserByEmail(email); if (user?.status !== 'active' || hash !== getSimpleHash('' + user.password)) { throw new ForbiddenError(); } // Allow unauthenticated update const service = new UsersService({ knex: this.knex, schema: this.schema, accountability: { ...(this.accountability ?? createDefaultAccountability()), admin: true, // We need to skip permissions checks for the update call below }, }); await service.updateOne(user.id, { password, status: 'active' }, opts); } async clearCaches(opts) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); if (this.cache && opts?.autoPurgeCache !== false) { await this.cache.clear(); } } }