import { z } from "zod"; import { ApiScope, Timestamp } from "./common.ts"; // 管理员登录门禁(任务书 §3.1-14,做法见 docs/design/auth-reference.md): // 单管理员账号密码 + 服务端会话(cookie umt_session)+ HMAC 派生 CSRF 双提交 + 可选 TOTP; // 外部程序走 API Key(Bearer)。两种调用者在服务端统一为 Actor。 /** 会话 cookie 名(HttpOnly,SameSite=Lax) */ export const SESSION_COOKIE = "umt_session"; /** CSRF cookie 名(非 HttpOnly,前端读出后放进 CSRF_HEADER) */ export const CSRF_COOKIE = "umt_csrf"; /** 写方法(POST/PUT/PATCH/DELETE)用 cookie 会话调用时必须带的请求头 */ export const CSRF_HEADER = "x-csrf-token"; /** 统一调用者:控制台会话(admin)或外部 API Key */ export const ActorKind = z.enum(["admin", "api_key"]); export type ActorKind = z.infer; export const Actor = z.object({ type: ActorKind, /** admin:admin_user.id(开发模式 DEV_ADMIN_KEY 为 "dev-admin");api_key:api_key.id */ id: z.string(), name: z.string(), scopes: z.array(ApiScope), }); export type Actor = z.infer; const Username = z .string() .min(1) .max(64) .regex(/^[A-Za-z0-9_.@-]+$/, "用户名只能包含字母、数字与 _ . @ -"); /** 密码:长度 10–256(argon2id 存储,不限制字符集) */ export const Password = z.string().min(10, "密码至少 10 个字符").max(256); /** POST /auth/setup:首次初始化管理员(setup token 在首次启动时打印到服务端日志一次) */ export const AdminSetupRequest = z.object({ setupToken: z.string().min(16).max(256), username: Username, password: Password, }); export type AdminSetupRequest = z.infer; /** 6 位 TOTP 码 */ export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字"); /** 备用恢复码:10 位字母数字(不含易混的 I/O/0/1),展示为 XXXXX-XXXXX;输入时忽略大小写、空格与连字符 */ export const RecoveryCode = z .string() .trim() .min(10) .max(32) .regex(/^[A-Za-z0-9\s-]+$/, "恢复码格式不正确"); /** 启用 TOTP 时一次性生成的恢复码个数 */ export const RECOVERY_CODE_COUNT = 8; /** POST /auth/login */ export const LoginRequest = z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256), /** 开启 TOTP 后第二步提交 */ totpCode: TotpCode.optional(), /** 开启 TOTP 后第二步也可以用一次性恢复码代替验证码(用过即作废) */ recoveryCode: RecoveryCode.optional(), }); export type LoginRequest = z.infer; /** GET /auth/session(免鉴权,SPA 探测登录态与是否需要初始化) */ export const SessionInfo = z.object({ initialized: z.boolean(), authenticated: z.boolean(), actor: Actor.nullable(), /** 仅 cookie 会话有值 */ expiresAt: Timestamp.nullable(), totpEnabled: z.boolean(), /** 已登记至少一把 Passkey(登录页据此显示"用 Passkey 登录") */ passkeyEnabled: z.boolean(), }); export type SessionInfo = z.infer; /** * POST /auth/login 结果:ok=登录成功(cookie 已下发);totp_required=密码正确但需第二步验证码 * (不计入失败次数,前端保留用户名/密码并追加验证码重新提交) */ export const LoginResult = z.discriminatedUnion("status", [ z.object({ status: z.literal("ok"), session: SessionInfo }), z.object({ status: z.literal("totp_required") }), ]); export type LoginResult = z.infer; /** POST /auth/password:校验旧密码 → 改密 → 吊销其它会话 */ export const PasswordChangeRequest = z.object({ currentPassword: z.string().min(1).max(256), newPassword: Password, }); export type PasswordChangeRequest = z.infer; export const PasswordChangeResult = z.object({ revokedSessions: z.number().int().nonnegative() }); export type PasswordChangeResult = z.infer; /** GET /auth/totp */ export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean(), /** 未用过的恢复码个数(未开启时为 0) */ recoveryCodesRemaining: z.number().int().nonnegative(), }); export type TotpStatus = z.infer; /** * GET /auth/totp/setup:待确认的 TOTP 密钥(没有就生成一个;已有未确认的原样返回,刷新页面不作废)。 * 前端把 otpauthUri 渲染成二维码;已开启时 409。 */ export const TotpSetup = z.object({ otpauthUri: z.string(), /** base32 密钥(无法扫码时手动输入) */ secret: z.string(), issuer: z.string(), account: z.string(), }); export type TotpSetup = z.infer; /** * POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认(同时生成恢复码); * disable 需要当前密码 + 验证码(或恢复码);recovery_codes 用当前密码重新生成恢复码(旧的全部作废) */ export const TotpAction = z.discriminatedUnion("action", [ z.object({ action: z.literal("begin") }), z.object({ action: z.literal("enable"), code: TotpCode }), z.object({ action: z.literal("disable"), password: z.string().min(1).max(256), code: z.union([TotpCode, RecoveryCode]), }), z.object({ action: z.literal("recovery_codes"), password: z.string().min(1).max(256) }), ]); export type TotpAction = z.infer; export const TotpActionResult = z.object({ status: TotpStatus, /** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */ otpauthUri: z.string().optional(), secret: z.string().optional(), /** 仅 enable / recovery_codes 返回:新生成的恢复码明文(只显示这一次,库里只存哈希) */ recoveryCodes: z.array(z.string()).optional(), }); export type TotpActionResult = z.infer; // ── Passkey(WebAuthn,替代密码的第一因子;登录成功直接建会话,不再要求 TOTP) ── export const PasskeyName = z.string().trim().min(1, "输入名称").max(60); export const PasskeyInfo = z.object({ id: z.string(), name: z.string(), /** singleDevice / multiDevice(可同步) */ deviceType: z.string().nullable(), backedUp: z.boolean(), createdAt: Timestamp, lastUsedAt: Timestamp.nullable(), }); export type PasskeyInfo = z.infer; export const PasskeyList = z.object({ items: z.array(PasskeyInfo) }); export type PasskeyList = z.infer; /** * 注册 / 登录挑战:options 原样交给 @simplewebauthn/browser(startRegistration / startAuthentication), * challengeId 随校验请求带回(挑战存库、一次性、5 分钟有效)。 */ export const PasskeyChallenge = z.object({ challengeId: z.string(), /** WebAuthn 选项 JSON 由 @simplewebauthn 定义,合同里不重复建模 */ options: z.record(z.string(), z.unknown()), }); export type PasskeyChallenge = z.infer; const WebAuthnResponse = z.record(z.string(), z.unknown()); export const PasskeyRegisterRequest = z.object({ challengeId: z.string().uuid(), name: PasskeyName, /** startRegistration 的返回值(RegistrationResponseJSON) */ response: WebAuthnResponse, }); export type PasskeyRegisterRequest = z.infer; export const PasskeyLoginRequest = z.object({ challengeId: z.string().uuid(), /** startAuthentication 的返回值(AuthenticationResponseJSON) */ response: WebAuthnResponse, }); export type PasskeyLoginRequest = z.infer; export const PasskeyRenameRequest = z.object({ name: PasskeyName }); export type PasskeyRenameRequest = z.infer; /** * 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。 * 新增动作只在这里加。 */ export const AuditAction = z.enum([ "admin.setup", "admin.login_success", "admin.login_failed", "admin.logout", "admin.password_change", "admin.totp_enable", "admin.totp_disable", "admin.recovery_codes_regenerate", "admin.recovery_code_login", "admin.passkey_register", "admin.passkey_rename", "admin.passkey_remove", "admin.passkey_login", "api_key.create", "api_key.update", "api_key.revoke", "settings.update", "secret.create", "secret.rotate", "secret.delete", "secret.master_key_rotate", "library_root.create", "library_root.update", "library_root.delete", "library_root.test", "downloader_instance.create", "downloader_instance.update", "downloader_instance.delete", "downloader_instance.test", "media_server_instance.create", "media_server_instance.update", "media_server_instance.delete", "media_server_instance.test", "storage_instance.create", "storage_instance.update", "storage_instance.delete", "storage_instance.test", "path_mapping.create", "path_mapping.update", "path_mapping.delete", "path_mapping.verify", "site_account.credentials_set", "engine.run_cancel", "engine.run_retry", ]); export type AuditAction = z.infer;