Files
UHD_Media_Tool/docs/runbook/auth.md
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

115 lines
14 KiB
Markdown

# 认证门禁 / 设置 / 实例 / 密钥 / 引擎面板 / 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):
```sql
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. 执行轮换,两个密钥同时给:
```sh
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` 便于断言)。