feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus

- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
  (migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
  hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
  SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
  CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
  sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
  {type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
  to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
  downloader/media-server instances (+test), path-mappings (+verify),
  PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
  seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
  admin APIs against mock qB/Emby, SSE across two Deno subprocesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:59:54 +08:00
1 parent 07db0dac5b
commit ff230e5da7
34 files changed
+4363 -78

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# 认证门禁 / 设置 / 实例 / 密钥 / 引擎面板 / 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;
```
然后重启 api 进程,按日志里新的 setup token 重新初始化。
## 登录与会话
| 端点 | 鉴权 | 说明 |
|---|---|---|
| `GET /auth/session` | 免 | `{initialized, authenticated, actor, expiresAt, totpEnabled}`;有会话时顺带补发 CSRF cookie |
| `POST /auth/setup` | 免 | 见上 |
| `POST /auth/login` | 免 | `{username, password, totpCode?}` → `{status:"ok", session}` 或 `{status:"totp_required"}`(密码对但缺验证码,不计失败) |
| `POST /auth/logout` | 登录 | 软删除吊销当前会话并清 cookie |
| `POST /auth/password` | 仅 cookie 会话 | 校验旧密码 → 改密 → **吊销其它所有会话**(保留当前) |
| `GET/POST /auth/totp` | 仅 cookie 会话 | `{action:"begin"}` 返回 otpauth URI;`{action:"enable", code}`;`{action:"disable", password, code}` |
- 会话: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` 防重放。Passkey 未做。
- 清理: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")`,两种调用者一视同仁;只允许控制台会话的操作挂 `requireAdminSession()`。旧的 `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` 与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。主密钥轮换(重新加密全表)尚未实现,`key_version` 已预留。
- 管理面:`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(旧的删除),响应不回显。
- `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`。
- `ctx.events.publish(type, payload)` 返回 `Promise<EventEnvelope | null>`,永不 reject(失败记日志),可以不 await。
- 测试/无库场景用进程内 `EventBus`(`publishSync` / `sinceSync` 便于断言)。