UNPKG

@directus/api

Version:

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

385 lines (383 loc) 15.4 kB
import { getMilliseconds } from "../utils/get-milliseconds.js"; import { DEFAULT_AUTH_PROVIDER } from "../constants.js"; import database_default from "../database/index.js"; import { fetchRolesTree } from "../permissions/lib/fetch-roles-tree.js"; import { fetchGlobalAccess } from "../permissions/modules/fetch-global-access/fetch-global-access.js"; import emitter_default from "../emitter.js"; import { getSecret } from "../utils/get-secret.js"; import { PayloadService } from "./payload.js"; import { ActivityService } from "./activity.js"; import { RateLimiterRes, createRateLimiter } from "../rate-limiter.js"; import { stall } from "../utils/stall.js"; import { getEntitlementManager } from "../license/entitlements/manager.js"; import { RevisionsService } from "./revisions.js"; import { SettingsService } from "./settings.js"; import { getAuthProvider } from "../auth.js"; import { TFAService } from "./tfa.js"; import { getLicenseManager } from "../license/manager.js"; import "../license/index.js"; import { useEnv } from "@directus/env"; import { InvalidCredentialsError, InvalidOtpError, ResourceRestrictedError, ServiceUnavailableError, UserSuspendedError } from "@directus/errors"; import { clone, cloneDeep } from "lodash-es"; import { Action } from "@directus/constants"; import { performance } from "perf_hooks"; import jwt from "jsonwebtoken"; //#region src/services/authentication.ts const env = useEnv(); const loginAttemptsLimiter = createRateLimiter("RATE_LIMITER", { duration: 0 }); var AuthenticationService = class { knex; accountability; activityService; schema; constructor(options) { this.knex = options.knex || database_default(); this.accountability = options.accountability || null; this.activityService = new ActivityService({ knex: this.knex, schema: options.schema }); this.schema = options.schema; } /** * Retrieve the tokens for a given user email. * * Password is optional to allow usage of this function within the SSO flow and extensions. Make sure * to handle password existence checks elsewhere */ async login(providerName = DEFAULT_AUTH_PROVIDER, payload, options) { const { nanoid } = await import("nanoid"); const STALL_TIME = env["LOGIN_STALL_TIME"]; const timeStart = performance.now(); const provider = getAuthProvider(providerName); const emitStatus = (status, loginPayload, loginUser, error) => { emitter_default.emitAction("auth.login", { payload: loginPayload, status, user: loginUser?.id, provider: providerName, error }, { database: this.knex, schema: this.schema, accountability: this.accountability }); }; let userId; try { userId = await provider.getUserID(cloneDeep(payload)); } catch (err) { emitStatus("fail", payload, void 0, err); await stall(STALL_TIME, timeStart); throw err; } const user = await this.knex.select("id", "first_name", "last_name", "email", "password", "status", "role", "tfa_secret", "provider", "external_identifier", "auth_data").from("directus_users").where("id", userId).first(); const updatedPayload = await emitter_default.emitFilter("auth.login", payload, { status: "pending", user: user?.id, provider: providerName }, { database: this.knex, schema: this.schema, accountability: this.accountability }); if (user?.status !== "active" || user?.provider !== providerName) { const loginError = new InvalidCredentialsError(); emitStatus("fail", updatedPayload, user, loginError); await stall(STALL_TIME, timeStart); throw loginError; } const { auth_login_attempts: allowedAttempts } = await new SettingsService({ knex: this.knex, schema: this.schema }).readSingleton({ fields: ["auth_login_attempts"] }); if (allowedAttempts !== null) { loginAttemptsLimiter.points = allowedAttempts; try { await loginAttemptsLimiter.consume(user.id); } catch (error) { if (error instanceof RateLimiterRes && error.remainingPoints === 0) { await this.knex("directus_users").update({ status: "suspended" }).where({ id: user.id }); await getEntitlementManager().clearCache("sso_enabled", "seats"); if (this.accountability) { const activity = await this.activityService.createOne({ action: Action.UPDATE, user: user.id, ip: this.accountability.ip, user_agent: this.accountability.userAgent, origin: this.accountability.origin, collection: "directus_users", item: user.id }); const revisionsService = new RevisionsService({ knex: this.knex, schema: this.schema }); const payloadService = new PayloadService("directus_users", { accountability: this.accountability, knex: this.knex, schema: this.schema }); await revisionsService.createOne({ activity, collection: "directus_users", item: user.id, data: await payloadService.prepareDelta(user), delta: { status: "suspended" } }); } user.status = "suspended"; await loginAttemptsLimiter.set(user.id, 0, 0); } else throw new ServiceUnavailableError({ service: "authentication", reason: "Rate limiter unreachable" }); } } try { await provider.login(clone(user), cloneDeep(updatedPayload)); } catch (err) { emitStatus("fail", updatedPayload, user, err); await stall(STALL_TIME, timeStart); throw err; } if (user.tfa_secret && !options?.otp) { const loginError = new InvalidOtpError(); emitStatus("fail", updatedPayload, user, loginError); await stall(STALL_TIME, timeStart); throw loginError; } if (user.tfa_secret && options?.otp) { if (await new TFAService({ knex: this.knex, schema: this.schema }).verifyOTP(user.id, options?.otp) === false) { const loginError = new InvalidOtpError(); emitStatus("fail", updatedPayload, user, loginError); await stall(STALL_TIME, timeStart); throw loginError; } } const globalAccess = await fetchGlobalAccess({ roles: await fetchRolesTree(user.role, { knex: this.knex }), user: user.id, ip: this.accountability?.ip ?? null }, { knex: this.knex }); if (await getLicenseManager().isLocked() && globalAccess.admin === false) throw new ResourceRestrictedError({ category: "login" }); const tokenPayload = { id: user.id, role: user.role, app_access: globalAccess.app, admin_access: globalAccess.admin }; if (!user.tfa_secret) { if (await this.knex.select("directus_policies.enforce_tfa").from("directus_users").leftJoin("directus_roles", "directus_users.role", "directus_roles.id").leftJoin("directus_access", "directus_roles.id", "directus_access.role").leftJoin("directus_policies", "directus_access.policy", "directus_policies.id").where("directus_users.id", user.id).where("directus_policies.enforce_tfa", true).first()) tokenPayload.enforce_tfa = true; } const refreshToken = nanoid(64); const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env["REFRESH_TOKEN_TTL"], 0)); if (options?.session) tokenPayload.session = refreshToken; const customClaims = await emitter_default.emitFilter("auth.jwt", tokenPayload, { status: "pending", user: user?.id, provider: providerName, type: "login" }, { database: this.knex, schema: this.schema, accountability: this.accountability }); const TTL = env[options?.session ? "SESSION_COOKIE_TTL" : "ACCESS_TOKEN_TTL"]; const accessToken = jwt.sign(customClaims, getSecret(), { expiresIn: TTL, issuer: "directus" }); await this.knex("directus_sessions").insert({ token: refreshToken, user: user.id, expires: refreshTokenExpiration, ip: this.accountability?.ip, user_agent: this.accountability?.userAgent, origin: this.accountability?.origin }); await this.knex("directus_sessions").delete().where("expires", "<", /* @__PURE__ */ new Date()); if (this.accountability) await this.activityService.createOne({ action: Action.LOGIN, user: user.id, ip: this.accountability.ip, user_agent: this.accountability.userAgent, origin: this.accountability.origin, collection: "directus_users", item: user.id }); await this.knex("directus_users").update({ last_access: /* @__PURE__ */ new Date() }).where({ id: user.id }); emitStatus("success", updatedPayload, user); if (allowedAttempts !== null) await loginAttemptsLimiter.set(user.id, 0, 0); await stall(STALL_TIME, timeStart); return { accessToken, refreshToken, expires: getMilliseconds(TTL), id: user.id }; } async refresh(refreshToken, options) { const { nanoid } = await import("nanoid"); const STALL_TIME = env["LOGIN_STALL_TIME"]; const timeStart = performance.now(); if (!refreshToken) throw new InvalidCredentialsError(); const record = await this.knex.select({ session_expires: "s.expires", session_next_token: "s.next_token", user_id: "u.id", user_first_name: "u.first_name", user_last_name: "u.last_name", user_email: "u.email", user_password: "u.password", user_status: "u.status", user_provider: "u.provider", user_external_identifier: "u.external_identifier", user_auth_data: "u.auth_data", user_role: "u.role", share_id: "d.id", share_start: "d.date_start", share_end: "d.date_end" }).from("directus_sessions AS s").leftJoin("directus_users AS u", "s.user", "u.id").leftJoin("directus_shares AS d", "s.share", "d.id").where("s.token", refreshToken).andWhere("s.expires", ">=", /* @__PURE__ */ new Date()).andWhere("s.oauth_client", null).andWhere((subQuery) => { subQuery.whereNull("d.date_end").orWhere("d.date_end", ">=", /* @__PURE__ */ new Date()); }).andWhere((subQuery) => { subQuery.whereNull("d.date_start").orWhere("d.date_start", "<=", /* @__PURE__ */ new Date()); }).first(); if (!record || !record.share_id && !record.user_id) throw new InvalidCredentialsError(); if (record.user_id && record.user_status !== "active") { await this.knex("directus_sessions").where({ token: refreshToken }).del(); if (record.user_status === "suspended") { await stall(STALL_TIME, timeStart); throw new UserSuspendedError(); } else { await stall(STALL_TIME, timeStart); throw new InvalidCredentialsError(); } } const roles = await fetchRolesTree(record.user_role, { knex: this.knex }); const globalAccess = await fetchGlobalAccess({ user: record.user_id, roles, ip: this.accountability?.ip ?? null }, { knex: this.knex }); if (await getLicenseManager().isLocked() && globalAccess.admin === false) throw new ResourceRestrictedError({ category: "login" }); if (record.user_id) await getAuthProvider(record.user_provider).refresh({ id: record.user_id, first_name: record.user_first_name, last_name: record.user_last_name, email: record.user_email, password: record.user_password, status: record.user_status, provider: record.user_provider, external_identifier: record.user_external_identifier, auth_data: record.user_auth_data, role: record.user_role, app_access: globalAccess.app, admin_access: globalAccess.admin }); let newRefreshToken = record.session_next_token ?? nanoid(64); const sessionDuration = env[options?.session ? "SESSION_COOKIE_TTL" : "REFRESH_TOKEN_TTL"]; const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(sessionDuration, 0)); const tokenPayload = { id: record.user_id, role: record.user_role, app_access: globalAccess.app, admin_access: globalAccess.admin }; if (options?.session) { newRefreshToken = await this.updateStatefulSession(record, refreshToken, newRefreshToken, refreshTokenExpiration); tokenPayload.session = newRefreshToken; } else await this.knex("directus_sessions").update({ token: newRefreshToken, expires: refreshTokenExpiration }).where({ token: refreshToken }); if (record.share_id) { tokenPayload.share = record.share_id; tokenPayload.role = null; tokenPayload.app_access = false; tokenPayload.admin_access = false; delete tokenPayload.id; } const customClaims = await emitter_default.emitFilter("auth.jwt", tokenPayload, { status: "pending", user: record.user_id, provider: record.user_provider, type: "refresh" }, { database: this.knex, schema: this.schema, accountability: this.accountability }); const TTL = env[options?.session ? "SESSION_COOKIE_TTL" : "ACCESS_TOKEN_TTL"]; const accessToken = jwt.sign(customClaims, getSecret(), { expiresIn: TTL, issuer: "directus" }); if (record.user_id) await this.knex("directus_users").update({ last_access: /* @__PURE__ */ new Date() }).where({ id: record.user_id }); await this.knex("directus_sessions").delete().where({ user: record.user_id, share: record.share_id }).andWhere("expires", "<", /* @__PURE__ */ new Date()); return { accessToken, refreshToken: newRefreshToken, expires: getMilliseconds(TTL), id: record.user_id }; } async updateStatefulSession(sessionRecord, oldSessionToken, newSessionToken, sessionExpiration) { if (sessionRecord["session_next_token"]) { await this.knex("directus_sessions").update({ expires: sessionExpiration }).where({ token: newSessionToken }); return newSessionToken; } const GRACE_PERIOD = getMilliseconds(env["SESSION_REFRESH_GRACE_PERIOD"], 1e4); if ((await this.knex("directus_sessions").update({ next_token: newSessionToken, expires: new Date(Date.now() + GRACE_PERIOD) }, ["next_token"]).where({ token: oldSessionToken, next_token: null })).length === 0) { const { next_token } = await this.knex("directus_sessions").select("next_token").where({ token: oldSessionToken }).first(); return next_token; } await this.knex("directus_sessions").insert({ token: newSessionToken, user: sessionRecord["user_id"], share: sessionRecord["share_id"], expires: sessionExpiration, ip: this.accountability?.ip, user_agent: this.accountability?.userAgent, origin: this.accountability?.origin, oauth_client: sessionRecord["oauth_client"] }); return newSessionToken; } async logout(refreshToken) { const record = await this.knex.select("u.id", "u.first_name", "u.last_name", "u.email", "u.password", "u.status", "u.role", "u.provider", "u.external_identifier", "u.auth_data").from("directus_sessions as s").innerJoin("directus_users as u", "s.user", "u.id").where("s.token", refreshToken).andWhere("s.oauth_client", null).first(); if (record) { const user = record; await getAuthProvider(user.provider).logout(clone(user)); if (this.accountability) await this.activityService.createOne({ action: Action.LOGOUT, user: user.id, ip: this.accountability.ip, user_agent: this.accountability.userAgent, origin: this.accountability.origin, collection: "directus_users", item: user.id }); await this.knex.delete().from("directus_sessions").where("token", refreshToken); } } async verifyPassword(userID, password) { const user = await this.knex.select("id", "first_name", "last_name", "email", "password", "status", "role", "provider", "external_identifier", "auth_data").from("directus_users").where("id", userID).first(); if (!user) throw new InvalidCredentialsError(); await getAuthProvider(user.provider).verify(clone(user), password); } }; //#endregion export { AuthenticationService };