Files
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

14 KiB

认证门禁 / 设置 / 实例 / 密钥 / 引擎面板 / SSE 总线(W2-A)

实现:apps/server/src/http/auth.ts(中间件)、apps/server/src/http/sse*.ts(总线)、apps/server/src/modules/auth-settings/(路由与服务)、packages/contracts/src/auth.ts、迁移 0015_admin_auth、0016_sse_event。做法照 docs/design/auth-reference.md(uhd_doc)。

首次启动 / 初始化管理员

  1. api 进程启动时若 admin_user 还没有密码,会生成一次性 setup token(库里只存 sha256),明文打印到日志一次: 管理员尚未初始化。一次性 setup token(...):<token>。容器里:docker compose logs app | grep 'setup token'。
  2. 控制台登录页(GET /api/v1/auth/session 返回 initialized:false)进入初始化模式,提交 POST /auth/setup {setupToken, username, password}(密码 ≥10 字符)。成功即登录。
  3. 重启会重新生成 token(旧的作废);多个 api 进程同时启动时以最后打印的为准。初始化用条件 UPDATE(where password_hash is null and setup_token_hash = …),并发提交只有一个成功,其余 409/401。

忘记密码(需要数据库权限,刻意不走 Web):

update admin_user set username = null, password_hash = null, totp_secret_ref = null, totp_enabled_at = null;
update admin_session set revoked_at = now(), revoke_reason = 'reset' where revoked_at is null;
delete from admin_recovery_code;
delete from admin_passkey;

然后重启 api 进程,按日志里新的 setup token 重新初始化。

登录与会话

