UNPKG

@directus/api

Version:

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

445 lines (443 loc) 17.6 kB
import { useLogger } from "../logger/index.js"; import { UserIntegrityCheckFlag } from "../packages/types/dist/index.js"; import { DEFAULT_AUTH_PROVIDER } from "../constants.js"; import { clearSystemCache } from "../cache.js"; import database_default from "../database/index.js"; import { validateAccess } from "../permissions/modules/validate-access/validate-access.js"; import { getSecret } from "../utils/get-secret.js"; import { ItemsService } from "./items.js"; import { Url } from "../utils/url.js"; import { MailService } from "./mail/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 isUrlAllowed from "../utils/is-url-allowed.js"; import { verifyJWT } from "../utils/jwt.js"; import { stall } from "../utils/stall.js"; import { getEntitlementManager } from "../license/entitlements/manager.js"; import { SettingsService } from "./settings.js"; import "../license/index.js"; import { useEnv } from "@directus/env"; import { ForbiddenError, InvalidInviteError, InvalidPayloadError, RecordNotUniqueError } from "@directus/errors"; import { getSimpleHash, toArray, validatePayload } from "@directus/utils"; import { isEmpty } from "lodash-es"; import { USER_INACTIVE_LICENSE_STATUS } from "@directus/constants"; import { performance } from "perf_hooks"; import Joi from "joi"; import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from "@directus/validation"; import jwt from "jsonwebtoken"; //#region src/services/users.ts const env = useEnv(); const logger = useLogger(); var UsersService = class UsersService extends ItemsService { constructor(options) { super("directus_users", options); this.knex = options.knex || database_default(); 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); if ((await query).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 { auth_password_policy: policyRegExString } = await new SettingsService({ schema: this.schema, knex: this.knex }).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", "provider").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: [] }); } } /** * Block setting a non-default auth provider when the current license isn't entitled to SSO */ checkProviderEntitlement(input) { if ((Array.isArray(input) ? input : [input]).some((provider) => provider && provider !== DEFAULT_AUTH_PROVIDER) && !getEntitlementManager().isEntitled("sso_enabled")) throw new InvalidPayloadError({ reason: `Setting a custom "provider" isn't included in the current license` }); } /** * Create a new user */ async createOne(data, opts = {}) { try { if ("email" in data && data["email"] !== void 0) { this.validateEmail(data["email"]); await this.checkUniqueEmails([data["email"]]); } if ("password" in data) await this.checkPasswordPolicy([data["password"]]); if ("provider" in data) this.checkProviderEntitlement(data["provider"]); } catch (err) { opts.preMutationError = err; } if (!("status" in data) || data["status"] === "active") { 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 providers = data.map((payload) => payload["provider"]).filter((provider) => provider); 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); if (providers.length) this.checkProviderEntitlement(providers); } catch (err) { opts.preMutationError = err; } if (someActive) { opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits; opts.onRequireUserIntegrityCheck?.(opts.userIntegrityCheckFlags); } return await new ItemsService(this.collection, { schema: this.schema, accountability: this.accountability, knex: this.knex }).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"] !== void 0) throw new InvalidPayloadError({ reason: `You can't change the "tfa_secret" value manually` }); if (data["provider"] !== void 0) { if (this.accountability && this.accountability.admin !== true) throw new InvalidPayloadError({ reason: `You can't change the "provider" value manually` }); this.checkProviderEntitlement(data["provider"]); data["auth_data"] = null; } if (data["external_identifier"] !== void 0) { 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") opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits; else opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.RemainingAdmins; if (opts.userIntegrityCheckFlags) opts.onRequireUserIntegrityCheck?.(opts.userIntegrityCheckFlags); const result = await super.updateMany(keys, data, opts); if (data["status"] !== void 0 && data["status"] !== "active") await this.clearUserSessions(keys); else if (data["password"] !== void 0 || data["email"] !== void 0) await this.clearUserSessions(keys, this.accountability?.session); if ("role" in data) await this.clearCaches(opts); return result; } /** * Delete multiple users by primary key */ async deleteMany(keys, opts = {}) { if (this.accountability) await validateAccess({ collection: "directus_users", action: "delete", accountability: this.accountability, primaryKeys: keys }, { knex: this.knex, schema: this.schema }); 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; } 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); await getEntitlementManager().clearCache("seats", "sso_enabled"); 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$1 of emails) { const user = await this.getUserByEmail(email$1); if (isEmpty(user)) await this.createOne({ email: email$1, role, status: "invited" }, opts); else if (user.status === "invited" && user.role !== role) await this.updateOne(user.id, { role }, opts); if (isEmpty(user) || user.status === "invited") { const subjectLine = subject ?? "You've been invited"; mailService.send({ to: user?.email ?? email$1, subject: subjectLine, template: { name: "user-invitation", data: { url: this.inviteUrl(user?.email ?? email$1, url), email: user?.email ?? email$1 } } }).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 InvalidInviteError(); const service = new UsersService({ knex: this.knex, schema: this.schema }); const { allowed: isWithinLicenseLimits } = await getEntitlementManager().check("seats"); const status = isWithinLicenseLimits ? "active" : USER_INACTIVE_LICENSE_STATUS; await service.updateOne(user.id, { password, status }); } 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 settings = await new SettingsService(serviceOptions).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 = { email: input.email, password: input.password, role: publicRegistrationRole, status: hasEmailVerification ? "unverified" : "active", 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); else if (user.status !== "unverified") { 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, getSecret(), { 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", 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, getSecret()); 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(); 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 user = await this.getUserByEmail(email); if (user?.status !== "active") { await stall(STALL_TIME, timeStart); throw new ForbiddenError(); } if (user.provider !== DEFAULT_AUTH_PROVIDER) { await stall(STALL_TIME, timeStart); throw new ForbiddenError(); } 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(); await new UsersService({ knex: this.knex, schema: this.schema, accountability: { ...this.accountability ?? createDefaultAccountability(), admin: true } }).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(); } }; //#endregion export { UsersService };