- 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]>
8.2 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)。
首次启动 / 初始化管理员
- api 进程启动时若
admin_user还没有密码,会生成一次性 setup token(库里只存 sha256),明文打印到日志一次:管理员尚未初始化。一次性 setup token(...):<token>。容器里:docker compose logs app | grep 'setup token'。 - 控制台登录页(
GET /api/v1/auth/session返回initialized:false)进入初始化模式,提交POST /auth/setup {setupToken, username, password}(密码 ≥10 字符)。成功即登录。 - 重启会重新生成 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_sessioncookie(HttpOnly、SameSite=Lax、Path=/,HTTPS 时 Secure),库里只存 sha256;固定 12 小时,不续期;last_seen_at60 秒节流更新。 - CSRF:
umt_csrfcookie(非 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_sessioncookie → 管理员会话(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.*前缀匹配。动作枚举见 contractsAuditAction。
引擎面板(仅 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便于断言)。