端点 鉴权 说明
GET /auth/session 免 {initialized, authenticated, actor, expiresAt, totpEnabled};有会话时顺带补发 CSRF cookie
POST /auth/setup 免 见上
POST /auth/login 免 {username, password, totpCode? | recoveryCode?} → {status:"ok", session} 或 {status:"totp_required"}(密码对但缺验证码,不计失败)
POST /auth/passkeys/login/options 免 Passkey 登录挑战 {challengeId, options}(没有登记任何 Passkey 时 412)
POST /auth/passkeys/login/verify 免 {challengeId, response} → {status:"ok", session}(下发与密码登录相同的会话 cookie)
POST /auth/logout 登录 软删除吊销当前会话并清 cookie
POST /auth/password 仅 cookie 会话 校验旧密码 → 改密 → 吊销其它所有会话(保留当前)
GET /auth/totp 仅 cookie 会话 {enabled, pending, recoveryCodesRemaining}
GET /auth/totp/setup 仅 cookie 会话 待确认密钥 {otpauthUri, secret, issuer, account},没有就生成、已有原样返回(刷新不作废已扫的码);已开启 409。控制台用 qr-code-styling 把 otpauthUri 渲染成二维码
POST /auth/totp 仅 cookie 会话 {action:"begin"} 重新生成密钥;{action:"enable", code} 返回 8 个恢复码;{action:"disable", password, code}(code 可以是验证码或恢复码);{action:"recovery_codes", password} 重新生成恢复码
GET /auth/passkeys、POST /auth/passkeys/register/options、POST /auth/passkeys/register/verify {challengeId, name, response}、PATCH /auth/passkeys/{id} {name}、DELETE /auth/passkeys/{id} 仅 cookie 会话 Passkey 列表 / 注册 / 重命名 / 删除
  • 会话:32 字节随机 token 进 umt_session cookie(HttpOnly、SameSite=Lax、Path=/,HTTPS 时 Secure),库里只存 sha256;固定 12 小时,不续期;last_seen_at 60 秒节流更新。
  • CSRF:umt_csrf cookie(非 HttpOnly)= HMAC-SHA256(key=会话 token, "umt/csrf/v1");cookie 会话的 POST/PUT/PATCH/DELETE 必须带 x-csrf-token 头等于该值,否则 403。Bearer 调用不校验 CSRF。
  • 限速(落 login_attempt 表,多进程一致):15 分钟窗口内同 IP 失败 ≥5 次、或同账号失败 ≥15 次 → 429(账号维度取 3 倍,和 uhd_doc 一致,否则单个 IP 就能把管理员锁死)。窗口从最近一次成功之后算起;被拦下的请求不落表、不写审计。setup 失败也计入 IP 维度。
  • 客户端 IP:直连对端是回环/私网时才信 X-Real-IP / X-Forwarded-For 最后一跳,否则用对端地址;部署在公网反代后面时反代必须覆盖这两个头。
  • 密码:argon2id(m=19456 KiB, t=2, p=1),用 hash-wasm(WASM)。没选 @node-rs/argon2 / @denosaurs/argon2:都是原生库,需要 --allow-ffi,而编译后的二进制只授予 net/env/sys/read。输出标准 PHC 串,与 uhd_doc 的 Bun.password 互认。
  • TOTP:RFC 6238(SHA1/6 位/30 秒),密钥存 secret(purpose=other),totp_last_counter 防重放。
  • 恢复码:开启 TOTP 时一次性生成 8 个(XXXXX-XXXXX,不含易混的 I/O/0/1),明文只在开启 / 重新生成的响应里出现一次,库里 admin_recovery_code 存 argon2id 哈希;登录第二步用 recoveryCode 代替 totpCode,用过即作废(条件更新,并发只成功一次),审计 admin.recovery_code_login。输错计入失败限速(reason=bad_recovery_code)。停用 TOTP 时恢复码一并删除。
  • Passkey(@simplewebauthn/server,做法照 uhd_doc):替代密码的第一因子,登录成功直接建会话(不再要求 TOTP)。可以登记多把,各自命名;admin_passkey 存 COSE 公钥与签名计数器(计数器回退即拒绝,防克隆)。挑战存 webauthn_challenge,一次性、5 分钟有效。失败计入 IP 维度限速(账号维度记为 (passkey)),与密码登录共用 15 分钟窗口。
    • RP ID:环境变量 WEBAUTHN_RP_ID(浏览器地址栏里的主机名,不含协议和端口);不设时取请求 Host(反代后面取 X-Forwarded-Host)。换域名后已登记的 Passkey 全部失效,需要用密码登录后重新登记。
    • 来源:WEBAUTHN_ORIGINS(逗号分隔,例如 https://media.example.com);不设时接受主机名等于 RP ID 或其子域的来源。站点名 WEBAUTHN_RP_NAME(缺省 UHD Media Tool)。
    • 浏览器只在安全上下文(HTTPS 或 localhost)里提供 Passkey;用 IP 访问时登录页不显示入口。
  • 清理:worker 每小时删 7 天前的 login_attempt、过期/吊销超过 30 天的 admin_session。

鉴权中间件(其他模块怎么用)

/api/v1/*(health 与上面五个免鉴权端点除外)统一经过 apiKeyAuth:

  • Authorization: Bearer <key> → API Key(开发模式另认 DEV_ADMIN_KEY,生产忽略);
  • 否则 umt_session cookie → 管理员会话(scopes 视为 ["admin"])。

处理器里:c.get("actor") → { type: "admin" | "api_key", id, name, scopes }(contracts Actor);c.get("session") 仅 cookie 会话有值。路由照旧挂 requireScope("read" | "organize.read" | "organize.execute" | "admin"),两种调用者一视同仁;只允许控制台会话的操作在 contracts 注册表里标 auth: "session"(路由封装据此自动挂 requireAdminSession(),API Key 包括 admin scope 与 DEV_ADMIN_KEY 一律 403),免鉴权端点标 auth: "public" 且必须与 PUBLIC_AUTH_PATHS 一致(api-registry.test 断言)。目前标 session 的有:POST /auth/password、GET/POST /auth/totp、GET /auth/totp/setup、/auth/passkeys*(登录两步除外)、POST /approvals、PATCH /approvals/{id}。旧的 c.get("principal") 仍可用(kind 多了 admin_session)。写审计时 actor_type:admin → user,API Key → api_key。

密钥服务 ctx.secrets

  • create({purpose, plaintext, label?}) → secretRef、resolve(ref) → 明文、replace(ref, plaintext)(轮换)、remove(ref)。
  • AES-256-GCM;每条 secret 随机 16 字节盐经 HKDF-SHA256 从 SECRET_MASTER_KEY 派生独立密钥(info=umt/secret/v1),随机 12 字节 nonce,AAD 绑定 (id, purpose)(密文挪行/改用途即解密失败)。
  • 其它表只存 secret_ref;需要明文时现场 resolve,不缓存、不写日志、不进模型上下文。失败抛 SecretError(消息只含 ref)。
  • SECRET_MASTER_KEY 与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。kdf_params.kid 记着加密用的主密钥指纹(HMAC 派生,不能反推密钥)。

主密钥轮换

一次性命令 --role rotate-master-key:用 SECRET_MASTER_KEY 解密、SECRET_MASTER_KEY_NEW 重新加密 secret 表。逐行一个事务(select … for update → 解密 → 新盐新 nonce 加密 → 写回);已经能被新密钥解开的行跳过,所以中断后原样重跑即可。每 50 行一条进度日志,结束写审计 secret.master_key_rotate(只有计数与新密钥指纹)。新旧密钥都解不开的行保持原样,在日志里列出 id,命令退出码 1。

  1. 备份数据库(docs/runbook/backup-restore.md),旧密钥保留在原处。
  2. 生成新密钥:openssl rand -hex 32,存进密码管理器。
  3. 停掉 api 与 worker(轮换期间还在跑的进程只认旧密钥,读到已换过的行会解密失败):docker compose stop app。
  4. 执行轮换,两个密钥同时给:
    docker compose run --rm --no-deps -e SECRET_MASTER_KEY_NEW=<新密钥> app rotate-master-key
    
    日志最后一条是"主密钥轮换完成"(退出码 0)才算完成;中断或报错就原样再执行一次。
  5. 把 .env 里的 SECRET_MASTER_KEY 换成新密钥,删掉 SECRET_MASTER_KEY_NEW,启动:docker compose up -d app。到设置页各实例点"测试连接"确认凭证可用。
  6. 旧密钥在确认无误、且新密钥之后做过一次备份以前不要销毁:旧备份只能用旧密钥解。

回滚(第 5 步之前任何时候,或启动后发现凭证不可用):停 app,新旧对调再跑一遍——docker compose run --rm --no-deps -e SECRET_MASTER_KEY=<新密钥> -e SECRET_MASTER_KEY_NEW=<旧密钥> app rotate-master-key(开发环境:deno run -A --env-file=.env apps/server/src/main.ts --role rotate-master-key),完成后 .env 保持旧密钥启动。也可以直接用第 1 步的备份恢复。

  • 管理面:GET/POST /secrets、PATCH /secrets/{id}(轮换)、DELETE /secrets/{id}(仍被引用 → 409)。只返回元数据。

设置 ctx.settings

  • get(key) / all():settings 表按分组合并 DEFAULT_SETTINGS 后校验,缓存 30 秒;PATCH /settings 写库后广播 settings.changed(经 PG 总线,所有进程立即失效)。需要订阅变化:(ctx.settings as CachedSettingsReader).onChange(keys => …)(modules/auth-settings/provided.ts)。
  • GET /settings(read)、PATCH /settings(admin):按分组部分更新 → mergeSettings → GlobalSettings 整体校验(不合法 422)。
  • 自动化总开关 automation.enabled、恢复后只读对账 automation.readOnlyReconcile 就在这里改;各处理器执行自动写入前读 await ctx.settings.get("automation") 自行判断。
  • Pi 上游密钥:pi.apiKey 只写,存为 secret(purpose=llm_api_key)回填 pi.apiKeySecretRef;传 null 清除并删 secret。审计里只记 (set) / (cleared)。

API Key / 实例 / 审计

  • GET/POST /api-keys、PATCH /api-keys/{id}、DELETE /api-keys/{id}(撤销)。创建时明文 umt_… 只返回一次,库里只存 sha256。
  • library-roots、downloader-instances、media-server-instances、path-mappings:CRUD(GET 列表/详情 read,写 admin)+ DELETE。
    • 库根登记/改路径时写哨兵文件 .uhd-root(writeRootSentinel),目录不存在或不可写 → 412;POST /library-roots/{id}/test 查哨兵在线。
    • 实例凭证 credential 只写 → secret;POST /downloader-instances/{id}/test(qB:有用户名走登录,否则当 WebUI API Key)、POST /media-server-instances/{id}/test(Emby/Jellyfin ping),结果写回 health。
    • POST /path-mappings/{id}/verify:本地前缀是否为可达目录。
  • PUT /sites/{id}/credentials {label?, authKind, credential, username?}(兼容别名):等价于按 (site, label) 新建或更新一条 site_account,凭证换新 secret(旧的删除),响应不回显。规范接口是 /site-accounts 资源 CRUD,新客户端请用 POST /site-accounts / PATCH /site-accounts/{id}。
  • GET /audit-events?actorType&actorId&action&targetType&targetId&cursor&limit:新→旧,action=admin.* 前缀匹配。动作枚举见 contracts AuditAction。

引擎面板(仅 admin,只读直查 openworkflow schema)

GET /engine/runs?status&workflowName&from&to&cursor&limit、GET /engine/runs/{id}(含 stepAttempts、signals)、GET /engine/stats(按状态计数、最老 pending、持有租约的 worker 快照——非权威注册表)、POST /engine/runs/{id}/cancel(走 TaskEngine.cancel,先写业务侧取消标志)。不返回 config/context;input/output/error/signal data 按敏感字段名脱敏。

SSE 多进程总线

  • PgEventBus(http/sse-pg.ts):publish 在事务里持咨询锁插 sse_event(seq = bigserial,提交顺序即 seq 顺序)并 pg_notify('umt_events', seq);每个进程一条 LISTEN 连接,收到通知按 seq 从表里补读并分发给本进程订阅者;断线自动重连并补读。
  • GET /events?after=<seq>(或 Last-Event-ID)从表里补读;表只保留最近 10000 条(每 200 次发布裁剪),起点已被裁剪时先发 system.resync。
  • 谁用 SSE,谁用 JSON 轮询(W4-A 决定):浏览器 EventSource 带不了 Authorization 头,所以 SSE 只给控制台用(cookie 会话 umt_session,同源,withCredentials)。外部程序 / API Key 调用者用同一个端点取 JSON 页: GET /events?after=<seq>&limit=<1-1000>(带 limit,或只发 Accept: application/json 时返回 JSON,缺省 100 条),响应 { items: EventEnvelope[], nextAfter, hasMore, latestSeq, gap }。下次用 after=nextAfter 续读;hasMore=true 时立即再取,否则按自己的节奏轮询;gap=true 表示起点已被裁剪,先重拉业务快照再从 latestSeq 续读。数据来自同一张 sse_event 表,和 SSE 看到的 seq 一致。合同:Api.events.list(EventReplayQuery / EventReplayPage)。
  • ctx.events.publish(type, payload) 返回 Promise<EventEnvelope | null>,永不 reject(失败记日志),可以不 await。
  • 测试/无库场景用进程内 EventBus(publishSync / sinceSync 便于断言)。