Files
UHD_Media_Tool/docs/runbook/auth.md
T
rootandClaude Opus 5.5 1023642ef9 feat(web): W4-A wire console to real endpoints
- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local
  mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status;
  stream auto-follow only while the user stays near the bottom (abort stays reachable)
- settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript)
- download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of
  time-window engine run matching and path matching; new business-task tab with cancel/retry
- workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons
- preference tag revision history; subscription search by title/kind; identity/alias/subscription
  lists show entity titles; slot cards show current asset summary; policy preview sample titles
- DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary /
  OrganizePlanArtifactContent taken from contracts
- docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:32:11 +08:00

9.1 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;

然后重启 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(旧的删除),响应不回显。规范接口是 /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 便于断言)。