UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

1,465 lines 122 kB
import { $atom, $context, $inject, $module, Alepha, AlephaError, z } from "alepha"; import { $audit } from "alepha/api/audits"; import { $bucket } from "alepha/bucket"; import { $issuer, $permission, $secure, CryptoProvider, InvalidCredentialsError, SecurityProvider } from "alepha/security"; import { $action, BadRequestError, ConflictError, HttpError, NotFoundError, UnauthorizedError, okSchema } from "alepha/server"; import { $entity, $repository, db, pageQuerySchema, sql } from "alepha/orm"; import { $logger } from "alepha/logger"; import { AlephaApiVerification, VerificationService } from "alepha/api/verifications"; import { $client } from "alepha/server/links"; import { $notification } from "alepha/api/notifications"; import { CaptchaProvider } from "alepha/captcha"; import { $authApple, $authCredentials, $authFacebook, $authFranceConnect, $authGithub, $authGoogle, $authMicrosoft, ServerAuthProvider, authenticationProviderSchema } from "alepha/server/auth"; import { $etag } from "alepha/server/etag"; import { $cache, CacheProvider } from "alepha/cache"; import { DatabaseCacheProvider } from "alepha/cache/database"; import { DateTimeProvider } from "alepha/datetime"; import { $job } from "alepha/api/jobs"; import { randomInt } from "node:crypto"; import { FileSystemProvider } from "alepha/system"; import { AlephaApiKeys, ApiKeyService } from "alepha/api/keys"; import { AlephaOAuth, OAuthClientService, oauthOptions } from "alepha/api/oauth"; import { $parameter, AlephaApiParameters } from "alepha/api/parameters"; import { mcpStreamableHttpOptions } from "alepha/mcp"; //#region ../../src/api/users/audits/SessionAudits.ts /** * Authentication & session-security audit events. * * Holds two audit types: * - `auth` — login / logout / token refresh / MFA. * - `security` — rate limiting, session invalidation, and related guards. * * Failed events are logged with `success: false`; severity (`warning`) is * derived centrally in `AuditService.create`. Register as a module variant * and log via the exposed primitives: * `sessionAudits(realm)?.auth.log("login", { success: false, … })`. */ var SessionAudits = class { auth = $audit({ type: "auth", description: "Authentication events (login, logout, token refresh, MFA).", actions: [ "login", "logout", "token_refresh", "mfa_setup", "mfa_verify" ] }); security = $audit({ type: "security", description: "Security events (rate limiting, session invalidation, blocked access).", actions: [ "rate_limited", "sessions_invalidated", "permission_denied", "blocked" ] }); }; //#endregion //#region ../../src/api/users/audits/UserAudits.ts /** * User-management audit events. * * Holds the `user` audit type. Mirrors the `$notification`/`$job` holder * pattern (see {@link UserNotifications}) — register as a module variant and * log via the exposed primitive: `userAudits(realm)?.user.log("create", …)`. */ var UserAudits = class { user = $audit({ type: "user", description: "User management events (create, update, delete, role/password changes).", actions: [ "create", "update", "delete", "role_change", "password_change", "enable", "disable" ] }); }; //#endregion //#region ../../src/api/users/buckets/UserBuckets.ts /** * User-specific file storage wrapper service. * * This service provides file storage for user-related files such as: * - User avatars/profile pictures * * Declared as a module variant — not auto-injected. It is instantiated * lazily the first time something calls `alepha.inject(UserBuckets)`. */ var UserBuckets = class { /** * Bucket for user avatar storage. */ avatars = $bucket({ maxSize: 5 * 1024 * 1024, mimeTypes: [ "image/jpeg", "image/png", "image/gif", "image/webp" ] }); }; //#endregion //#region ../../src/api/users/schemas/identityQuerySchema.ts const identityQuerySchema = pageQuerySchema.extend({ userId: z.uuid().optional(), provider: z.string().optional() }); //#endregion //#region ../../src/api/users/entities/users.ts const DEFAULT_USER_REALM_NAME = "default"; const users = $entity({ name: "users", schema: z.object({ id: db.primaryKey(z.uuid()), version: db.version(), createdAt: db.createdAt(), updatedAt: db.updatedAt(), realm: db.default(z.text(), DEFAULT_USER_REALM_NAME), username: z.shortText({ minLength: 3, maxLength: 30 }).optional(), email: z.string().meta({ format: "email" }).optional(), phoneNumber: z.e164().optional(), roles: db.default(z.array(z.string()), []), firstName: z.string().optional(), lastName: z.string().optional(), picture: z.string().optional(), enabled: db.default(z.boolean(), true), emailVerified: db.default(z.boolean(), false), lastLoginAt: z.datetime().optional(), organizationId: db.organization() }), indexes: [ { expressions: (self) => [self.realm, sql`LOWER(${self.username})`], unique: true, name: "users_realm_username_lower_idx" }, { columns: ["realm", "email"], unique: true }, { columns: ["realm", "phoneNumber"], unique: true } ] }); //#endregion //#region ../../src/api/users/entities/identities.ts const identities = $entity({ name: "identities", schema: z.object({ id: db.primaryKey(z.uuid()), version: db.version(), createdAt: db.createdAt(), updatedAt: db.updatedAt(), userId: db.ref(z.uuid(), () => users.cols.id), password: z.text().optional(), provider: z.text(), providerUserId: z.text().optional(), providerData: z.json().optional() }), indexes: [ "userId", "provider", { columns: ["userId", "provider"] }, { columns: ["provider", "providerUserId"], unique: true } ] }); //#endregion //#region ../../src/api/users/schemas/identityResourceSchema.ts const identityResourceSchema = identities.schema.omit({ password: true }); //#endregion //#region ../../src/api/users/atoms/realmAuthSettingsAtom.ts const fieldRequirement = (description) => z.union([ z.const("none"), z.const("optional"), z.const("required") ]).describe(description); const usernameFieldRequirement = (description) => z.union([ z.const("none"), z.const("optional"), z.const("required"), z.const("email") ]).describe(description); const realmAuthSettingsAtom = $atom({ name: "alepha.api.users.realmAuthSettings", schema: z.object({ displayName: z.string().describe("Display name shown on auth pages (e.g., 'Customer Portal')").optional(), description: z.string().describe("Description shown on auth pages").optional(), logoUrl: z.string().describe("Logo URL for auth pages").optional(), registrationAllowed: z.boolean().describe("Enable user self-registration"), email: fieldRequirement("Email address field requirement for user accounts"), username: usernameFieldRequirement("Username field requirement for user accounts"), usernameRegExp: z.string().describe("Regular expression that usernames must match (if username is enabled)"), usernameBlocklist: z.array(z.text()).describe("Usernames that the slugger / manual registration must reject. Default empty so apps can register `admin`/`root`/`me`/etc. if they want; populate it explicitly for handles you want to keep off-limits."), phoneNumber: fieldRequirement("Phone number field requirement for user accounts"), verifyEmailRequired: z.boolean().describe("Require email verification for user accounts"), verifyPhoneRequired: z.boolean().describe("Require phone verification for user accounts"), firstNameLastName: fieldRequirement("First and last name field requirement for user accounts"), resetPasswordAllowed: z.boolean().describe("Enable forgot password functionality"), captchaRequired: z.boolean().describe("Require captcha verification on registration (needs a CaptchaProvider registered, e.g. TurnstileCaptchaProvider)"), adminEmails: z.array(z.email()).describe("List of email addresses that are automatically promoted to admin role on login"), adminUsernames: z.array(z.text()).describe("List of usernames that are automatically promoted to admin role on login"), defaultRoles: z.array(z.string()).describe("Default roles assigned to newly registered users"), verifyEmailUrl: z.string().describe("Base URL for email verification links (used when verification method is 'link'). Token and email are appended as query params.").optional(), passwordPolicy: z.object({ minLength: z.integer().min(1).describe("Minimum password length").default(8), requireUppercase: z.boolean().describe("Require at least one uppercase letter"), requireLowercase: z.boolean().describe("Require at least one lowercase letter"), requireNumbers: z.boolean().describe("Require at least one number"), requireSpecialCharacters: z.boolean().describe("Require at least one special character") }), loginRateLimit: z.object({ ipMaxAttempts: z.integer().min(1).describe("Max failed login attempts per IP before temporary lockout").default(15), accountMaxAttempts: z.integer().min(1).describe("Max failed login attempts per account before temporary lockout").default(5), windowMs: z.integer().min(1e3).describe("Rate limit window duration in milliseconds").default(900 * 1e3) }), registrationIpMaxAttempts: z.integer().min(1).describe("Max registration attempts per IP before temporary lockout. Default 10 protects against signup abuse; raise it in dev/e2e environments where a single localhost IP spawns many test users.").default(10), refreshToken: z.object({ expirationIdle: z.integer().min(1e3).describe("Maximum time in milliseconds a refresh token may stay unused before being invalidated. When set, sessions whose last refresh is older than this window are rejected and deleted, even if the absolute `expiresAt` has not been reached. Recommended for SaaS auth posture (SOC2/ISO27001). Leave undefined to disable idle invalidation (default).").optional() }) }), default: { registrationAllowed: true, email: "required", username: "none", usernameRegExp: "^[a-zA-Z0-9_-]{3,30}$", usernameBlocklist: [], phoneNumber: "none", verifyEmailRequired: false, verifyPhoneRequired: false, resetPasswordAllowed: false, captchaRequired: false, firstNameLastName: "none", adminEmails: [], adminUsernames: [], defaultRoles: ["user"], passwordPolicy: { minLength: 8, requireUppercase: true, requireLowercase: true, requireNumbers: true, requireSpecialCharacters: false }, loginRateLimit: { ipMaxAttempts: 15, accountMaxAttempts: 5, windowMs: 900 * 1e3 }, registrationIpMaxAttempts: 10, refreshToken: {} } }); //#endregion //#region ../../src/api/users/entities/sessions.ts const sessions = $entity({ name: "sessions", schema: z.object({ id: db.primaryKey(z.uuid()), version: db.version(), createdAt: db.createdAt(), updatedAt: db.updatedAt(), refreshToken: z.uuid(), userId: db.ref(z.uuid(), () => users.cols.id), /** * OAuth client this session was minted for, when it was created via the * OAuth 2.1 authorization flow — the `client_id` of an `oauth_clients` * row. Null for first-party logins. Deliberately NOT a DB-level foreign * key: `sessions` is a core entity and must not depend on the optional * OAuth module's table; the join to `oauth_clients` is done at query time. */ clientId: z.text({ maxLength: 64 }).optional(), expiresAt: z.datetime(), /** * Last time the session was used to refresh an access token. * Used by realm `refreshToken.expirationIdle` to invalidate idle sessions. * `null` on existing rows pre-migration — falls back to `createdAt`. */ lastUsedAt: z.datetime().optional(), ip: z.text().optional(), /** * ISO 3166-1 alpha-2 country code derived from the request geo headers * (`cf-ipcountry` on Cloudflare, CDN equivalents elsewhere) at login time. * `null` on pre-migration rows and where geo isn't available. */ country: z.text({ maxLength: 2 }).optional(), userAgent: z.object({ os: z.text(), browser: z.text(), device: z.enum([ "MOBILE", "DESKTOP", "TABLET" ]) }).optional() }), indexes: [ "userId", "expiresAt", { column: "refreshToken", unique: true } ] }); //#endregion //#region ../../src/api/users/providers/RealmProvider.ts var RealmProvider = class { alepha = $inject(Alepha); defaultIdentities = $repository(identities); defaultSessions = $repository(sessions); defaultUsers = $repository(users); realms = /* @__PURE__ */ new Map(); register(realmName, realmOptions = {}) { if (realmName.includes(".")) throw new AlephaError(`Realm name "${realmName}" must not contain dots — dots are reserved for parameter tree paths`); const features = { jobs: false, notifications: false, apiKeys: false, parameters: false, avatars: false, audits: false, ...realmOptions.features }; const realm = { name: realmName, repositories: { identities: realmOptions.entities?.identities ?? this.defaultIdentities, sessions: realmOptions.entities?.sessions ?? this.defaultSessions, users: realmOptions.entities?.users ?? this.defaultUsers }, settings: { ...realmAuthSettingsAtom.options.default, ...realmOptions.settings, passwordPolicy: { ...realmAuthSettingsAtom.options.default.passwordPolicy, ...realmOptions.settings?.passwordPolicy }, loginRateLimit: { ...realmAuthSettingsAtom.options.default.loginRateLimit, ...realmOptions.settings?.loginRateLimit }, refreshToken: { ...realmAuthSettingsAtom.options.default.refreshToken, ...realmOptions.settings?.refreshToken } }, features, getSettings: async function() { if (this.settingsParameter) return await this.settingsParameter.get(); return this.settings; } }; this.realms.set(realmName, realm); return this.getRealm(realmName); } /** * Gets a registered realm by name, auto-creating default if needed. */ getRealm(realmName = DEFAULT_USER_REALM_NAME) { let realm = this.realms.get(realmName); if (!realm) { const firstRealm = Array.from(this.realms.values())[0]; if (realmName === "default" && firstRealm) realm = firstRealm; else if (this.alepha.isTest()) realm = this.register(realmName); else throw new AlephaError(`Missing realm '${realmName}', please declare $realm in your application.`); } return realm; } identityRepository(realmName = DEFAULT_USER_REALM_NAME) { return this.getRealm(realmName).repositories.identities; } sessionRepository(realmName = DEFAULT_USER_REALM_NAME) { return this.getRealm(realmName).repositories.sessions; } userRepository(realmName = DEFAULT_USER_REALM_NAME) { return this.getRealm(realmName).repositories.users; } }; //#endregion //#region ../../src/api/users/services/IdentityService.ts var IdentityService = class { alepha = $inject(Alepha); log = $logger(); realmProvider = $inject(RealmProvider); userAudits(realmName) { if (this.realmProvider.getRealm(realmName).features.audits) return this.alepha.inject(UserAudits); } identities(userRealmName) { return this.realmProvider.identityRepository(userRealmName); } /** * Find identities with pagination and filtering. */ async findIdentities(q = {}, userRealmName) { this.log.trace("Finding identities", { query: q, userRealmName }); q.sort ??= "-createdAt"; const where = this.identities(userRealmName).createQueryWhere(); if (q.userId) where.userId = { eq: q.userId }; if (q.provider) where.provider = { like: q.provider }; const result = await this.identities(userRealmName).paginate(q, { where }, { count: true }); this.log.debug("Identities found", { count: result.content.length, total: result.page.totalElements }); return result; } /** * Get an identity by ID. */ async getIdentityById(id, userRealmName) { this.log.trace("Getting identity by ID", { id, userRealmName }); const identity = await this.identities(userRealmName).getById(id); this.log.debug("Identity retrieved", { id, provider: identity.provider, userId: identity.userId }); return identity; } /** * Delete an identity by ID. */ async deleteIdentity(id, userRealmName) { this.log.trace("Deleting identity", { id, userRealmName }); const identity = await this.getIdentityById(id, userRealmName); await this.identities(userRealmName).deleteById(id); this.log.info("Identity deleted", { id, provider: identity.provider, userId: identity.userId }); const realm = this.realmProvider.getRealm(userRealmName); await this.userAudits(userRealmName)?.user.log("update", { resourceType: "user", userRealm: realm.name, resourceId: identity.userId, description: `Identity provider disconnected: ${identity.provider}`, metadata: { identityId: id, provider: identity.provider, userId: identity.userId } }); } }; //#endregion //#region ../../src/api/users/controllers/AdminIdentityController.ts var AdminIdentityController = class { url = "/identities"; group = "admin:identities"; identityService = $inject(IdentityService); securityProvider = $inject(SecurityProvider); /** * Find identities with pagination and filtering. */ findIdentities = $action({ path: this.url, group: this.group, use: [$secure({ permissions: ["admin:identity:read"] })], description: "Find identities with pagination and filtering", schema: { query: identityQuerySchema.extend({ userRealmName: z.string().optional() }), response: z.page(identityResourceSchema) }, handler: ({ query, user }) => { const { userRealmName, ...q } = query; return this.identityService.findIdentities(q, this.securityProvider.assertRealmScope(user, userRealmName)); } }); /** * Get an identity by ID. */ getIdentity = $action({ path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:identity:read"] })], description: "Get an identity by ID", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), response: identityResourceSchema }, handler: ({ params, query, user }) => this.identityService.getIdentityById(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName)) }); /** * Delete an identity. */ deleteIdentity = $action({ method: "DELETE", path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:identity:delete"] })], description: "Delete an identity", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), response: okSchema }, handler: async ({ params, query, user }) => { await this.identityService.deleteIdentity(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName)); return { ok: true, id: params.id }; } }); }; //#endregion //#region ../../src/api/users/schemas/sessionQuerySchema.ts const sessionQuerySchema = pageQuerySchema.extend({ userId: z.uuid().optional() }); //#endregion //#region ../../src/api/users/schemas/sessionResourceSchema.ts /** * Slim view of the session's owner — embedded by the admin listing so the * UI can render a human-readable identifier instead of just a UUID. Comes * back via a left join, so it's optional (a session whose user was deleted * still returns; `user` is undefined). */ const sessionUserSummarySchema = z.object({ id: z.uuid(), email: z.string().meta({ format: "email" }).optional(), username: z.shortText({ minLength: 3, maxLength: 30 }).optional(), firstName: z.string().optional(), lastName: z.string().optional() }); const sessionResourceSchema = z.object({ id: z.uuid(), version: z.number(), createdAt: z.datetime(), updatedAt: z.datetime(), refreshToken: z.uuid(), userId: z.uuid(), expiresAt: z.datetime(), ip: z.string().optional(), country: z.string().optional(), userAgent: z.object({ os: z.string(), browser: z.string(), device: z.enum([ "MOBILE", "DESKTOP", "TABLET" ]) }).optional(), user: sessionUserSummarySchema.optional() }); //#endregion //#region ../../src/api/users/services/SessionCrudService.ts /** * Relation map embedding a slim user summary on every session row, so the * admin UI can render `user.email`/`user.username` instead of a bare UUID. * Left-join (default) so sessions whose owner was deleted still come back * with `user: undefined`. */ const withUser = { user: { join: users, on: ["userId", users.cols.id] } }; var SessionCrudService = class { log = $logger(); realmProvider = $inject(RealmProvider); sessions(userRealmName) { return this.realmProvider.sessionRepository(userRealmName); } /** * Find sessions with pagination and filtering. */ async findSessions(q = {}, userRealmName) { this.log.trace("Finding sessions", { query: q, userRealmName }); q.sort ??= "-createdAt"; const where = this.sessions(userRealmName).createQueryWhere(); if (q.userId) where.userId = { eq: q.userId }; const result = await this.sessions(userRealmName).paginate(q, { where, with: withUser }, { count: true }); this.log.debug("Sessions found", { count: result.content.length, total: result.page.totalElements }); return result; } /** * Get a session by ID. */ async getSessionById(id, userRealmName) { this.log.trace("Getting session by ID", { id, userRealmName }); const session = await this.sessions(userRealmName).getOne({ where: { id: { eq: id } }, with: withUser }); this.log.debug("Session retrieved", { id, userId: session.userId }); return session; } /** * Delete a session by ID. */ async deleteSession(id, userRealmName) { this.log.trace("Deleting session", { id, userRealmName }); await this.getSessionById(id, userRealmName); await this.sessions(userRealmName).deleteById(id); this.log.info("Session deleted", { id }); } /** * Delete many sessions by ID in one repository call. */ async deleteSessions(ids, userRealmName) { if (ids.length === 0) return []; this.log.trace("Deleting sessions", { count: ids.length, userRealmName }); const deleted = await this.sessions(userRealmName).deleteMany({ id: { inArray: ids } }); this.log.info("Sessions deleted", { count: deleted.length }); return deleted.map(String); } }; //#endregion //#region ../../src/api/users/controllers/AdminSessionController.ts var AdminSessionController = class { url = "/sessions"; group = "admin:sessions"; sessionService = $inject(SessionCrudService); securityProvider = $inject(SecurityProvider); /** * Find sessions with pagination and filtering. */ findSessions = $action({ path: this.url, group: this.group, use: [$secure({ permissions: ["admin:session:read"] })], description: "Find sessions with pagination and filtering", schema: { query: sessionQuerySchema.extend({ userRealmName: z.string().optional() }), response: z.page(sessionResourceSchema) }, handler: ({ query, user }) => { const { userRealmName, ...q } = query; return this.sessionService.findSessions(q, this.securityProvider.assertRealmScope(user, userRealmName)); } }); /** * Get a session by ID. */ getSession = $action({ path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:session:read"] })], description: "Get a session by ID", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), response: sessionResourceSchema }, handler: ({ params, query, user }) => this.sessionService.getSessionById(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName)) }); /** * Delete a session. */ deleteSession = $action({ method: "DELETE", path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:session:delete"] })], description: "Delete a session", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), response: okSchema }, handler: async ({ params, query, user }) => { await this.sessionService.deleteSession(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName)); return { ok: true, id: params.id }; } }); /** * Delete many sessions in one repository call. */ deleteSessions = $action({ method: "POST", path: `${this.url}/delete`, group: this.group, use: [$secure({ permissions: ["admin:session:delete"] })], description: "Delete many sessions", schema: { query: z.object({ userRealmName: z.string().optional() }), body: z.object({ ids: z.array(z.uuid()).min(1).max(1e3) }), response: z.object({ deleted: z.array(z.string()) }) }, handler: async ({ body, query, user }) => { return { deleted: await this.sessionService.deleteSessions(body.ids, this.securityProvider.assertRealmScope(user, query.userRealmName)) }; } }); }; //#endregion //#region ../../src/api/users/schemas/createUserSchema.ts const createUserSchema = users.insertSchema.omit({ realm: true }); //#endregion //#region ../../src/api/users/schemas/updateUserSchema.ts const updateUserSchema = users.insertSchema.omit({ id: true, version: true, createdAt: true, updatedAt: true }).partial(); //#endregion //#region ../../src/api/users/schemas/userQuerySchema.ts const userQuerySchema = pageQuerySchema.extend({ /** * Free-text search applied (case-insensitive) across `email`, * `username`, `firstName`, and `lastName`. Matches with a leading and * trailing wildcard, so `?search=foo` finds any user whose email, * username, or name contains `foo`. */ search: z.string().optional(), email: z.string().optional(), enabled: z.boolean().optional(), emailVerified: z.boolean().optional(), roles: z.array(z.string()).optional() }); //#endregion //#region ../../src/api/users/schemas/userResourceSchema.ts const userResourceSchema = users.schema; //#endregion //#region ../../src/api/users/notifications/UserNotifications.ts var UserNotifications = class { passwordReset = $notification({ category: "security", description: "Email sent to users with a verification code to reset their password.", critical: true, sensitive: true, email: { subject: "Reset your password", body: (it) => ` <h1>Reset Your Password</h1> <p>Hi ${it.email},</p> <p>We received a request to reset your password. Use the code below to verify your identity:</p> <p style="margin: 30px 0; text-align: center;"> <span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;"> ${it.code} </span> </p> <p>This code will expire in ${it.expiresInMinutes} minutes.</p> <p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p> <p>Best regards,<br>The Team</p> ` }, translations: { fr: { email: { subject: "Réinitialisez votre mot de passe", body: (it) => ` <h1>Réinitialisez votre mot de passe</h1> <p>Bonjour,</p> <p>Nous avons reçu une demande de réinitialisation de votre mot de passe. Utilisez le code ci-dessous pour confirmer votre identité :</p> <p style="margin: 30px 0; text-align: center;"> <span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;"> ${it.code} </span> </p> <p>Ce code expire dans ${it.expiresInMinutes} minutes.</p> <p>Si vous n'avez pas demandé de réinitialisation, vous pouvez ignorer cet e-mail : votre mot de passe restera inchangé.</p> ` } } }, schema: z.object({ email: z.string().meta({ format: "email" }), code: z.string(), expiresInMinutes: z.number() }) }); emailVerification = $notification({ category: "security", description: "Email sent to users with a verification code to verify their email address.", critical: true, sensitive: true, email: { subject: "Verify your email address", body: (it) => ` <h1>Verify Your Email Address</h1> <p>Hi ${it.email},</p> <p>Thanks for signing up! Use the code below to verify your email address:</p> <p style="margin: 30px 0; text-align: center;"> <span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;"> ${it.code} </span> </p> <p>This code will expire in ${it.expiresInMinutes} minutes.</p> <p>If you did not create an account, please ignore this email.</p> <p>Best regards,<br>The Team</p> ` }, translations: { fr: { email: { subject: "Vérifiez votre adresse e-mail", body: (it) => ` <h1>Vérifiez votre adresse e-mail</h1> <p>Bonjour,</p> <p>Merci de votre inscription ! Utilisez le code ci-dessous pour vérifier votre adresse e-mail :</p> <p style="margin: 30px 0; text-align: center;"> <span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;"> ${it.code} </span> </p> <p>Ce code expire dans ${it.expiresInMinutes} minutes.</p> <p>Si vous n'avez pas créé de compte, ignorez cet e-mail.</p> ` } } }, schema: z.object({ email: z.string().meta({ format: "email" }), code: z.string(), expiresInMinutes: z.number() }) }); phoneVerification = $notification({ category: "security", description: "SMS sent to users with a verification code to verify their phone number.", critical: true, sensitive: true, sms: { message: (it) => `Your verification code is: ${it.code}. This code expires in ${it.expiresInMinutes} minutes.` }, schema: z.object({ phoneNumber: z.string(), code: z.string(), expiresInMinutes: z.number() }) }); passwordResetLink = $notification({ category: "security", description: "Email sent to users with a link to reset their password.", critical: true, sensitive: true, email: { subject: "Reset your password", body: (it) => ` <h1>Reset Your Password</h1> <p>Hi ${it.email},</p> <p>We received a request to reset your password. Click the link below to create a new password:</p> <p style="margin: 30px 0;"> <a href="${it.resetUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;"> Reset Password </a> </p> <p>Or copy and paste this link into your browser:</p> <p style="word-break: break-all; color: #666;">${it.resetUrl}</p> <p>This link will expire in ${it.expiresInMinutes} minutes.</p> <p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p> <p>Best regards,<br>The Team</p> ` }, schema: z.object({ email: z.string().meta({ format: "email" }), resetUrl: z.string(), expiresInMinutes: z.number() }) }); accountLockout = $notification({ category: "security", description: "Email sent to users when their account is temporarily locked due to too many failed login attempts.", critical: true, sensitive: true, email: { subject: "Account temporarily locked", body: (it) => ` <h1>Account Temporarily Locked</h1> <p>Hi ${it.email},</p> <p>Your account has been temporarily locked due to too many failed login attempts.</p> <p>If this was you, please wait ${it.lockoutMinutes} minutes before trying again. If you've forgotten your password, you can reset it using the password reset feature.</p> <p>If this wasn't you, someone may be trying to access your account. We recommend changing your password as soon as possible.</p> <p>Best regards,<br>The Team</p> ` }, translations: { fr: { email: { subject: "Compte temporairement verrouillé", body: (it) => ` <h1>Compte temporairement verrouillé</h1> <p>Bonjour,</p> <p>Votre compte a été temporairement verrouillé suite à un trop grand nombre de tentatives de connexion échouées.</p> <p>Si c'était bien vous, patientez ${it.lockoutMinutes} minutes avant de réessayer. Mot de passe oublié ? Utilisez la réinitialisation de mot de passe.</p> <p>Si ce n'était pas vous, quelqu'un essaie peut-être d'accéder à votre compte. Nous vous recommandons de changer votre mot de passe au plus vite.</p> ` } } }, schema: z.object({ email: z.string().meta({ format: "email" }), lockoutMinutes: z.number() }) }); emailVerificationLink = $notification({ category: "security", description: "Email sent to users with a link to verify their email address.", critical: true, sensitive: true, email: { subject: "Verify your email address", body: (it) => ` <h1>Verify Your Email Address</h1> <p>Hi ${it.email},</p> <p>Thanks for signing up! Click the button below to verify your email address:</p> <p style="margin: 30px 0;"> <a href="${it.verifyUrl}" style="background-color: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;"> Verify Email </a> </p> <p>Or copy and paste this link into your browser:</p> <p style="word-break: break-all; color: #666;">${it.verifyUrl}</p> <p>This link will expire in ${it.expiresInMinutes} minutes.</p> <p>If you did not create an account, please ignore this email.</p> <p>Best regards,<br>The Team</p> ` }, schema: z.object({ email: z.string().meta({ format: "email" }), verifyUrl: z.string(), expiresInMinutes: z.number() }) }); }; //#endregion //#region ../../src/api/users/services/UserService.ts var UserService = class { alepha = $inject(Alepha); log = $logger(); verificationController = $client(); verificationService = $inject(VerificationService); realmProvider = $inject(RealmProvider); cryptoProvider = $inject(CryptoProvider); userAudits(realmName) { if (this.realmProvider.getRealm(realmName).features.audits) return this.alepha.inject(UserAudits); } userNotifications(realmName) { if (this.realmProvider.getRealm(realmName).features.notifications) return this.alepha.inject(UserNotifications); } users(userRealmName) { return this.realmProvider.userRepository(userRealmName); } /** * Request email verification for a user. * @param email - The email address to verify. * @param userRealmName - Optional realm name. * @param method - The verification method: "code" (default) or "link". * @param verifyUrl - Base URL for verification link (required when method is "link"). */ async requestEmailVerification(email, userRealmName, method = "code") { this.log.trace("Requesting email verification", { email, userRealmName, method }); const user = await this.users(userRealmName).findOne({ where: { email: { eq: email } } }); if (!user) { this.log.debug("Email verification requested for non-existent user", { email }); return true; } if (user.emailVerified) { this.log.debug("Email verification requested for already verified user", { email, userId: user.id }); return true; } try { const verification = await this.verificationService.createVerification({ type: method, target: email }); if (method === "link") { const realmSettings = await this.realmProvider.getRealm(userRealmName).getSettings(); const baseUrl = realmSettings.verifyEmailUrl ?? "/verify-email"; const url = new URL(baseUrl, "http://localhost"); url.searchParams.set("email", email); url.searchParams.set("token", verification.token); const fullVerifyUrl = realmSettings.verifyEmailUrl ? `${baseUrl}${url.search}` : url.pathname + url.search; await this.userNotifications(userRealmName)?.emailVerificationLink.push({ contact: email, variables: { email, verifyUrl: fullVerifyUrl, expiresInMinutes: Math.floor(verification.codeExpiration / 60) } }); this.log.debug("Email verification link sent", { email, userId: user.id }); } else { await this.userNotifications(userRealmName)?.emailVerification.push({ contact: email, variables: { email, code: verification.token, expiresInMinutes: Math.floor(verification.codeExpiration / 60) } }); this.log.debug("Email verification code sent", { email, userId: user.id }); } } catch (error) { this.log.warn("Failed to send email verification", { email, error }); } return true; } /** * Verify a user's email using a valid verification token. * Supports both code (6-digit) and link (UUID) verification tokens. */ async verifyEmail(email, token, userRealmName) { this.log.trace("Verifying email", { email, userRealmName }); const type = /^\d{6}$/.test(token) ? "code" : "link"; if ((await this.verificationController.validateVerificationCode({ params: { type }, body: { target: email, token } }).catch(() => { this.log.warn("Invalid email verification token", { email, type }); throw new BadRequestError("Invalid or expired verification token"); })).alreadyVerified) { this.log.warn("Email verification token already used", { email }); throw new BadRequestError("Invalid or expired verification token"); } const user = await this.users(userRealmName).getOne({ where: { email: { eq: email } } }); await this.users(userRealmName).updateById(user.id, { emailVerified: true }); this.log.info("Email verified", { email, userId: user.id, type }); const realm = this.realmProvider.getRealm(userRealmName); await this.userAudits(userRealmName)?.user.log("update", { resourceType: "user", userId: user.id, userEmail: email, userRealm: realm.name, resourceId: user.id, description: "Email verified", metadata: { email, verificationType: type } }); } /** * Check if an email is verified. */ async isEmailVerified(email, userRealmName) { this.log.trace("Checking if email is verified", { email, userRealmName }); return (await this.users(userRealmName).findOne({ where: { email: { eq: email } } }))?.emailVerified ?? false; } /** * Find users with pagination and filtering. */ async findUsers(q = {}, userRealmName) { this.log.trace("Finding users", { query: q, userRealmName }); q.sort ??= "-createdAt"; const where = this.users(userRealmName).createQueryWhere(); if (q.search) { const pattern = `%${q.search}%`; where.or = [ { email: { ilike: pattern } }, { username: { ilike: pattern } }, { firstName: { ilike: pattern } }, { lastName: { ilike: pattern } } ]; } if (q.email) where.email = { like: q.email }; if (q.enabled !== void 0) where.enabled = { eq: q.enabled }; if (q.emailVerified !== void 0) where.emailVerified = { eq: q.emailVerified }; if (q.roles) where.roles = { arrayContains: q.roles }; const result = await this.users(userRealmName).paginate(q, { where }, { count: true }); this.log.debug("Users found", { count: result.content.length, total: result.page.totalElements }); return result; } /** * Get a user by ID. */ async getUserById(id, userRealmName) { this.log.trace("Getting user by ID", { id, userRealmName }); return await this.users(userRealmName).getById(id); } /** * Create a new user. */ async createUser(data, userRealmName) { this.log.trace("Creating user", { username: data.username, email: data.email, userRealmName }); const realm = this.realmProvider.getRealm(userRealmName); const realmSettings = await realm.getSettings(); if (data.username) { if (await this.users(userRealmName).findOne({ where: { realm: realm.name, username: { ilike: data.username } } })) { this.log.debug("Username already taken", { username: data.username }); throw new BadRequestError("User with this username already exists"); } } if (data.email) { if (await this.users(userRealmName).findOne({ where: { realm: realm.name, email: { eq: data.email } } })) { this.log.debug("Email already taken", { email: data.email }); throw new BadRequestError("User with this email already exists"); } } if (data.phoneNumber) { if (await this.users(userRealmName).findOne({ where: { realm: realm.name, phoneNumber: { eq: data.phoneNumber } } })) { this.log.debug("Phone number already taken", { phoneNumber: data.phoneNumber }); throw new BadRequestError("User with this phone number already exists"); } } const user = await this.users(userRealmName).create({ ...data, roles: data.roles ?? realmSettings.defaultRoles, realm: realm.name }); this.log.info("User created", { userId: user.id, username: user.username, email: user.email }); await this.userAudits(userRealmName)?.user.log("create", { resourceType: "user", userRealm: realm.name, resourceId: user.id, description: "User created", metadata: { username: user.username, email: user.email, roles: user.roles } }); return user; } /** * Update an existing user. */ async updateUser(id, data, userRealmName) { this.log.trace("Updating user", { id, userRealmName }); const before = await this.getUserById(id, userRealmName); const realm = this.realmProvider.getRealm(userRealmName); const users = this.users(userRealmName); if (data.username !== void 0 && data.username !== null && data.username !== before.username) { const existing = await users.findOne({ where: { realm: realm.name, username: { ilike: data.username } } }); if (existing && existing.id !== id) throw new ConflictError("User with this username already exists"); } if (data.email !== void 0 && data.email !== null && data.email !== before.email) { const existing = await users.findOne({ where: { realm: realm.name, email: { eq: data.email } } }); if (existing && existing.id !== id) throw new ConflictError("User with this email already exists"); data.emailVerified = false; } const user = await users.updateById(id, data); this.log.debug("User updated", { userId: id }); const changes = {}; for (const key of Object.keys(data)) if (data[key] !== void 0 && before[key] !== data[key]) changes[key] = { from: before[key], to: data[key] }; const isRoleChange = data.roles !== void 0 && JSON.stringify(before.roles) !== JSON.stringify(data.roles); await this.userAudits(userRealmName)?.user.log(isRoleChange ? "role_change" : "update", { resourceType: "user", userRealm: realm.name, resourceId: user.id, description: isRoleChange ? "User roles changed" : `User updated: ${Object.keys(changes).join(", ")}`, metadata: { changes } }); return user; } /** * Set (or reset) a user's password. Upserts a "credentials" identity * with the new hash. Used by admin password-set flows; does NOT * verify any old password or token — the caller is responsible for * authorization. */ async setPassword(id, newPassword, userRealmName) { this.log.trace("Setting password", { id, userRealmName }); const user = await this.getUserById(id, userRealmName); const realm = this.realmProvider.getRealm(userRealmName); const settings = await realm.getSettings(); if (settings.passwordPolicy) { const policy = settings.passwordPolicy; if (policy.minLength && newPassword.length < policy.minLength) throw new BadRequestError(`Password must be at least ${policy.minLength} characters`); } const hash = await this.cryptoProvider.hashPassword(newPassword); const identities = this.realmProvider.identityRepository(userRealmName); const existing = await identities.findOne({ where: { userId: { eq: id }, provider: { eq: "credentials" } } }); if (existing) await identities.updateById(existing.id, { password: hash }); else await identities.create({ userId: id, provider: "credentials", password: hash }); await this.userAudits(userRealmName)?.user.log("password_change", { resourceType: "user", userId: id, userEmail: user.email ?? void 0, userRealm: realm.name, resourceId: id, severity: "warning", description: "Password set by admin" }); } /** * Delete a user by ID. */ async deleteUser(id, userRealmName) { this.log.trace("Deleting user", { id, userRealmName }); const user = await this.getUserById(id, userRealmName); await this.realmProvider.sessionRepository(userRealmName).deleteMany({ userId: { eq: id } }); await this.realmProvider.identityRepository(userRealmName).deleteMany({ userId: { eq: id } }); await this.users(userRealmName).deleteById(id); this.log.info("User deleted", { userId: id }); const realm = this.realmProvider.getRealm(userRealmName); await this.userAudits(userRealmName)?.user.log("delete", { resourceType: "user", userRealm: realm.name, resourceId: id, severity: "warning", description: "User deleted", metadata: { username: user.username, email: user.email } }); } }; //#endregion //#region ../../src/api/users/controllers/AdminUserController.ts var AdminUserController = class { url = "/users"; group = "admin:users"; userService = $inject(UserService); securityProvider = $inject(SecurityProvider); /** * List roles available in a realm. Used by the admin UI to render the * role picker and grey out defaults (which cannot be removed). */ findRoles = $action({ path: "/metadata/roles", group: this.group, use: [$secure({ permissions: ["admin:user:read"] })], description: "List roles available in a realm", schema: { query: z.object({ userRealmName: z.string().optional() }), response: z.array(z.object({ name: z.string(), default: z.boolean().optional(), description: z.string().optional() })) }, handler: ({ query, user }) => { return this.securityProvider.getRoles(this.securityProvider.assertRealmScope(user, query.userRealmName)).map((r) => ({ name: r.name, default: r.default, description: r.description })); } }); /** * Find users with pagination and filtering. */ findUsers = $action({ path: this.url, group: this.group, use: [$secure({ permissions: ["admin:user:read"] })], description: "Find users with pagination and filtering", schema: { query: userQuerySchema.extend({ userRealmName: z.string().optional() }), response: z.page(userResourceSchema) }, handler: ({ query, user }) => { const { userRealmName, ...q } = query; return this.userService.findUsers(q, this.securityProvider.assertRealmScope(user, userRealmName)); } }); /** * Get a user by ID. */ getUser = $action({ path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:user:read"] })], description: "Get a user by ID", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), response: userResourceSchema }, handler: ({ params, query, user }) => this.userService.getUserById(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName)) }); /** * Create a new user. */ createUser = $action({ method: "POST", path: this.url, group: this.group, use: [$secure({ permissions: ["admin:user:create"] })], description: "Create a new user", schema: { query: z.object({ userRealmName: z.string().optional() }), body: createUserSchema, response: userResourceSchema }, handler: ({ body, query, user }) => this.userService.createUser(body, this.securityProvider.assertRealmScope(user, query.userRealmName)) }); /** * Update a user. */ updateUser = $action({ method: "PATCH", path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:user:update"] })], description: "Update a user", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), body: updateUserSchema, response: userResourceSchema }, handler: ({ params, body, query, user }) => this.userService.updateUser(params.id, body, this.securityProvider.assertRealmScope(user, query.userRealmName)) }); /** * Set (or reset) a user's password. Admin-only flow — does NOT * require knowing the previous password. Hash is stored as a * "credentials" identity for the user (upsert). */ setUserPassword = $action({ method: "POST", path: `${this.url}/:id/password`, group: this.group, use: [$secure({ permissions: ["admin:user:update"] })], description: "Set a user's password", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), body: z.object({ password: z.string().min(1) }), response: okSchema }, handler: async ({ params, body, query, user }) => { await this.userService.setPassword(params.id, body.password, this.securityProvider.assertRealmScope(user, query.userRealmName)); return { ok: true, id: params.id }; } }); /** * Delete a user. */ deleteUser = $action({ method: "DELETE", path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:user:delete"] })], description: "Delete a user", schema: { params: z.object({ id: z.uuid() }), query: z.object({ userRealmName: z.string().optional() }), response: okSchema }, handler: async ({ params, query, user }) => { await this.userService.deleteUser(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName)); return { ok: true, id: params.id }; } }); /** * Delete many users in one request. Each id is processed sequentially so * cascades and side-effects run as if called one-by-one. Errors on a single * id surface with that id in the response. */ deleteUsers = $action({ method: "POST", path: `${this.url}/delete`, group: this.group, use: [$secure({ permissions: ["admin:user:delete"] })], description: "Delete many users", schema: { query: z.object({ user