@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
445 lines (443 loc) • 17.6 kB
JavaScript
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 };