Files
UHD_Media_Tool/packages/contracts/src/auth.ts
T
rootandClaude Opus 5.5 b0e5d539ed feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation
- GET /auth/totp/setup returns the pending secret and otpauth URL (created
  on first call, reused on refresh) for the console to render as a QR code.
- Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in
  admin_recovery_code); login accepts recoveryCode in place of totpCode,
  disable accepts either, and recovery_codes regenerates with the password.
- Passkeys via @simplewebauthn/server: register/list/rename/delete under
  the console session, public login options/verify that reuse the login
  rate limit and session cookies. Challenges are one-time rows in
  webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID /
  WEBAUTHN_ORIGINS, falling back to the request host. Signature counter
  regressions are rejected. SessionInfo gains passkeyEnabled.
- --role rotate-master-key re-encrypts the secret table from
  SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction,
  skips rows already on the new key (resumable, swap keys to roll back),
  logs progress and audits secret.master_key_rotate. Secrets now record
  the master key fingerprint in kdf_params.kid.
- Runbook: recovery codes, passkey configuration, rotation steps and
  rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:22:07 +08:00

236 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof ActorKind>;
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<typeof Actor>;
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<typeof AdminSetupRequest>;
/** 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<typeof LoginRequest>;
/** 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<typeof SessionInfo>;
/**
* 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<typeof LoginResult>;
/** POST /auth/password:校验旧密码 → 改密 → 吊销其它会话 */
export const PasswordChangeRequest = z.object({
currentPassword: z.string().min(1).max(256),
newPassword: Password,
});
export type PasswordChangeRequest = z.infer<typeof PasswordChangeRequest>;
export const PasswordChangeResult = z.object({ revokedSessions: z.number().int().nonnegative() });
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
/** 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<typeof TotpStatus>;
/**
* 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<typeof TotpSetup>;
/**
* 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<typeof TotpAction>;
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<typeof TotpActionResult>;
// ── 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<typeof PasskeyInfo>;
export const PasskeyList = z.object({ items: z.array(PasskeyInfo) });
export type PasskeyList = z.infer<typeof PasskeyList>;
/**
* 注册 / 登录挑战: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<typeof PasskeyChallenge>;
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<typeof PasskeyRegisterRequest>;
export const PasskeyLoginRequest = z.object({
challengeId: z.string().uuid(),
/** startAuthentication 的返回值(AuthenticationResponseJSON) */
response: WebAuthnResponse,
});
export type PasskeyLoginRequest = z.infer<typeof PasskeyLoginRequest>;
export const PasskeyRenameRequest = z.object({ name: PasskeyName });
export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
/**
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
* 新增动作只在这里加。
*/
export const AuditAction = z.enum([
"admin.setup",
"admin.login_success",
"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",
"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<typeof AuditAction>;