From ff230e5da79754475f37e7d60ca6aa9e4fec300d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 19:59:54 +0800 Subject: [PATCH] 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 --- apps/server/src/http/auth.ts | 171 +++++- apps/server/src/http/sse-pg.ts | 217 +++++++ apps/server/src/http/sse.ts | 72 ++- .../src/modules/auth-settings/auth-routes.ts | 243 ++++++++ .../src/modules/auth-settings/auth-service.ts | 337 +++++++++++ .../src/modules/auth-settings/crypto.ts | 112 ++++ .../src/modules/auth-settings/endpoint.ts | 225 +++++++ .../modules/auth-settings/engine-routes.ts | 260 ++++++++ .../server/src/modules/auth-settings/index.ts | 63 +- .../modules/auth-settings/instances-routes.ts | 557 ++++++++++++++++++ .../src/modules/auth-settings/keys-routes.ts | 205 +++++++ .../src/modules/auth-settings/provided.ts | 265 ++++++++- .../modules/auth-settings/settings-routes.ts | 73 +++ .../test/auth-settings/admin-api.test.ts | 374 ++++++++++++ apps/server/test/auth-settings/harness.ts | 176 ++++++ apps/server/test/auth-settings/login.test.ts | 218 +++++++ .../server/test/auth-settings/secrets.test.ts | 97 +++ apps/server/test/auth-settings/sse-pg.test.ts | 147 +++++ apps/server/test/bootstrap.test.ts | 17 +- apps/server/test/fixtures/sse-bus-proc.ts | 43 ++ apps/server/test/sse.test.ts | 12 +- deno.json | 1 + deno.lock | 5 + docs/runbook/auth.md | 83 +++ packages/contracts/src/auth.ts | 143 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/instances.ts | 34 ++ packages/contracts/src/ops.ts | 112 ++++ packages/contracts/test/db-sync.test.ts | 11 +- packages/contracts/test/schemas.test.ts | 2 + packages/db/migrations/0015_admin_auth.ts | 71 +++ packages/db/migrations/0016_sse_event.ts | 26 + packages/db/src/types.ts | 61 ++ packages/db/test/schema.test.ts | 7 + 34 files changed, 4363 insertions(+), 78 deletions(-) create mode 100644 apps/server/src/http/sse-pg.ts create mode 100644 apps/server/src/modules/auth-settings/auth-routes.ts create mode 100644 apps/server/src/modules/auth-settings/auth-service.ts create mode 100644 apps/server/src/modules/auth-settings/crypto.ts create mode 100644 apps/server/src/modules/auth-settings/endpoint.ts create mode 100644 apps/server/src/modules/auth-settings/engine-routes.ts create mode 100644 apps/server/src/modules/auth-settings/instances-routes.ts create mode 100644 apps/server/src/modules/auth-settings/keys-routes.ts create mode 100644 apps/server/src/modules/auth-settings/settings-routes.ts create mode 100644 apps/server/test/auth-settings/admin-api.test.ts create mode 100644 apps/server/test/auth-settings/harness.ts create mode 100644 apps/server/test/auth-settings/login.test.ts create mode 100644 apps/server/test/auth-settings/secrets.test.ts create mode 100644 apps/server/test/auth-settings/sse-pg.test.ts create mode 100644 apps/server/test/fixtures/sse-bus-proc.ts create mode 100644 docs/runbook/auth.md create mode 100644 packages/contracts/src/auth.ts create mode 100644 packages/db/migrations/0015_admin_auth.ts create mode 100644 packages/db/migrations/0016_sse_event.ts diff --git a/apps/server/src/http/auth.ts b/apps/server/src/http/auth.ts index b41f25ca..02af3319 100644 --- a/apps/server/src/http/auth.ts +++ b/apps/server/src/http/auth.ts @@ -1,11 +1,16 @@ -import { createHash, timingSafeEqual } from "node:crypto"; -import type { ApiScope } from "@umt/contracts"; +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { type Actor, type ApiScope, ApiScope as ApiScopeSchema, CSRF_HEADER, SESSION_COOKIE } from "@umt/contracts"; import type { DB, Kysely } from "@umt/db"; import type { MiddlewareHandler } from "hono"; +import { getCookie } from "hono/cookie"; import { ApiException } from "./errors.ts"; +// 鉴权中间件:控制台 cookie 会话(umt_session)与外部 API Key(Bearer)二选一, +// 统一写入 c.var.actor({type:'admin'|'api_key', id, name, scopes});requireScope 对两者一视同仁。 +// 旧字段 c.var.principal 保留兼容(kind 区分 api_key / dev_admin / admin_session)。 + export interface Principal { - kind: "api_key" | "dev_admin"; + kind: "api_key" | "dev_admin" | "admin_session"; id: string; name: string; scopes: string[]; @@ -18,13 +23,32 @@ export interface ApiKeyRecord { revokedAt: Date | null; } -/** API Key 查询接口,便于测试替换 */ +export interface SessionRecord { + id: string; + adminUserId: string; + username: string; + expiresAt: Date; + revokedAt: Date | null; + lastSeenAt: Date; +} + +/** API Key / 会话查询接口,便于测试替换(会话方法可选:测试内存仓库可以只实现 API Key) */ export interface ApiKeyStore { findByHash(hash: string): Promise; touch(id: string): Promise; + /** 按 sha256(token) 查会话(未过滤过期/吊销,由中间件判断) */ + findSession?(hash: string): Promise; + /** 节流更新 last_seen_at */ + touchSession?(id: string): Promise; } export const hashApiKey = (key: string) => createHash("sha256").update(key, "utf8").digest("hex"); +/** 会话 token 与 API Key 一样只存 sha256(高熵随机串不需要慢哈希) */ +export const hashSessionToken = hashApiKey; + +/** CSRF 双提交 token:HMAC-SHA256(key=会话原始 token, "umt/csrf/v1") */ +export const deriveCsrfToken = (sessionToken: string) => + createHmac("sha256", sessionToken).update("umt/csrf/v1").digest("base64url"); export function kyselyApiKeyStore(db: Kysely): ApiKeyStore { return { @@ -39,6 +63,26 @@ export function kyselyApiKeyStore(db: Kysely): ApiKeyStore { async touch(id) { await db.updateTable("api_key").set({ last_used_at: new Date() }).where("id", "=", id).execute(); }, + async findSession(hash) { + const row = await db + .selectFrom("admin_session as s") + .innerJoin("admin_user as u", "u.id", "s.admin_user_id") + .select(["s.id", "s.admin_user_id", "u.username", "s.expires_at", "s.revoked_at", "s.last_seen_at"]) + .where("s.session_hash", "=", hash) + .executeTakeFirst(); + if (!row?.username) return null; + return { + id: row.id, + adminUserId: row.admin_user_id, + username: row.username, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + lastSeenAt: row.last_seen_at, + }; + }, + async touchSession(id) { + await db.updateTable("admin_session").set({ last_seen_at: new Date() }).where("id", "=", id).execute(); + }, }; } @@ -48,7 +92,28 @@ export interface AuthOptions { devAdminKey?: string | undefined; } -export type AuthEnv = { Variables: { principal: Principal } }; +export interface SessionVar { + id: string; + /** cookie 里的原始 token(用于派生 CSRF、改密时保留当前会话);不要写日志 */ + token: string; + adminUserId: string; + expiresAt: Date; +} + +export type AuthEnv = { + Variables: { + principal: Principal; + actor: Actor; + /** 仅 cookie 会话调用时存在 */ + session: SessionVar | undefined; + }; +}; + +/** 免鉴权路径(相对 /api/v1):登录前就要能访问;带有效 cookie 时仍会挂上 actor */ +export const PUBLIC_AUTH_PATHS = ["/auth/setup", "/auth/login", "/auth/session"] as const; +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +/** last_seen_at 节流写库间隔 */ +const TOUCH_INTERVAL_MS = 60_000; function safeEqual(a: string, b: string) { const ab = Buffer.from(a); @@ -56,25 +121,77 @@ function safeEqual(a: string, b: string) { return ab.length === bb.length && timingSafeEqual(ab, bb); } -/** Bearer API Key 鉴权:成功后在 c.var.principal 写入调用者 */ -export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler { - return async (c, next) => { - const header = c.req.header("authorization") ?? ""; - const m = /^Bearer\s+(\S+)$/i.exec(header); - if (!m?.[1]) throw new ApiException("unauthorized", "缺少 Bearer API Key"); - const key = m[1]; +const toScopes = (s: readonly string[]): ApiScope[] => + s.filter((x): x is ApiScope => ApiScopeSchema.safeParse(x).success); - if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) { - c.set("principal", { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] }); +/** + * /api/v1 鉴权: + * - `Authorization: Bearer `:API Key(开发模式另认 DEV_ADMIN_KEY); + * - 否则 cookie `umt_session`:管理员会话(未过期、未吊销),写方法还要校验 `x-csrf-token` == HMAC 派生值; + * - PUBLIC_AUTH_PATHS 免鉴权(有有效会话时照样挂 actor)。 + */ +export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler { + const setActor = ( + c: Parameters>[0], + p: Principal, + type: Actor["type"], + session?: SessionVar, + ) => { + c.set("principal", p); + c.set("actor", { type, id: p.id, name: p.name, scopes: toScopes(p.scopes) }); + c.set("session", session); + }; + + return async (c, next) => { + const rel = c.req.path.replace(/^\/api\/v1/, ""); + const isPublic = (PUBLIC_AUTH_PATHS as readonly string[]).includes(rel); + const header = c.req.header("authorization") ?? ""; + + if (header) { + const m = /^Bearer\s+(\S+)$/i.exec(header); + if (!m?.[1]) { + if (isPublic) return next(); + throw new ApiException("unauthorized", "Authorization 头格式应为 Bearer "); + } + const key = m[1]; + if (opts.devAdminKey && safeEqual(key, opts.devAdminKey)) { + setActor(c, { kind: "dev_admin", id: "dev-admin", name: "dev-admin", scopes: ["admin"] }, "admin"); + return next(); + } + const rec = opts.store ? await opts.store.findByHash(hashApiKey(key)) : null; + if (!rec || rec.revokedAt) { + if (isPublic) return next(); + throw new ApiException("unauthorized", "API Key 无效或已撤销"); + } + setActor(c, { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes }, "api_key"); + // last_used_at 尽力更新,失败不影响请求 + opts.store?.touch(rec.id).catch(() => {}); return next(); } - if (!opts.store) throw new ApiException("unauthorized", "API Key 无效"); - const rec = await opts.store.findByHash(hashApiKey(key)); - if (!rec || rec.revokedAt) throw new ApiException("unauthorized", "API Key 无效或已撤销"); - c.set("principal", { kind: "api_key", id: rec.id, name: rec.name, scopes: rec.scopes }); - // last_used_at 尽力更新,失败不影响请求 - opts.store.touch(rec.id).catch(() => {}); - return next(); + + const token = getCookie(c, SESSION_COOKIE); + if (token && opts.store?.findSession) { + const s = await opts.store.findSession(hashSessionToken(token)); + const now = Date.now(); + if (s && !s.revokedAt && s.expiresAt.getTime() > now) { + if (!isPublic && WRITE_METHODS.has(c.req.method)) { + const provided = c.req.header(CSRF_HEADER); + if (!provided || !safeEqual(provided, deriveCsrfToken(token))) { + throw new ApiException("forbidden", "CSRF 校验失败,请刷新页面后重试"); + } + } + setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", { + id: s.id, + token, + adminUserId: s.adminUserId, + expiresAt: s.expiresAt, + }); + if (now - s.lastSeenAt.getTime() > TOUCH_INTERVAL_MS) opts.store.touchSession?.(s.id).catch(() => {}); + return next(); + } + } + if (isPublic) return next(); + throw new ApiException("unauthorized", token ? "会话已过期或已注销,请重新登录" : "需要登录或 Bearer API Key"); }; } @@ -86,7 +203,7 @@ export function hasScope(granted: readonly string[], required: ApiScope): boolea return false; } -/** 要求调用者至少具备其中一个 scope */ +/** 要求调用者至少具备其中一个 scope(cookie 会话与 API Key 同样适用) */ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler { return async (c, next) => { const p = c.get("principal"); @@ -97,3 +214,13 @@ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler { return next(); }; } + +/** 只允许控制台管理员会话(或开发模式 DEV_ADMIN_KEY),拒绝外部 API Key —— 用于改密、TOTP 等账号操作 */ +export function requireAdminSession(): MiddlewareHandler { + return async (c, next) => { + const p = c.get("principal"); + if (!p) throw new ApiException("unauthorized", "未鉴权"); + if (p.kind !== "admin_session") throw new ApiException("forbidden", "该操作只能在控制台登录后进行"); + return next(); + }; +} diff --git a/apps/server/src/http/sse-pg.ts b/apps/server/src/http/sse-pg.ts new file mode 100644 index 00000000..410d43f8 --- /dev/null +++ b/apps/server/src/http/sse-pg.ts @@ -0,0 +1,217 @@ +import type { EventEnvelope } from "@umt/contracts"; +import { type DB, type Kysely, sql } from "@umt/db"; +import pg from "pg"; +import type { Logger } from "pino"; +import type { BusListener, EventBusPort, SinceResult } from "./sse.ts"; + +/** 默认通知通道 */ +export const EVENTS_CHANNEL = "umt_events"; +/** 发布时串行化 seq 分配与提交的咨询锁键('umte') */ +const PUBLISH_LOCK_KEY = 0x756d7465; + +export interface PgEventBusOptions { + db: Kysely; + /** LISTEN 专用连接(不走连接池) */ + connectionString: string; + logger: Logger; + channel?: string; + /** sse_event 保留最近多少条供 ?after= 补读,默认 10000 */ + retain?: number; + /** 每发布多少条裁剪一次,默认 200 */ + pruneEvery?: number; + /** LISTEN 断线重连退避上限,默认 10s */ + maxReconnectMs?: number; + /** 仅测试:LISTEN 连接的 search_path 等 pg 启动参数 */ + pgOptions?: string; +} + +type Row = { seq: number; type: string; at: Date; payload: unknown }; +const toEnvelope = (r: Row): EventEnvelope => ({ + seq: Number(r.seq), + type: r.type, + at: r.at.toISOString(), + payload: r.payload, +}); + +/** + * 多进程 SSE 总线:发布 = 在 sse_event 追加一行(持咨询锁,保证 seq 顺序即提交顺序)+ pg_notify(channel, seq); + * 每个进程一条 LISTEN 连接,收到通知后按 seq 从表里补读并分发给本进程订阅者(包括自己发布的)。 + * LISTEN 断线期间的事件在重连后按 lastDispatched 补读,不丢。 + */ +export class PgEventBus implements EventBusPort { + private readonly listeners = new Set(); + private readonly channel: string; + private readonly retain: number; + private readonly pruneEvery: number; + private client: pg.Client | null = null; + private lastDispatched = 0; + private draining: Promise | null = null; + private drainAgain = false; + private published = 0; + private stopped = false; + private reconnectTimer: ReturnType | null = null; + + private constructor(private readonly opts: PgEventBusOptions) { + this.channel = opts.channel ?? EVENTS_CHANNEL; + if (!/^[a-z_][a-z0-9_]*$/.test(this.channel)) throw new Error(`非法通道名 ${this.channel}`); + this.retain = opts.retain ?? 10_000; + this.pruneEvery = opts.pruneEvery ?? 200; + } + + /** 建总线并开始 LISTEN;从当前最大 seq 之后开始分发 */ + static async start(opts: PgEventBusOptions): Promise { + const bus = new PgEventBus(opts); + bus.lastDispatched = await bus.maxSeq(); + await bus.connect(); + return bus; + } + + private get log() { + return this.opts.logger; + } + + private async maxSeq(): Promise { + const r = await sql<{ m: number | null }>`select max(seq) as m from sse_event`.execute(this.opts.db); + return Number(r.rows[0]?.m ?? 0); + } + + private async connect(): Promise { + const client = new pg.Client({ + connectionString: this.opts.connectionString, + ...(this.opts.pgOptions ? { options: this.opts.pgOptions } : {}), + }); + client.on("notification", (msg) => { + if (msg.channel === this.channel) this.scheduleDrain(); + }); + const onLost = (err?: Error) => { + if (this.client !== client) return; + this.client = null; + client.removeAllListeners("notification"); + client.end().catch(() => {}); + if (!this.stopped) { + this.log.warn({ err }, "SSE 总线 LISTEN 连接断开,准备重连"); + this.reconnect(500); + } + }; + client.on("error", onLost); + client.on("end", () => onLost()); + await client.connect(); + await client.query(`listen ${this.channel}`); + this.client = client; + // 断线期间可能漏掉通知:连上后补读一次 + this.scheduleDrain(); + } + + private reconnect(delayMs: number) { + if (this.stopped || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + try { + await this.connect(); + this.log.info("SSE 总线 LISTEN 已重连"); + } catch (err) { + this.log.warn({ err }, "SSE 总线重连失败"); + this.reconnect(Math.min(delayMs * 2, this.opts.maxReconnectMs ?? 10_000)); + } + }, delayMs); + } + + private scheduleDrain() { + if (this.draining) { + this.drainAgain = true; + return; + } + this.draining = (async () => { + try { + do { + this.drainAgain = false; + await this.drainOnce(); + } while (this.drainAgain && !this.stopped); + } catch (err) { + this.log.warn({ err }, "SSE 总线补读失败"); + } finally { + this.draining = null; + } + })(); + } + + private async drainOnce() { + for (;;) { + const rows = await sql`select seq, type, at, payload from sse_event + where seq > ${this.lastDispatched} order by seq limit 500`.execute(this.opts.db); + for (const r of rows.rows) { + const e = toEnvelope(r); + this.lastDispatched = e.seq; + for (const l of this.listeners) { + try { + l(e); + } catch (err) { + this.log.warn({ err, type: e.type }, "SSE 订阅者异常"); + } + } + } + if (rows.rows.length < 500) return; + } + } + + async publish

(type: string, payload: P): Promise | null> { + try { + const row = await this.opts.db.transaction().execute(async (trx) => { + await sql`select pg_advisory_xact_lock(${PUBLISH_LOCK_KEY})`.execute(trx); + const r = await sql`insert into sse_event (type, payload) + values (${type}, ${JSON.stringify(payload ?? null)}::jsonb) returning seq, type, at, payload`.execute(trx); + const inserted = r.rows[0]!; + // NOTIFY 在提交时才投递 + await sql`select pg_notify(${this.channel}, ${String(inserted.seq)})`.execute(trx); + return inserted; + }); + this.published += 1; + if (this.published % this.pruneEvery === 0) void this.prune(); + return toEnvelope(row) as EventEnvelope

; + } catch (err) { + this.log.error({ err, type }, "SSE 事件发布失败"); + return null; + } + } + + /** 只保留最近 retain 条 */ + async prune(): Promise { + try { + await sql`delete from sse_event where seq <= (select max(seq) from sse_event) - ${this.retain}`.execute( + this.opts.db, + ); + } catch (err) { + this.log.warn({ err }, "sse_event 裁剪失败"); + } + } + + async since(after: number, limit = 1000): Promise { + const [rows, bounds] = await Promise.all([ + sql`select seq, type, at, payload from sse_event where seq > ${after} order by seq limit ${limit}`.execute( + this.opts.db, + ), + sql<{ lo: number | null; hi: number | null }>`select min(seq) as lo, max(seq) as hi from sse_event`.execute( + this.opts.db, + ), + ]); + const lo = bounds.rows[0]?.lo; + const hi = Number(bounds.rows[0]?.hi ?? 0); + // 裁剪过:请求起点早于最老保留条目 + const gap = after > 0 && lo != null && after < Number(lo) - 1; + return { events: rows.rows.map(toEnvelope), gap, latestSeq: Math.max(hi, after) }; + } + + subscribe(l: BusListener): () => void { + this.listeners.add(l); + return () => this.listeners.delete(l); + } + + async stop(): Promise { + this.stopped = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + const c = this.client; + this.client = null; + await c?.end().catch(() => {}); + await this.draining; + } +} diff --git a/apps/server/src/http/sse.ts b/apps/server/src/http/sse.ts index 4919ecf4..696a3be9 100644 --- a/apps/server/src/http/sse.ts +++ b/apps/server/src/http/sse.ts @@ -3,27 +3,39 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { ApiException } from "./errors.ts"; -type Listener = (e: EventEnvelope) => void; +export type BusListener = (e: EventEnvelope) => void; -/** - * SSE 总线端口。模块与 SSE 路由只依赖这个接口;W2-A 把实现换成 PG NOTIFY + 持久化 seq 时接口不变。 - */ -export interface EventBusPort { - readonly latestSeq: number; - publish

(type: string, payload: P): EventEnvelope

; - /** 取 seq > after 的事件;gap=true 表示起点已不可补读,客户端需重新拉快照 */ - since(after: number): { events: EventEnvelope[]; gap: boolean }; - subscribe(l: Listener): () => void; +/** since() 结果:gap=true 表示起点已不可补读(被裁剪),客户端需重新拉快照 */ +export interface SinceResult { + events: EventEnvelope[]; + gap: boolean; + latestSeq: number; } /** - * 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。 - * 多进程部署时需换成 PG LISTEN/NOTIFY 或 outbox 投影(W2)。 + * SSE 总线端口。模块与 SSE 路由只依赖这个接口。 + * 生产实现是 PgEventBus(sse-pg.ts:sse_event 表 + LISTEN/NOTIFY,多进程可见); + * EventBus 是进程内实现,只给单元测试与无库场景用。 + */ +export interface EventBusPort { + /** + * 发布事件并广播给所有进程的订阅者;resolve 为带 seq 的信封。 + * 永不 reject:持久化失败时记日志并返回 null(调用方可以不 await)。 + */ + publish

(type: string, payload: P): Promise | null>; + /** 取 seq > after 的事件(按 seq 升序,最多 limit 条) */ + since(after: number, limit?: number): Promise; + /** 订阅本进程收到的事件(含其它进程发布的);返回退订函数 */ + subscribe(l: BusListener): () => void; +} + +/** + * 进程内事件总线:序号自增,保留最近 capacity 条供断线补读。仅测试/单进程无库场景使用。 */ export class EventBus implements EventBusPort { private seq = 0; private readonly buffer: EventEnvelope[] = []; - private readonly listeners = new Set(); + private readonly listeners = new Set(); constructor(private readonly capacity = 1000) {} @@ -31,7 +43,8 @@ export class EventBus implements EventBusPort { return this.seq; } - publish

(type: string, payload: P): EventEnvelope

{ + /** 同步发布(测试便利);publish 是它的异步包装 */ + publishSync

(type: string, payload: P): EventEnvelope

{ const e: EventEnvelope

= { seq: ++this.seq, type, at: new Date().toISOString(), payload }; this.buffer.push(e); if (this.buffer.length > this.capacity) this.buffer.shift(); @@ -39,19 +52,28 @@ export class EventBus implements EventBusPort { return e; } - /** 取 seq > after 的缓冲事件;gap=true 表示请求的起点已被淘汰,客户端需重新拉快照 */ - since(after: number): { events: EventEnvelope[]; gap: boolean } { - const oldest = this.buffer[0]?.seq ?? this.seq + 1; - const gap = after > 0 && after < oldest - 1; - return { events: this.buffer.filter((e) => e.seq > after), gap }; + publish

(type: string, payload: P): Promise | null> { + return Promise.resolve(this.publishSync(type, payload)); } - subscribe(l: Listener): () => void { + sinceSync(after: number, limit = 1000): SinceResult { + const oldest = this.buffer[0]?.seq ?? this.seq + 1; + const gap = after > 0 && after < oldest - 1; + return { events: this.buffer.filter((e) => e.seq > after).slice(0, limit), gap, latestSeq: this.seq }; + } + + since(after: number, limit?: number): Promise { + return Promise.resolve(this.sinceSync(after, limit)); + } + + subscribe(l: BusListener): () => void { this.listeners.add(l); return () => this.listeners.delete(l); } } +const SINCE_PAGE = 500; + export interface SseOptions { bus: EventBusPort; keepaliveMs?: number; @@ -79,10 +101,10 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) { wake?.(); }); - const { events, gap } = bus.since(after); + const { events, gap, latestSeq } = await bus.since(after, SINCE_PAGE); let last = after; if (gap) { - await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq: bus.latestSeq }) }); + await stream.writeSSE({ event: EventType.resync, data: JSON.stringify({ latestSeq }) }); } const write = async (e: EventEnvelope) => { if (e.seq <= last) return; @@ -90,6 +112,12 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) { await stream.writeSSE({ id: String(e.seq), event: e.type, data: JSON.stringify(e) }); }; for (const e of events) await write(e); + // 积压超过一页时继续翻页补读(实时事件已在 queue 里排队,write 按 seq 去重) + let page = events; + while (page.length >= SINCE_PAGE && !closed) { + page = (await bus.since(last, SINCE_PAGE)).events; + for (const e of page) await write(e); + } const timer = setInterval(() => { void stream.write(": keepalive\n\n"); diff --git a/apps/server/src/modules/auth-settings/auth-routes.ts b/apps/server/src/modules/auth-settings/auth-routes.ts new file mode 100644 index 00000000..797d80e1 --- /dev/null +++ b/apps/server/src/modules/auth-settings/auth-routes.ts @@ -0,0 +1,243 @@ +import { + AdminSetupRequest, + CSRF_COOKIE, + LoginRequest, + LoginResult, + PasswordChangeRequest, + PasswordChangeResult, + SESSION_COOKIE, + SessionInfo, + TotpAction, + TotpActionResult, + TotpStatus, +} from "@umt/contracts"; +import type { Context } from "hono"; +import { deleteCookie, setCookie } from "hono/cookie"; +import { z } from "zod"; +import { type AuthEnv, deriveCsrfToken, requireAdminSession } from "../../http/auth.ts"; +import { ApiException } from "../../http/errors.ts"; +import type { ModuleApp, ServerContext } from "../types.ts"; +import { + adminActor, + type CreatedSession, + changePassword, + createSession, + loadAdmin, + revokeSession, + sessionInfo, + setupAdmin, + totpBegin, + totpDisable, + totpEnable, + totpStatus, + verifyLogin, +} from "./auth-service.ts"; +import { audit, type Def, endpoint } from "./endpoint.ts"; + +// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS), +// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。 + +const tags = ["auth"]; +const OkResult = z.object({ ok: z.literal(true) }); + +const D = { + setup: { + method: "post", + path: "/auth/setup", + scope: "read", + summary: "首次初始化管理员", + body: AdminSetupRequest, + response: SessionInfo, + tags, + }, + login: { + method: "post", + path: "/auth/login", + scope: "read", + summary: "管理员登录", + body: LoginRequest, + response: LoginResult, + tags, + }, + logout: { method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult, tags }, + session: { + method: "get", + path: "/auth/session", + scope: "read", + summary: "当前登录态(免鉴权)", + response: SessionInfo, + tags, + }, + password: { + method: "post", + path: "/auth/password", + scope: "admin", + summary: "修改密码(吊销其它会话)", + body: PasswordChangeRequest, + response: PasswordChangeResult, + tags, + }, + totpGet: { method: "get", path: "/auth/totp", scope: "admin", summary: "TOTP 状态", response: TotpStatus, tags }, + totpPost: { + method: "post", + path: "/auth/totp", + scope: "admin", + summary: "TOTP 绑定/启用/停用", + body: TotpAction, + response: TotpActionResult, + tags, + }, +} as const satisfies Record; + +const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i; + +/** + * 客户端 IP:只有直连对端是回环/私网(即前面有自己的反代)时才信 X-Real-IP / X-Forwarded-For 最后一跳, + * 否则用对端地址——防止客户端伪造头绕过 IP 限速(对应 Caddy trusted_proxies_strict)。 + */ +export function clientIp(c: Context): string { + const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname; + if (remote && !PRIVATE_IP.test(remote)) return remote; + const xri = c.req.header("x-real-ip")?.trim(); + const xff = c.req + .header("x-forwarded-for") + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean) + .at(-1); + return xri || xff || remote || "unknown"; +} + +function isHttps(c: Context): boolean { + if (new URL(c.req.url).protocol === "https:") return true; + const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname; + if (remote && !PRIVATE_IP.test(remote)) return false; + return c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https"; +} + +function issueCookies(c: Context, s: { token: string; expiresAt: Date }) { + const secure = isHttps(c); + setCookie(c, SESSION_COOKIE, s.token, { httpOnly: true, sameSite: "Lax", path: "/", secure, expires: s.expiresAt }); + setCookie(c, CSRF_COOKIE, deriveCsrfToken(s.token), { + httpOnly: false, + sameSite: "Lax", + path: "/", + secure, + expires: s.expiresAt, + }); +} + +function clearCookies(c: Context) { + deleteCookie(c, SESSION_COOKIE, { path: "/" }); + deleteCookie(c, CSRF_COOKIE, { path: "/" }); +} + +export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) { + const deps = () => ({ db: ctx.db, logger: ctx.logger, secrets: ctx.secrets }); + const meta = (c: Context) => ({ ip: clientIp(c), userAgent: c.req.header("user-agent") ?? null }); + + const startSession = async (c: Context, adminId: string): Promise => { + const s = await createSession(ctx.db, adminId, meta(c)); + issueCookies(c, s); + return s; + }; + + endpoint( + app, + D.setup, + async ({ body, c }) => { + const admin = await setupAdmin(deps(), body, clientIp(c)); + const s = await startSession(c, admin.id); + const actor = adminActor(admin); + await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username }); + ctx.logger.info({ username: admin.username }, "管理员已初始化"); + return await sessionInfo(ctx.db, actor, s.expiresAt); + }, + { public: true }, + ); + + endpoint( + app, + D.login, + async ({ body, c }) => { + const r = await verifyLogin(deps(), body, clientIp(c)); + if (r.status === "totp_required") return { status: "totp_required" as const }; + const s = await startSession(c, r.admin.id); + const actor = adminActor(r.admin); + await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) }); + return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) }; + }, + { public: true }, + ); + + endpoint( + app, + D.session, + async ({ c }) => { + const session = c.get("session"); + // CSRF cookie 丢了(被清)时顺手补发 + if (session) { + setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), { + httpOnly: false, + sameSite: "Lax", + path: "/", + secure: isHttps(c), + expires: session.expiresAt, + }); + } + return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null); + }, + { public: true }, + ); + + endpoint(app, D.logout, async ({ c, actor }) => { + const session = c.get("session"); + if (session) { + await revokeSession(ctx.db, session.id, "logout"); + await audit(ctx, actor, "admin.logout", { type: "admin_session", id: session.id }); + } + clearCookies(c); + return { ok: true as const }; + }); + + endpoint( + app, + D.password, + async ({ body, c, actor }) => { + const session = c.get("session"); + if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行"); + const revoked = await changePassword(deps(), body, { + adminUserId: session.adminUserId, + sessionId: session.id, + ip: clientIp(c), + }); + await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked }); + return { revokedSessions: revoked }; + }, + { middleware: [requireAdminSession()] }, + ); + + endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)), { middleware: [requireAdminSession()] }); + + endpoint( + app, + D.totpPost, + async ({ body, actor }) => { + const admin = await loadAdmin(ctx.db); + if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化"); + if (body.action === "begin") { + const r = await totpBegin(deps(), admin); + return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret }; + } + if (body.action === "enable") { + await totpEnable(deps(), admin, body.code); + await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id }); + } else { + await totpDisable(deps(), admin, body); + await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id }); + } + return { status: totpStatus(await loadAdmin(ctx.db)) }; + }, + { middleware: [requireAdminSession()] }, + ); +} diff --git a/apps/server/src/modules/auth-settings/auth-service.ts b/apps/server/src/modules/auth-settings/auth-service.ts new file mode 100644 index 00000000..a3934464 --- /dev/null +++ b/apps/server/src/modules/auth-settings/auth-service.ts @@ -0,0 +1,337 @@ +import { randomBytes } from "node:crypto"; +import type { Actor, SessionInfo } from "@umt/contracts"; +import { type DB, type Kysely, sql } from "@umt/db"; +import type { Logger } from "pino"; +import { hashSessionToken } from "../../http/auth.ts"; +import { ApiException } from "../../http/errors.ts"; +import type { SecretStore } from "../types.ts"; +import { + base32Encode, + dummyPasswordHash, + hashPassword, + randomToken, + sha256Hex, + verifyPassword, + verifyTotp, +} from "./crypto.ts"; + +// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md): +// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、 +// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。 + +/** 固定会话时长 12 小时,无滑动续期 */ +export const SESSION_TTL_MS = 12 * 3600_000; +/** 失败限速窗口与阈值(IP、账号各自独立计数) */ +export const LOGIN_WINDOW_MS = 15 * 60_000; +export const MAX_FAILURES_PER_IP = 5; +/** 账号维度阈值取 IP 的 3 倍(同 uhd_doc):等于 IP 阈值时,单个 IP 就能把管理员锁在门外 */ +export const MAX_FAILURES_PER_ACCOUNT = 15; +const TOTP_ISSUER = "UHD Media Tool"; + +export interface AuthDeps { + db: Kysely; + logger: Logger; + secrets: SecretStore; +} + +export interface AdminRow { + id: string; + username: string | null; + password_hash: string | null; + totp_secret_ref: string | null; + totp_pending_secret_ref: string | null; + totp_enabled_at: Date | null; + totp_last_counter: number | null; +} + +export async function loadAdmin(db: Kysely): Promise { + const r = await db + .selectFrom("admin_user") + .select([ + "id", + "username", + "password_hash", + "totp_secret_ref", + "totp_pending_secret_ref", + "totp_enabled_at", + "totp_last_counter", + ]) + .executeTakeFirst(); + return r ?? null; +} + +export const adminActor = (a: { id: string; username: string | null }): Actor => ({ + type: "admin", + id: a.id, + name: a.username ?? "admin", + scopes: ["admin"], +}); + +/** + * 首次启动:还没有管理员时生成一次性 setup token,只存 sha256,明文打印到日志一次。 + * 条件 UPSERT:已初始化(password_hash 非空)的行不会被改写;并发启动时各自覆盖,以最后打印的为准。 + * 返回明文 token(已初始化则 null)。 + */ +export async function ensureSetupToken(deps: Pick): Promise { + const token = randomToken(24); + const r = await sql<{ id: string }>` + insert into admin_user (singleton, setup_token_hash) values (true, ${sha256Hex(token)}) + on conflict (singleton) do update set setup_token_hash = excluded.setup_token_hash + where admin_user.password_hash is null + returning id`.execute(deps.db); + if (!r.rows.length) return null; + // 注意:logger 会把名为 *token* 的字段脱敏,所以 token 放在消息文本里 + deps.logger.warn(`管理员尚未初始化。一次性 setup token(打开控制台登录页完成初始化;重启会重新生成):${token}`); + return token; +} + +// ── 限速 ── + +async function recentFailures(db: Kysely, col: "ip" | "username", value: string): Promise { + const since = new Date(Date.now() - LOGIN_WINDOW_MS); + const c = sql.ref(col); + const r = await sql<{ n: number }>` + select count(*)::int as n from login_attempt + where ${c} = ${value} and not success and at > ${since} + and at > coalesce((select max(at) from login_attempt where ${c} = ${value} and success), '-infinity')`.execute( + db, + ); + return r.rows[0]?.n ?? 0; +} + +/** 已达阈值 → 429;被拦下的请求不记尝试、不写审计(防扫描器撑爆表) */ +export async function assertNotRateLimited(db: Kysely, ip: string, username: string): Promise { + const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]); + if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) { + throw new ApiException("rate_limited", "失败次数过多,请 15 分钟后再试"); + } +} + +export async function recordAttempt( + db: Kysely, + a: { ip: string; username: string; success: boolean; reason?: string }, +): Promise { + await db + .insertInto("login_attempt") + .values({ ip: a.ip, username: a.username, success: a.success, reason: a.reason ?? null }) + .execute(); +} + +// ── 会话 ── + +export interface CreatedSession { + id: string; + token: string; + expiresAt: Date; +} + +export async function createSession( + db: Kysely, + adminUserId: string, + meta: { ip: string; userAgent: string | null }, +): Promise { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + const row = await db + .insertInto("admin_session") + .values({ + admin_user_id: adminUserId, + session_hash: hashSessionToken(token), + ip: meta.ip, + user_agent: meta.userAgent?.slice(0, 400) ?? null, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + return { id: row.id, token, expiresAt }; +} + +export async function revokeSession(db: Kysely, id: string, reason: string): Promise { + await db + .updateTable("admin_session") + .set({ revoked_at: new Date(), revoke_reason: reason }) + .where("id", "=", id) + .where("revoked_at", "is", null) + .execute(); +} + +/** 吊销该管理员除 keepId 以外的全部有效会话,返回吊销条数 */ +export async function revokeOtherSessions( + db: Kysely, + adminUserId: string, + keepId: string | null, + reason: string, +): Promise { + let q = db + .updateTable("admin_session") + .set({ revoked_at: new Date(), revoke_reason: reason }) + .where("admin_user_id", "=", adminUserId) + .where("revoked_at", "is", null); + if (keepId) q = q.where("id", "!=", keepId); + const r = await q.executeTakeFirst(); + return Number(r.numUpdatedRows); +} + +// ── setup / login / 改密 ── + +export async function setupAdmin( + deps: AuthDeps, + input: { setupToken: string; username: string; password: string }, + ip: string, +): Promise { + await assertNotRateLimited(deps.db, ip, "(setup)"); + const passwordHash = await hashPassword(input.password); + const r = await sql<{ id: string }>` + update admin_user set username = ${input.username}, password_hash = ${passwordHash}, + setup_token_hash = null, password_changed_at = now() + where password_hash is null and setup_token_hash = ${sha256Hex(input.setupToken)} + returning id`.execute(deps.db); + if (!r.rows.length) { + const admin = await loadAdmin(deps.db); + if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化"); + await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" }); + throw new ApiException("unauthorized", "setup token 无效(以服务端日志最新打印的为准)"); + } + const admin = await loadAdmin(deps.db); + if (!admin) throw new Error("admin_user 行丢失"); + return admin; +} + +export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" }; + +export async function verifyLogin( + deps: AuthDeps, + input: { username: string; password: string; totpCode?: string | undefined }, + ip: string, +): Promise { + await assertNotRateLimited(deps.db, ip, input.username); + const admin = await loadAdmin(deps.db); + if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化,请先完成初始化"); + const userOk = admin.username === input.username; + // 用户名不对也跑一次 argon2,避免用时差探测账号 + let pwOk = false; + if (userOk) pwOk = await verifyPassword(input.password, admin.password_hash); + else await verifyPassword(input.password, await dummyPasswordHash()); + if (!pwOk) { + await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" }); + throw new ApiException("unauthorized", "用户名或密码错误"); + } + if (admin.totp_enabled_at && admin.totp_secret_ref) { + // 密码正确但缺第二步:不计失败 + if (!input.totpCode) return { status: "totp_required" }; + if (!(await consumeTotp(deps, admin, input.totpCode))) { + await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" }); + throw new ApiException("unauthorized", "验证码错误"); + } + } + await recordAttempt(deps.db, { ip, username: input.username, success: true }); + return { status: "ok", admin }; +} + +/** 校验并消费 TOTP 计数器(条件更新防并发重放) */ +async function consumeTotp(deps: AuthDeps, admin: AdminRow, code: string): Promise { + if (!admin.totp_secret_ref) return false; + const secret = await deps.secrets.resolve(admin.totp_secret_ref); + const counter = verifyTotp(secret, code, admin.totp_last_counter); + if (counter === null) return false; + const r = await sql`update admin_user set totp_last_counter = ${counter} + where id = ${admin.id} and (totp_last_counter is null or totp_last_counter < ${counter})`.execute(deps.db); + return Number(r.numAffectedRows ?? 0) > 0; +} + +export async function changePassword( + deps: AuthDeps, + input: { currentPassword: string; newPassword: string }, + ctx: { adminUserId: string; sessionId: string; ip: string }, +): Promise { + const admin = await loadAdmin(deps.db); + if (!admin?.password_hash || admin.id !== ctx.adminUserId) throw new ApiException("unauthorized", "会话无效"); + await assertNotRateLimited(deps.db, ctx.ip, admin.username ?? ""); + if (!(await verifyPassword(input.currentPassword, admin.password_hash))) { + await recordAttempt(deps.db, { + ip: ctx.ip, + username: admin.username ?? "", + success: false, + reason: "bad_current_password", + }); + throw new ApiException("unauthorized", "当前密码错误"); + } + const hash = await hashPassword(input.newPassword); + await deps.db + .updateTable("admin_user") + .set({ password_hash: hash, password_changed_at: new Date() }) + .where("id", "=", admin.id) + .execute(); + return await revokeOtherSessions(deps.db, admin.id, ctx.sessionId, "password_change"); +} + +// ── TOTP 管理 ── + +export function totpStatus(admin: AdminRow | null) { + return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref }; +} + +export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: string; secret: string }> { + if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定"); + const secret = base32Encode(randomBytes(20)); + const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` }); + const old = admin.totp_pending_secret_ref; + await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute(); + if (old) await deps.secrets.remove(old).catch(() => {}); + const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`); + const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`; + return { uri, secret }; +} + +export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise { + if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "请先生成 TOTP 密钥"); + const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref); + const counter = verifyTotp(secret, code, null); + if (counter === null) throw new ApiException("unauthorized", "验证码错误"); + const old = admin.totp_secret_ref; + await deps.db + .updateTable("admin_user") + .set({ + totp_secret_ref: admin.totp_pending_secret_ref, + totp_pending_secret_ref: null, + totp_enabled_at: new Date(), + totp_last_counter: counter, + }) + .where("id", "=", admin.id) + .execute(); + if (old) await deps.secrets.remove(old).catch(() => {}); +} + +export async function totpDisable( + deps: AuthDeps, + admin: AdminRow, + input: { password: string; code: string }, +): Promise { + if (!admin.totp_enabled_at || !admin.totp_secret_ref) throw new ApiException("precondition_failed", "TOTP 未启用"); + if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) { + throw new ApiException("unauthorized", "密码错误"); + } + if (!(await consumeTotp(deps, admin, input.code))) throw new ApiException("unauthorized", "验证码错误"); + const ref = admin.totp_secret_ref; + await deps.db + .updateTable("admin_user") + .set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null }) + .where("id", "=", admin.id) + .execute(); + await deps.secrets.remove(ref).catch(() => {}); +} + +export async function sessionInfo( + db: Kysely, + actor: Actor | undefined, + expiresAt: Date | null, +): Promise { + const admin = await loadAdmin(db); + return { + initialized: !!admin?.password_hash, + authenticated: !!actor, + actor: actor ?? null, + expiresAt: expiresAt?.toISOString() ?? null, + totpEnabled: !!admin?.totp_enabled_at, + }; +} diff --git a/apps/server/src/modules/auth-settings/crypto.ts b/apps/server/src/modules/auth-settings/crypto.ts new file mode 100644 index 00000000..9ce3e44a --- /dev/null +++ b/apps/server/src/modules/auth-settings/crypto.ts @@ -0,0 +1,112 @@ +import { createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto"; +import { argon2id, argon2Verify } from "hash-wasm"; + +// 认证用密码学原语。argon2id 用 hash-wasm(WASM,纯 JS 装载): +// @node-rs/argon2 / @denosaurs/argon2 都是原生库,需要 --allow-ffi,而编译后的 umt-server 只授予 +// net/env/sys/read 权限;hash-wasm 输出标准 PHC 串,与 Bun.password / @node-rs/argon2 互认。 + +/** OWASP 推荐参数(与 uhd_doc 相同):m=19 MiB, t=2, p=1 */ +export const ARGON2_PARAMS = { memorySize: 19_456, iterations: 2, parallelism: 1, hashLength: 32 } as const; + +export async function hashPassword(password: string): Promise { + return await argon2id({ password, salt: randomBytes(16), ...ARGON2_PARAMS, outputType: "encoded" }); +} + +export async function verifyPassword(password: string, digest: string): Promise { + try { + return await argon2Verify({ password, hash: digest }); + } catch { + return false; + } +} + +/** 预先算好的假哈希:账号不存在时也跑一次校验,避免用时差探测账号 */ +let dummyHash: Promise | null = null; +export function dummyPasswordHash(): Promise { + dummyHash ??= hashPassword(randomToken(16)); + return dummyHash; +} + +export const sha256Hex = (s: string) => createHash("sha256").update(s, "utf8").digest("hex"); + +/** 高熵随机 token(base64url) */ +export const randomToken = (bytes = 32) => randomBytes(bytes).toString("base64url"); + +export function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + +/** HKDF-SHA256 从主密钥派生 32 字节子密钥 */ +export function deriveKey(masterKey: string, salt: Uint8Array, info: string): Buffer { + return Buffer.from(hkdfSync("sha256", Buffer.from(masterKey, "utf8"), salt, Buffer.from(info, "utf8"), 32)); +} + +// ── TOTP(RFC 6238:SHA1 / 6 位 / 30 秒),纯 node:crypto ── + +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Encode(buf: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ""; + for (const b of buf) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + out += B32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += B32[(value << (5 - bits)) & 31]; + return out; +} + +export function base32Decode(s: string): Buffer { + const clean = s.replace(/=+$/, "").replace(/\s+/g, "").toUpperCase(); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + const idx = B32.indexOf(ch); + if (idx < 0) throw new Error("invalid base32"); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(out); +} + +export const TOTP_STEP_SEC = 30; + +export function totpAt(secretB32: string, counter: number): string { + const msg = Buffer.alloc(8); + msg.writeBigUInt64BE(BigInt(counter)); + const h = createHmac("sha1", base32Decode(secretB32)).update(msg).digest(); + const off = h[h.length - 1]! & 0xf; + const code = ((h.readUInt32BE(off) & 0x7fffffff) % 1_000_000).toString(); + return code.padStart(6, "0"); +} + +export const totpCounter = (nowMs = Date.now()) => Math.floor(nowMs / 1000 / TOTP_STEP_SEC); + +/** + * 校验验证码(容忍前后各 1 个步长);返回命中的计数器,调用方需持久化并拒绝 ≤ 已用计数器(防重放)。 + */ +export function verifyTotp( + secretB32: string, + code: string, + lastCounter: number | null, + nowMs = Date.now(), +): number | null { + const now = totpCounter(nowMs); + for (const c of [now - 1, now, now + 1]) { + if (lastCounter !== null && c <= lastCounter) continue; + if (safeEqual(totpAt(secretB32, c), code)) return c; + } + return null; +} diff --git a/apps/server/src/modules/auth-settings/endpoint.ts b/apps/server/src/modules/auth-settings/endpoint.ts new file mode 100644 index 00000000..68423cad --- /dev/null +++ b/apps/server/src/modules/auth-settings/endpoint.ts @@ -0,0 +1,225 @@ +import { createRoute, type RouteConfig } from "@hono/zod-openapi"; +import { + type Actor, + type ApiScope, + type AuditAction, + apiResponse, + CursorQuery, + cursorPage, + type EndpointDef, + IdParam, +} from "@umt/contracts"; +import { type DB, type Kysely, sql } from "@umt/db"; +import type { Context, MiddlewareHandler } from "hono"; +import type { Logger } from "pino"; +import type { z } from "zod"; +import { type AuthEnv, requireScope } from "../../http/auth.ts"; +import { ApiException } from "../../http/errors.ts"; +import type { ModuleApp } from "../types.ts"; + +// 本模块路由的薄封装:用 contracts 的 EndpointDef(Api.xxx 或本地同形定义)注册 OpenAPI 路由, +// 自动挂 requireScope、校验入参,并按响应 schema parse 出参(剥掉多余列,防止 key_hash 之类泄出)。 + +export type Def = Omit & { scope: ApiScope; tags?: string[] }; +type Infer = T extends z.ZodType ? z.infer : Record; + +export interface HandlerInput { + params: Infer; + query: Infer; + body: Infer; + c: Context; + actor: Actor; +} + +export function endpoint( + app: ModuleApp, + def: D, + /** 返回值按 def.response parse(剥掉多余列;不符合合同即 500) */ + handler: (input: HandlerInput) => Promise, + extra: { + middleware?: MiddlewareHandler[]; + status?: 200 | 201; + /** 免鉴权端点(必须同时列在 http/auth.ts 的 PUBLIC_AUTH_PATHS),不挂 requireScope */ + public?: boolean; + } = {}, +) { + const request: RouteConfig["request"] = {}; + if (def.params) request.params = def.params as never; + if (def.query) request.query = def.query as never; + if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true }; + const status = extra.status ?? 200; + const route = createRoute({ + method: def.method, + path: def.path, + summary: def.summary, + tags: def.tags ?? ["auth-settings"], + middleware: [...(extra.public ? [] : [requireScope(def.scope)]), ...(extra.middleware ?? [])] as never, + request, + responses: { + [status]: { + description: def.summary, + content: { "application/json": { schema: apiResponse(def.response) } }, + }, + }, + }); + // biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验 + app.openapi(route, (async (c: any) => { + const input = { + params: def.params ? c.req.valid("param") : {}, + query: def.query ? c.req.valid("query") : {}, + body: def.body ? c.req.valid("json") : {}, + c, + actor: c.get("actor"), + } as HandlerInput; + const data = def.response.parse(await handler(input)); + return c.json({ ok: true, data }, status); + // biome-ignore lint/suspicious/noExplicitAny: 同上 + }) as any); +} + +/** 标准资源端点定义(与 contracts Api 里 crud() 生成的路径/scope 一致,但保留具体 schema 类型) */ +export function crudDefs( + base: string, + name: string, + s: { row: R; create: C; update: U }, +) { + const tags = [base.slice(1)]; + const item = `${base}/{id}`; + return { + list: { + method: "get" as const, + path: base, + scope: "read" as const, + summary: `${name}列表`, + query: CursorQuery, + response: cursorPage(s.row), + tags, + }, + get: { + method: "get" as const, + path: item, + scope: "read" as const, + summary: `${name}详情`, + params: IdParam, + response: s.row, + tags, + }, + create: { + method: "post" as const, + path: base, + scope: "admin" as const, + summary: `新建${name}`, + body: s.create, + response: s.row, + tags, + }, + update: { + method: "patch" as const, + path: item, + scope: "admin" as const, + summary: `修改${name}`, + params: IdParam, + body: s.update, + response: s.row, + tags, + }, + }; +} + +// ── 行映射 ── + +const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase()); + +/** DB 行 → API 对象:snake_case → camelCase,Date → ISO 串 */ +export function toApi>(row: object): T { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) out[camel(k)] = v instanceof Date ? v.toISOString() : v; + return out as T; +} + +const snake = (s: string) => s.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); + +/** API 输入 → DB 列(跳过 undefined;对象/数组值 JSON 化以写 jsonb) */ +export function toDb(input: Record, jsonCols: readonly string[] = []): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (v === undefined) continue; + const col = snake(k); + out[col] = jsonCols.includes(col) && v !== null ? JSON.stringify(v) : v; + } + return out; +} + +// ── 游标(<时间列> desc, id desc;时间按微秒编码,避免 JS Date 毫秒精度截断导致跳行)── + +export interface Cursor { + us: number; + id: string; +} +export const encodeCursor = (c: Cursor) => Buffer.from(JSON.stringify(c), "utf8").toString("base64url"); +export function decodeCursor(s: string | undefined): Cursor | null { + if (!s) return null; + try { + const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as Cursor; + if (!Number.isSafeInteger(v.us) || typeof v.id !== "string" || !v.id) throw new Error(); + return v; + } catch { + throw new ApiException("bad_request", "cursor 无效"); + } +} + +/** select 里加上 `cursorCol("created_at")`,得到 cursor_us 列 */ +export const cursorCol = (col: string) => + sql`(extract(epoch from ${sql.ref(col)}) * 1000000)::bigint`.as("cursor_us"); + +/** where 条件:(, id) < 游标位置;idType 为 id 列的类型(本地表 uuid,引擎表 text) */ +export const afterCursor = (col: string, cur: Cursor, idCol = "id", idType: "uuid" | "text" = "uuid") => + sql`(${sql.ref(col)}, ${sql.ref(idCol)}) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id}::${sql.raw(idType)})`; + +/** 取 limit+1 行判断是否还有下一页;去掉 cursor_us 辅助列 */ +export function pageOf(rows: R[], limit: number) { + const items = rows.slice(0, limit); + const last = items[items.length - 1]; + const nextCursor = rows.length > limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null; + return { + items: items.map(({ cursor_us: _c, ...rest }) => rest as Omit), + nextCursor, + }; +} + +// ── 审计 ── + +/** contracts ActorType:控制台管理员记为 user */ +export const auditActorType = (a: Actor | undefined) => (a?.type === "api_key" ? "api_key" : a ? "user" : "system"); + +/** 写审计;失败只记日志,绝不影响主流程 */ +export async function audit( + deps: { db: Kysely; logger: Logger }, + actor: Actor | undefined, + action: AuditAction, + target: { type?: string; id?: string | null } = {}, + payload: Record = {}, +): Promise { + try { + await deps.db + .insertInto("audit_event") + .values({ + actor_type: auditActorType(actor), + actor_id: actor?.id ?? null, + action, + target_type: target.type ?? null, + target_id: target.id ?? null, + payload: JSON.stringify(payload), + }) + .execute(); + } catch (err) { + deps.logger.warn({ err, action }, "审计写入失败"); + } +} + +/** PG 唯一/外键冲突 → 409 */ +export function pgConflict(err: unknown, message: string): never { + const code = (err as { code?: string })?.code; + if (code === "23505" || code === "23503") throw new ApiException("conflict", message); + throw err; +} diff --git a/apps/server/src/modules/auth-settings/engine-routes.ts b/apps/server/src/modules/auth-settings/engine-routes.ts new file mode 100644 index 00000000..ce1cb364 --- /dev/null +++ b/apps/server/src/modules/auth-settings/engine-routes.ts @@ -0,0 +1,260 @@ +import { + cursorPage, + EngineCancelRequest, + EngineCancelResult, + EngineRun, + EngineRunDetail, + EngineRunQuery, + EngineStats, +} from "@umt/contracts"; +import { type DB, type Kysely, sql } from "@umt/db"; +import { z } from "zod"; +import { ApiException } from "../../http/errors.ts"; +import { redact } from "../../logger.ts"; +import type { ModuleApp, ServerContext } from "../types.ts"; +import { audit, type Cursor, type Def, decodeCursor, encodeCursor, endpoint } from "./endpoint.ts"; + +// 引擎面板只读 API(仅 admin):直查 openworkflow schema(docs/design/openworkflow-dashboard-reference.md)。 +// 不返回 config/context;input/output/error/signal data 过敏感字段脱敏;取消走 TaskEngine.cancel(先写业务侧取消标志)。 + +const tags = ["engine"]; +const RunIdParam = z.object({ id: z.string().min(1).max(200) }); +const D = { + runs: { + method: "get", + path: "/engine/runs", + scope: "admin", + summary: "引擎 runs(状态/名称/时间筛选,新→旧)", + query: EngineRunQuery, + response: cursorPage(EngineRun), + tags, + }, + run: { + method: "get", + path: "/engine/runs/{id}", + scope: "admin", + summary: "run 详情(step attempts + signals)", + params: RunIdParam, + response: EngineRunDetail, + tags, + }, + stats: { + method: "get", + path: "/engine/stats", + scope: "admin", + summary: "按状态计数 / 最老待处理 / 活跃 worker 快照", + response: EngineStats, + tags, + }, + cancel: { + method: "post", + path: "/engine/runs/{id}/cancel", + scope: "admin", + summary: "取消 run", + params: RunIdParam, + body: EngineCancelRequest, + response: EngineCancelResult, + tags, + }, +} as const satisfies Record; + +const NAMESPACE = "default"; +const iso = (d: Date | null) => (d ? d.toISOString() : null); + +type RunRow = { + id: string; + workflow_name: string; + version: string | null; + status: string; + attempts: number; + worker_id: string | null; + parent_step_attempt_id: string | null; + available_at: Date | null; + deadline_at: Date | null; + started_at: Date | null; + finished_at: Date | null; + created_at: Date; + updated_at: Date; + cursor_us: number; +}; + +const runView = (r: RunRow) => ({ + id: r.id, + workflowName: r.workflow_name, + version: r.version, + status: r.status, + attempts: r.attempts, + workerId: r.worker_id, + parentStepAttemptId: r.parent_step_attempt_id, + availableAt: iso(r.available_at), + deadlineAt: iso(r.deadline_at), + startedAt: iso(r.started_at), + finishedAt: iso(r.finished_at), + createdAt: r.created_at.toISOString(), + updatedAt: r.updated_at.toISOString(), +}); + +const RUN_COLS = sql.raw(`id, workflow_name, version, status, attempts, worker_id, parent_step_attempt_id, + available_at, deadline_at, started_at, finished_at, created_at, updated_at, + (extract(epoch from created_at) * 1000000)::bigint as cursor_us`); + +export interface EngineQueries { + listRuns( + q: z.infer, + ): Promise<{ items: ReturnType[]; nextCursor: string | null }>; + getRun(id: string): Promise | null>; + stats(): Promise>; +} + +/** 直查引擎表;schema 默认 openworkflow(测试可覆盖) */ +export function engineQueries(db: Kysely, schema = "openworkflow"): EngineQueries { + if (!/^[a-z_][a-z0-9_]*$/.test(schema)) throw new Error(`非法 schema 名 ${schema}`); + const t = (name: string) => sql.raw(`"${schema}".${name}`); + return { + async listRuns(q) { + const cur: Cursor | null = decodeCursor(q.cursor); + const conds = [sql`namespace_id = ${NAMESPACE}`]; + if (q.status) conds.push(sql`status = ${q.status}`); + if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`); + if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`); + if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`); + if (cur) { + conds.push(sql`(created_at, id) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id})`); + } + const rows = await sql`select ${RUN_COLS} from ${t("workflow_runs")} + where ${sql.join(conds, sql` and `)} + order by created_at desc, id desc limit ${q.limit + 1}`.execute(db); + const items = rows.rows.slice(0, q.limit); + const last = items[items.length - 1]; + return { + items: items.map(runView), + nextCursor: + rows.rows.length > q.limit && last ? encodeCursor({ us: Number(last.cursor_us), id: last.id }) : null, + }; + }, + + async getRun(id) { + const r = await sql` + select ${RUN_COLS}, input, output, error from ${t("workflow_runs")} + where namespace_id = ${NAMESPACE} and id = ${id}`.execute(db); + const run = r.rows[0]; + if (!run) return null; + const steps = await sql<{ + id: string; + step_name: string; + kind: string; + status: string; + output: unknown; + error: unknown; + wait_signal: string | null; + timeout_at: string | null; + child_workflow_run_id: string | null; + started_at: Date | null; + finished_at: Date | null; + created_at: Date; + }>`select id, step_name, kind, status, output, error, + case when kind = 'signal-wait' then context->>'signal' end as wait_signal, + context->>'timeoutAt' as timeout_at, + child_workflow_run_id, started_at, finished_at, created_at + from ${t("step_attempts")} where namespace_id = ${NAMESPACE} and workflow_run_id = ${id} + order by created_at, id limit 1000`.execute(db); + const signals = await sql<{ + id: string; + signal: string; + data: unknown; + step_attempt_id: string; + created_at: Date; + }>` + select id, signal, data, step_attempt_id, created_at from ${t("workflow_signals")} + where namespace_id = ${NAMESPACE} and workflow_run_id = ${id} order by created_at, id limit 1000`.execute(db); + return { + ...runView(run), + input: redact(run.input), + output: redact(run.output), + error: redact(run.error), + stepAttempts: steps.rows.map((s) => ({ + id: s.id, + stepName: s.step_name, + kind: s.kind, + status: s.status, + output: redact(s.output), + error: redact(s.error), + waitSignal: s.wait_signal, + timeoutAt: s.timeout_at, + childWorkflowRunId: s.child_workflow_run_id, + startedAt: iso(s.started_at), + finishedAt: iso(s.finished_at), + createdAt: s.created_at.toISOString(), + })), + signals: signals.rows.map((s) => ({ + id: s.id, + signal: s.signal, + data: redact(s.data), + stepAttemptId: s.step_attempt_id, + createdAt: s.created_at.toISOString(), + })), + }; + }, + + async stats() { + const [counts, oldest, workers] = await Promise.all([ + sql<{ status: string; n: number }>`select status, count(*)::int as n from ${t("workflow_runs")} + where namespace_id = ${NAMESPACE} group by status`.execute(db), + sql<{ id: string; workflow_name: string; since: Date }>` + select id, workflow_name, coalesce(available_at, created_at) as since from ${t("workflow_runs")} + where namespace_id = ${NAMESPACE} and status = 'pending' + order by coalesce(available_at, created_at), id limit 1`.execute(db), + sql<{ worker_id: string; running: number; since: Date | null }>` + select worker_id, count(*)::int as running, min(started_at) as since from ${t("workflow_runs")} + where namespace_id = ${NAMESPACE} and status = 'running' and worker_id is not null + group by worker_id order by worker_id`.execute(db), + ]); + const byStatus: Record = { pending: 0, running: 0, completed: 0, failed: 0, canceled: 0 }; + for (const r of counts.rows) byStatus[r.status] = (byStatus[r.status] ?? 0) + r.n; + const o = oldest.rows[0]; + return { + byStatus, + oldestPending: o + ? { + id: o.id, + workflowName: o.workflow_name, + since: o.since.toISOString(), + ageSec: Math.max(0, (Date.now() - o.since.getTime()) / 1000), + } + : null, + activeWorkers: workers.rows.map((w) => ({ workerId: w.worker_id, running: w.running, since: iso(w.since) })), + }; + }, + }; +} + +export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: { schema?: string } = {}) { + let q: EngineQueries | null = null; + const queries = () => { + q ??= engineQueries(ctx.db, opts.schema); + return q; + }; + endpoint(app, D.runs, async ({ query }) => await queries().listRuns(query)); + endpoint(app, D.run, async ({ params }) => { + const r = await queries().getRun(params.id); + if (!r) throw new ApiException("not_found", `run ${params.id} 不存在`); + return r; + }); + endpoint(app, D.stats, async () => await queries().stats()); + endpoint(app, D.cancel, async ({ params, body, actor }) => { + const exists = await queries().getRun(params.id); + if (!exists) throw new ApiException("not_found", `run ${params.id} 不存在`); + const canceled = await ctx.engine.cancel(params.id, body.reason ?? `canceled by ${actor.type}:${actor.name}`); + await audit( + ctx, + actor, + "engine.run_cancel", + { type: "engine_run", id: params.id }, + { + workflowName: exists.workflowName, + canceled, + }, + ); + return { canceled }; + }); +} diff --git a/apps/server/src/modules/auth-settings/index.ts b/apps/server/src/modules/auth-settings/index.ts index 01abbeaf..235bff0b 100644 --- a/apps/server/src/modules/auth-settings/index.ts +++ b/apps/server/src/modules/auth-settings/index.ts @@ -1,12 +1,59 @@ -import type { ServerModule } from "../types.ts"; +import { sql } from "@umt/db"; +import type { ServerContext, ServerModule } from "../types.ts"; +import { registerAuthRoutes } from "./auth-routes.ts"; +import { ensureSetupToken } from "./auth-service.ts"; +import { registerEngineRoutes } from "./engine-routes.ts"; +import { registerInstanceRoutes } from "./instances-routes.ts"; +import { registerKeyRoutes } from "./keys-routes.ts"; +import { registerSettingsRoutes } from "./settings-routes.ts"; /** * W2-A 认证/设置/实例/引擎面板:管理员登录门禁、settings、api_key、secret 加密服务、 - * downloader/media_server 实例与 library_root/path_mapping、审计查询、SSE 总线改 PG 通知、引擎面板只读 API、自动化总开关。 - * secrets / settings 的实现放在 ./provided.ts(context 从这里取),SSE 总线在 http/sse.ts,鉴权在 http/auth.ts。 + * downloader/media_server 实例与 library_root/path_mapping、站点凭证录入、审计查询、引擎面板只读 API。 + * secrets / settings 的实现在 ./provided.ts(context 从这里取),SSE 总线在 http/sse*.ts,鉴权中间件在 http/auth.ts。 + * 用法见 docs/runbook/auth.md。 */ -export const authSettingsModule: ServerModule = { - name: "auth-settings", - registerRoutes() {}, - registerTasks() {}, -}; +export interface AuthSettingsModuleOptions { + /** 引擎表所在 schema,默认 openworkflow */ + engineSchema?: string; + /** api 角色启动时若未初始化管理员则生成并打印 setup token(默认 true;测试关掉自己调 ensureSetupToken) */ + setupTokenOnStart?: boolean; + /** 过期会话/登录尝试清理间隔,默认 1 小时 */ + cleanupIntervalMs?: number; +} + +/** 清理:登录尝试保留 7 天;已过期/已吊销会话保留 30 天(审计追溯) */ +export async function cleanupAuthTables(ctx: Pick): Promise { + await sql`delete from login_attempt where at < now() - interval '7 days'`.execute(ctx.db); + await sql`delete from admin_session + where coalesce(revoked_at, expires_at) < now() - interval '30 days'`.execute(ctx.db); +} + +export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}): ServerModule { + let timer: ReturnType | null = null; + return { + name: "auth-settings", + registerRoutes(app, ctx) { + registerAuthRoutes(app, ctx); + registerSettingsRoutes(app, ctx); + registerKeyRoutes(app, ctx); + registerInstanceRoutes(app, ctx); + registerEngineRoutes(app, ctx, opts.engineSchema ? { schema: opts.engineSchema } : {}); + if (opts.setupTokenOnStart !== false && ctx.db) { + ensureSetupToken(ctx).catch((err) => ctx.logger.error({ err }, "生成 setup token 失败")); + } + }, + registerTasks() {}, + start(ctx) { + const run = () => cleanupAuthTables(ctx).catch((err) => ctx.logger.warn({ err }, "认证表清理失败")); + timer = setInterval(run, opts.cleanupIntervalMs ?? 3600_000); + void run(); + }, + stop() { + if (timer) clearInterval(timer); + timer = null; + }, + }; +} + +export const authSettingsModule: ServerModule = createAuthSettingsModule(); diff --git a/apps/server/src/modules/auth-settings/instances-routes.ts b/apps/server/src/modules/auth-settings/instances-routes.ts new file mode 100644 index 00000000..24bd4d12 --- /dev/null +++ b/apps/server/src/modules/auth-settings/instances-routes.ts @@ -0,0 +1,557 @@ +import { stat } from "node:fs/promises"; +import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters"; +import { + Api, + type AuditAction, + DownloaderInstance, + DownloaderInstanceCreate, + DownloaderInstanceUpdate, + IdParam, + InstanceTestResult, + LibraryRoot, + LibraryRootCreate, + LibraryRootUpdate, + MediaServerInstance, + MediaServerInstanceCreate, + MediaServerInstanceUpdate, + PathMapping, + PathMappingCreate, + PathMappingUpdate, + type SecretPurpose, + SiteCredentialsPut, + SiteCredentialsResult, +} from "@umt/contracts"; +import type { DB } from "@umt/db"; +import { z } from "zod"; +import { ApiException } from "../../http/errors.ts"; +import { redact, redactString } from "../../logger.ts"; +import type { ModuleApp, ServerContext } from "../types.ts"; +import { + afterCursor, + audit, + crudDefs, + cursorCol, + type Def, + decodeCursor, + endpoint, + pageOf, + pgConflict, + toApi, + toDb, +} from "./endpoint.ts"; + +// 实例与路径:library_root / downloader_instance / media_server_instance / path_mapping 的 CRUD + 连通性测试, +// 以及站点账号凭证录入 PUT /sites/{id}/credentials。凭证明文只在请求里出现,存为 secret 后只回 secretRef。 + +const OkResult = z.object({ ok: z.literal(true) }); +const del = (path: string, summary: string, tag: string) => ({ + method: "delete" as const, + path, + scope: "admin" as const, + summary, + params: IdParam, + response: OkResult, + tags: [tag], +}); +const test = (path: string, summary: string, tag: string) => ({ + method: "post" as const, + path, + scope: "admin" as const, + summary, + params: IdParam, + response: InstanceTestResult, + tags: [tag], +}); + +type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping"; +const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] as const; + +const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500); + +const LR = crudDefs("/library-roots", "媒体库根", { + row: LibraryRoot, + create: LibraryRootCreate, + update: LibraryRootUpdate, +}); +const DL = crudDefs("/downloader-instances", "下载器实例", { + row: DownloaderInstance, + create: DownloaderInstanceCreate, + update: DownloaderInstanceUpdate, +}); +const MS = crudDefs("/media-server-instances", "媒体服务器实例", { + row: MediaServerInstance, + create: MediaServerInstanceCreate, + update: MediaServerInstanceUpdate, +}); +const PM = crudDefs("/path-mappings", "路径映射", { + row: PathMapping, + create: PathMappingCreate, + update: PathMappingUpdate, +}); + +export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) { + const db = () => ctx.db; + + /** 通用列表 / 详情 / 删除 */ + const list = (table: Table, tag: string, def: Def) => + endpoint(app, { ...def, tags: [tag] }, async ({ query }) => { + const q0 = query as { cursor?: string; limit: number }; + const cur = decodeCursor(q0.cursor); + // biome-ignore lint/suspicious/noExplicitAny: 四张表共用的通用查询 + let q = (db() as any).selectFrom(table).selectAll().select(cursorCol("created_at")); + if (cur) q = q.where(afterCursor("created_at", cur)); + const rows = await q + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(q0.limit + 1) + .execute(); + const page = pageOf(rows as { cursor_us: number; id: string }[], q0.limit); + return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor }; + }); + const getRow = async (table: Table, id: string) => { + // biome-ignore lint/suspicious/noExplicitAny: 同上 + const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst(); + if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`); + return r as Record & { id: string; secret_ref?: string | null }; + }; + const get = (table: Table, tag: string, def: Def) => + endpoint(app, { ...def, tags: [tag] }, async ({ params }) => + toApi(await getRow(table, (params as { id: string }).id)), + ); + const remove = (table: Table, tag: string, path: string, action: AuditAction) => + endpoint(app, del(path, `删除 ${table}`, tag), async ({ params, actor }) => { + const id = (params as { id: string }).id; + const row = await getRow(table, id); + // biome-ignore lint/suspicious/noExplicitAny: 同上 + await (db() as any) + .deleteFrom(table) + .where("id", "=", id) + .execute() + .catch((e: unknown) => pgConflict(e, `${table} 仍被引用,不能删除`)); + if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {}); + await audit(ctx, actor, action, { type: table, id }); + return { ok: true as const }; + }); + const insert = async (table: Table, values: Record) => + // biome-ignore lint/suspicious/noExplicitAny: 同上 + (await (db() as any) + .insertInto(table) + .values(values) + .returningAll() + .executeTakeFirstOrThrow() + .catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record & { + id: string; + }; + const update = async (table: Table, id: string, values: Record) => { + if (!Object.keys(values).length) return await getRow(table, id); + // biome-ignore lint/suspicious/noExplicitAny: 同上 + const r = await (db() as any) + .updateTable(table) + .set(values) + .where("id", "=", id) + .returningAll() + .executeTakeFirst() + .catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`)); + if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`); + return r as Record & { id: string }; + }; + + /** 凭证:有旧 ref 就原地替换,否则新建 */ + const putCredential = async (oldRef: string | null | undefined, purpose: SecretPurpose, plaintext: string) => { + if (oldRef) { + await ctx.secrets.replace(oldRef, plaintext); + return oldRef; + } + return await ctx.secrets.create({ purpose, plaintext }); + }; + + // ── library_root ── + const sentinel = async (localPath: string, file: string | null | undefined) => { + const name = file || DEFAULT_ROOT_SENTINEL; + try { + const st = await stat(localPath); + if (!st.isDirectory()) throw new Error("不是目录"); + await writeRootSentinel(localPath, name); + } catch (e) { + throw new ApiException( + "precondition_failed", + `库根不可写(需要目录存在且可写入哨兵文件 ${name}):${errMessage(e)}`, + ); + } + return name; + }; + list("library_root", "library-roots", LR.list); + get("library_root", "library-roots", LR.get); + endpoint(app, LR.create, async ({ body, actor }) => { + const file = await sentinel(body.localPath, body.sentinelFile); + const row = await insert("library_root", { + ...toDb(body, JSON_COLS), + sentinel_file: file, + health_status: "healthy", + last_health_at: new Date(), + }); + await audit(ctx, actor, "library_root.create", { type: "library_root", id: row.id }, { localPath: body.localPath }); + return toApi(row); + }); + endpoint(app, LR.update, async ({ params, body, actor }) => { + const values = toDb(body, JSON_COLS); + if (body.localPath !== undefined || body.sentinelFile !== undefined) { + const cur = await getRow("library_root", params.id); + values.sentinel_file = await sentinel( + body.localPath ?? String(cur.local_path), + body.sentinelFile ?? (cur.sentinel_file as string | null), + ); + } + const row = await update("library_root", params.id, values); + await audit(ctx, actor, "library_root.update", { type: "library_root", id: row.id }, { fields: Object.keys(body) }); + return toApi(row); + }); + remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete"); + endpoint( + app, + test("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)", "library-roots"), + async ({ params, actor }) => { + const row = await getRow("library_root", params.id); + const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL); + const ok = h.kind === "online"; + await update("library_root", row.id, { + health_status: ok ? "healthy" : "offline", + last_health_at: new Date(), + health_detail: JSON.stringify(h), + }); + await audit(ctx, actor, "library_root.test", { type: "library_root", id: row.id }, { ok }); + return { + ok, + version: null, + message: ok ? null : `offline: ${"reason" in h ? h.reason : "unknown"}`, + healthStatus: ok ? "healthy" : "offline", + }; + }, + ); + + // ── downloader_instance ── + list("downloader_instance", "downloader-instances", DL.list); + get("downloader_instance", "downloader-instances", DL.get); + endpoint(app, DL.create, async ({ body, actor }) => { + const { credential, ...rest } = body; + const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null; + try { + const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref }); + await audit( + ctx, + actor, + "downloader_instance.create", + { type: "downloader_instance", id: row.id }, + { name: row.name }, + ); + return toApi(row); + } catch (e) { + if (ref) await ctx.secrets.remove(ref).catch(() => {}); + throw e; + } + }); + endpoint(app, DL.update, async ({ params, body, actor }) => { + const { credential, ...rest } = body; + const values = toDb(rest, JSON_COLS); + if (credential) { + const cur = await getRow("downloader_instance", params.id); + values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential); + } + const row = await update("downloader_instance", params.id, values); + await audit( + ctx, + actor, + "downloader_instance.update", + { type: "downloader_instance", id: row.id }, + { + fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)), + }, + ); + return toApi(row); + }); + remove("downloader_instance", "downloader-instances", "/downloader-instances/{id}", "downloader_instance.delete"); + endpoint( + app, + { + ...test("/downloader-instances/{id}/test", "测试下载器连接", "downloader-instances"), + response: InstanceTestResult, + }, + async ({ params, actor }) => { + const row = await getRow("downloader_instance", params.id); + const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined; + const username = (row.username as string | null) ?? undefined; + // 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key + const client = new QbClient({ + instanceId: row.id, + baseUrl: String(row.base_url), + ...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}), + timeoutMs: 10_000, + retries: 0, + }); + let result: z.infer; + try { + const v = await client.version(); + result = { ok: true, version: v.app, message: null, healthStatus: "healthy" }; + await update("downloader_instance", row.id, { + health_status: "healthy", + last_seen_at: new Date(), + version_info: JSON.stringify({ app: v.app, webApi: v.webApi }), + }); + } catch (e) { + result = { ok: false, version: null, message: errMessage(e), healthStatus: "offline" }; + await update("downloader_instance", row.id, { health_status: "offline" }); + } + await audit( + ctx, + actor, + "downloader_instance.test", + { type: "downloader_instance", id: row.id }, + { ok: result.ok }, + ); + return result; + }, + ); + + // ── media_server_instance ── + list("media_server_instance", "media-server-instances", MS.list); + get("media_server_instance", "media-server-instances", MS.get); + endpoint(app, MS.create, async ({ body, actor }) => { + const { credential, ...rest } = body; + const ref = credential + ? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential }) + : null; + try { + const row = await insert("media_server_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref }); + await audit( + ctx, + actor, + "media_server_instance.create", + { type: "media_server_instance", id: row.id }, + { name: row.name }, + ); + return toApi(row); + } catch (e) { + if (ref) await ctx.secrets.remove(ref).catch(() => {}); + throw e; + } + }); + endpoint(app, MS.update, async ({ params, body, actor }) => { + const { credential, ...rest } = body; + const values = toDb(rest, JSON_COLS); + if (credential) { + const cur = await getRow("media_server_instance", params.id); + values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential); + } + const row = await update("media_server_instance", params.id, values); + await audit( + ctx, + actor, + "media_server_instance.update", + { type: "media_server_instance", id: row.id }, + { + fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)), + }, + ); + return toApi(row); + }); + remove( + "media_server_instance", + "media-server-instances", + "/media-server-instances/{id}", + "media_server_instance.delete", + ); + endpoint( + app, + test("/media-server-instances/{id}/test", "测试媒体服务器连接", "media-server-instances"), + async ({ params, actor }) => { + const row = await getRow("media_server_instance", params.id); + let result: z.infer; + if (!row.secret_ref) { + result = { ok: false, version: null, message: "未配置 API Key", healthStatus: "unknown" }; + } else { + const client = new EmbyClient({ + instanceId: row.id, + baseUrl: String(row.base_url), + apiKey: await ctx.secrets.resolve(row.secret_ref), + flavor: row.kind === "jellyfin" ? "jellyfin" : "emby", + ...(row.user_id ? { userId: String(row.user_id) } : {}), + timeoutMs: 10_000, + retries: 0, + }); + const r = await client.ping(); + if (r.status === "ok") { + result = { + ok: true, + version: r.value.version ?? null, + message: r.value.serverName ?? null, + healthStatus: "healthy", + }; + await update("media_server_instance", row.id, { + health_status: "healthy", + last_seen_at: new Date(), + version_info: JSON.stringify(redact(r.value)), + }); + } else { + result = { + ok: false, + version: null, + message: `${r.reason}: ${redactString(r.message)}`, + healthStatus: "offline", + }; + await update("media_server_instance", row.id, { health_status: "offline" }); + } + } + await audit( + ctx, + actor, + "media_server_instance.test", + { type: "media_server_instance", id: row.id }, + { ok: result.ok }, + ); + return result; + }, + ); + + // ── path_mapping ── + list("path_mapping", "path-mappings", PM.list); + get("path_mapping", "path-mappings", PM.get); + endpoint(app, PM.create, async ({ body, actor }) => { + const row = await insert("path_mapping", toDb(body, JSON_COLS)); + await audit( + ctx, + actor, + "path_mapping.create", + { type: "path_mapping", id: row.id }, + { + remotePrefix: body.remotePrefix, + localPrefix: body.localPrefix, + }, + ); + return toApi(row); + }); + endpoint(app, PM.update, async ({ params, body, actor }) => { + const values = toDb(body, JSON_COLS); + if (body.localPrefix !== undefined || body.remotePrefix !== undefined) values.verify_status = "unverified"; + const row = await update("path_mapping", params.id, values); + await audit(ctx, actor, "path_mapping.update", { type: "path_mapping", id: row.id }, { fields: Object.keys(body) }); + return toApi(row); + }); + endpoint(app, { ...Api.pathMappings.remove, tags: ["path-mappings"] }, async ({ params, actor }) => { + const r = await db().deleteFrom("path_mapping").where("id", "=", params.id).returning("id").executeTakeFirst(); + if (!r) throw new ApiException("not_found", "路径映射不存在"); + await audit(ctx, actor, "path_mapping.delete", { type: "path_mapping", id: params.id }); + return { ok: true as const }; + }); + endpoint(app, { ...Api.pathMappings.verify, tags: ["path-mappings"] }, async ({ params, actor }) => { + const row = await getRow("path_mapping", params.id); + let detail: Record; + let ok = false; + try { + const st = await stat(String(row.local_prefix)); + ok = st.isDirectory(); + detail = ok ? { localPrefix: "directory" } : { error: "localPrefix 不是目录" }; + } catch (e) { + detail = { error: errMessage(e), code: (e as { code?: string })?.code ?? null }; + } + const updated = await update("path_mapping", row.id, { + verify_status: ok ? "ok" : "failed", + verified_at: new Date(), + verify_detail: JSON.stringify(detail), + }); + await audit(ctx, actor, "path_mapping.verify", { type: "path_mapping", id: row.id }, { ok }); + return toApi(updated); + }); + + // ── 站点账号凭证 ── + const PURPOSE: Record = { + cookie: "site_cookie", + passkey: "site_passkey", + api_key: "site_api_key", + password: "site_password", + }; + endpoint( + app, + { + method: "put", + path: "/sites/{id}/credentials", + scope: "admin", + summary: "录入站点账号凭证(cookie/passkey/apiKey/密码 → secret_ref,不回显)", + params: IdParam, + body: SiteCredentialsPut, + response: SiteCredentialsResult, + tags: ["sites"], + }, + async ({ params, body, actor }) => { + const site = await db().selectFrom("site").select("id").where("id", "=", params.id).executeTakeFirst(); + if (!site) throw new ApiException("not_found", "站点不存在"); + const newRef = await ctx.secrets.create({ purpose: PURPOSE[body.authKind], plaintext: body.credential }); + let oldRef: string | null = null; + let created = false; + let accountId: string; + try { + accountId = await db() + .transaction() + .execute(async (trx) => { + const cur = await trx + .selectFrom("site_account") + .select(["id", "secret_ref", "user_info"]) + .where("site_id", "=", params.id) + .where("label", "=", body.label) + .forUpdate() + .executeTakeFirst(); + const userInfo = { + ...((cur?.user_info as Record | undefined) ?? {}), + ...(body.username ? { username: body.username } : {}), + }; + const common: Partial> = { + auth_kind: body.authKind, + secret_ref: newRef, + status: "unknown", + user_info: JSON.stringify(userInfo), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + }; + if (cur) { + oldRef = cur.secret_ref; + await trx + .updateTable("site_account") + .set(common as never) + .where("id", "=", cur.id) + .execute(); + return cur.id; + } + created = true; + const r = await trx + .insertInto("site_account") + .values({ site_id: params.id, label: body.label, ...common } as never) + .returning("id") + .executeTakeFirstOrThrow(); + return r.id; + }); + } catch (e) { + await ctx.secrets.remove(newRef).catch(() => {}); + throw e; + } + if (oldRef) await ctx.secrets.remove(oldRef).catch(() => {}); + await audit( + ctx, + actor, + "site_account.credentials_set", + { type: "site_account", id: accountId }, + { + siteId: params.id, + label: body.label, + authKind: body.authKind, + created, + }, + ); + return { + siteAccountId: accountId, + siteId: params.id, + label: body.label, + authKind: body.authKind, + secretRef: newRef, + created, + }; + }, + ); +} diff --git a/apps/server/src/modules/auth-settings/keys-routes.ts b/apps/server/src/modules/auth-settings/keys-routes.ts new file mode 100644 index 00000000..94dd537e --- /dev/null +++ b/apps/server/src/modules/auth-settings/keys-routes.ts @@ -0,0 +1,205 @@ +import { Api, AuditEvent, AuditEventQuery, cursorPage, IdParam, Secret } from "@umt/contracts"; +import { sql } from "@umt/db"; +import { z } from "zod"; +import { hashApiKey } from "../../http/auth.ts"; +import { ApiException } from "../../http/errors.ts"; +import type { ModuleApp, ServerContext } from "../types.ts"; +import { randomToken } from "./crypto.ts"; +import { + afterCursor, + audit, + cursorCol, + type Def, + decodeCursor, + endpoint, + pageOf, + pgConflict, + toApi, +} from "./endpoint.ts"; +import { createSecretStore } from "./provided.ts"; + +// API Key 管理(创建时一次性返回明文,只存 sha256)、凭证元数据管理(/secrets,明文只写)、审计查询。 + +const OkResult = z.object({ ok: z.literal(true) }); +const D = { + revokeKey: { + method: "delete", + path: "/api-keys/{id}", + scope: "admin", + summary: "撤销 API Key", + params: IdParam, + response: OkResult, + tags: ["api-keys"], + }, + deleteSecret: { + method: "delete", + path: "/secrets/{id}", + scope: "admin", + summary: "删除凭证(被引用时 409)", + params: IdParam, + response: OkResult, + tags: ["secrets"], + }, + audit: { + method: "get", + path: "/audit-events", + scope: "admin", + summary: "审计事件(按 actor/action/target 过滤,新→旧)", + query: AuditEventQuery, + response: cursorPage(AuditEvent), + tags: ["audit"], + }, +} as const satisfies Record; + +const KEY_COLS = ["id", "name", "scopes", "revoked_at", "last_used_at", "created_at", "updated_at"] as const; +const SECRET_COLS = [ + "id", + "name", + "purpose", + "algorithm", + "kdf", + "kdf_params", + "key_version", + "rotated_at", + "created_at", + "updated_at", +] as const; + +export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) { + // ── API Key ── + endpoint(app, { ...Api.apiKeys.list, tags: ["api-keys"] }, async ({ query }) => { + const cur = decodeCursor(query.cursor); + let q = ctx.db.selectFrom("api_key").select([...KEY_COLS, cursorCol("created_at")]); + if (cur) q = q.where(afterCursor("created_at", cur)); + const rows = await q + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(query.limit + 1) + .execute(); + const page = pageOf(rows, query.limit); + return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor }; + }); + + endpoint(app, { ...Api.apiKeys.create, tags: ["api-keys"] }, async ({ body, actor }) => { + const plaintextKey = `umt_${randomToken(32)}`; + const row = await ctx.db + .insertInto("api_key") + .values({ name: body.name, key_hash: hashApiKey(plaintextKey), scopes: [...new Set(body.scopes)] }) + .returning(KEY_COLS) + .executeTakeFirstOrThrow(); + await audit(ctx, actor, "api_key.create", { type: "api_key", id: row.id }, { name: row.name, scopes: row.scopes }); + return { apiKey: toApi(row), plaintextKey }; + }); + + endpoint(app, { ...Api.apiKeys.update, tags: ["api-keys"] }, async ({ params, body, actor }) => { + const set: Record = {}; + if (body.name !== undefined) set.name = body.name; + if (body.scopes !== undefined) set.scopes = [...new Set(body.scopes)]; + if (body.revoked) set.revoked_at = sql`coalesce(revoked_at, now())`; + if (!Object.keys(set).length) throw new ApiException("bad_request", "没有要修改的字段"); + const row = await ctx.db + .updateTable("api_key") + .set(set) + .where("id", "=", params.id) + .returning(KEY_COLS) + .executeTakeFirst(); + if (!row) throw new ApiException("not_found", "API Key 不存在"); + await audit( + ctx, + actor, + body.revoked ? "api_key.revoke" : "api_key.update", + { type: "api_key", id: row.id }, + { + name: body.name, + scopes: body.scopes, + }, + ); + return toApi(row); + }); + + endpoint(app, D.revokeKey, async ({ params, actor }) => { + const r = await ctx.db + .updateTable("api_key") + .set({ revoked_at: sql`coalesce(revoked_at, now())` }) + .where("id", "=", params.id) + .returning("id") + .executeTakeFirst(); + if (!r) throw new ApiException("not_found", "API Key 不存在"); + await audit(ctx, actor, "api_key.revoke", { type: "api_key", id: params.id }); + return { ok: true as const }; + }); + + // ── 凭证元数据 ── + const secrets = createSecretStore({ db: ctx.db, masterKey: ctx.config?.SECRET_MASTER_KEY ?? "" }); + const secretView = (r: Record) => Secret.parse(toApi(r)); + + endpoint(app, { ...Api.secrets.list, tags: ["secrets"] }, async ({ query }) => { + const cur = decodeCursor(query.cursor); + let q = ctx.db.selectFrom("secret").select([...SECRET_COLS, cursorCol("created_at")]); + if (cur) q = q.where(afterCursor("created_at", cur)); + const rows = await q + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(query.limit + 1) + .execute(); + const page = pageOf(rows, query.limit); + return { items: page.items.map(secretView), nextCursor: page.nextCursor }; + }); + + endpoint(app, { ...Api.secrets.create, tags: ["secrets"] }, async ({ body, actor }) => { + const id = await secrets + .create({ purpose: body.purpose, plaintext: body.plaintext, label: body.name }) + .catch((e) => pgConflict(e, "凭证名称已存在")); + await audit(ctx, actor, "secret.create", { type: "secret", id }, { name: body.name, purpose: body.purpose }); + const row = await ctx.db.selectFrom("secret").select(SECRET_COLS).where("id", "=", id).executeTakeFirstOrThrow(); + return secretView(row); + }); + + endpoint(app, { ...Api.secrets.update, tags: ["secrets"] }, async ({ params, body, actor }) => { + const exists = await secrets.meta(params.id); + if (!exists) throw new ApiException("not_found", "凭证不存在"); + await secrets.rotate(params.id, body.plaintext); + await audit(ctx, actor, "secret.rotate", { type: "secret", id: params.id }); + const row = await ctx.db + .selectFrom("secret") + .select(SECRET_COLS) + .where("id", "=", params.id) + .executeTakeFirstOrThrow(); + return secretView(row); + }); + + endpoint(app, D.deleteSecret, async ({ params, actor }) => { + const r = await ctx.db + .deleteFrom("secret") + .where("id", "=", params.id) + .returning("id") + .executeTakeFirst() + .catch((e) => pgConflict(e, "凭证仍被引用(实例/站点账号/Feed/设置),请先解除引用")); + if (!r) throw new ApiException("not_found", "凭证不存在"); + await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id }); + return { ok: true as const }; + }); + + // ── 审计查询 ── + endpoint(app, D.audit, async ({ query }) => { + const cur = decodeCursor(query.cursor); + let q = ctx.db.selectFrom("audit_event").selectAll().select(cursorCol("at")); + if (query.actorType) q = q.where("actor_type", "=", query.actorType); + if (query.actorId) q = q.where("actor_id", "=", query.actorId); + if (query.action) { + q = query.action.endsWith(".*") + ? q.where("action", "like", `${query.action.slice(0, -2).replace(/[\\%_]/g, "\\$&")}.%`) + : q.where("action", "=", query.action); + } + if (query.targetType) q = q.where("target_type", "=", query.targetType); + if (query.targetId) q = q.where("target_id", "=", query.targetId); + if (cur) q = q.where(afterCursor("at", cur)); + const rows = await q + .orderBy("at", "desc") + .orderBy("id", "desc") + .limit(query.limit + 1) + .execute(); + const page = pageOf(rows, query.limit); + return { items: page.items.map((r) => toApi(r)), nextCursor: page.nextCursor }; + }); +} diff --git a/apps/server/src/modules/auth-settings/provided.ts b/apps/server/src/modules/auth-settings/provided.ts index b029e563..019e9c2b 100644 --- a/apps/server/src/modules/auth-settings/provided.ts +++ b/apps/server/src/modules/auth-settings/provided.ts @@ -1,21 +1,222 @@ -import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts"; +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; +import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts"; import type { DB, Kysely } from "@umt/db"; import type { Logger } from "pino"; +import type { EventBusPort } from "../../http/sse.ts"; import type { SecretStore, SettingsReader } from "../types.ts"; +import { deriveKey } from "./crypto.ts"; -// auth-settings 模块向 ServerContext 提供的基础服务。W2-A 在这里替换实现,其它模块只用 ctx 上的接口。 +// auth-settings 模块向 ServerContext 提供的基础服务:ctx.secrets / ctx.settings。 -/** 占位:W2-A 实现 AES-GCM 加密存取(secret 表,SECRET_MASTER_KEY) */ -export function createSecretStore(_deps: { db: Kysely; masterKey: string }): SecretStore { - const notYet = async (): Promise => { - throw new Error("SecretStore 尚未实现(W2-A)"); - }; - return { resolve: notYet, create: notYet, replace: notYet, remove: notYet }; +// ── 密钥服务 ── + +export const SECRET_ALGORITHM = "aes-256-gcm"; +export const SECRET_KDF = "hkdf-sha256"; +/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */ +export const SECRET_KEY_VERSION = 1; + +/** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */ +export class SecretError extends Error { + constructor( + readonly kind: "not_found" | "decrypt_failed", + readonly secretRef: string, + ) { + super( + kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`, + ); + this.name = "SecretError"; + } } -/** settings 表(key → jsonb)按分组合并缺省值;每次读库,不缓存(设置变更立即生效) */ -export function createSettingsReader(deps: { db: Kysely; logger: Logger }): SettingsReader { - const all = async (): Promise => { +export interface SecretMeta { + id: string; + name: string; + purpose: SecretPurpose; + algorithm: string; + kdf: string; + kdfParams: Record; + keyVersion: number; + rotatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +/** SecretStore(put/get/rotate/delete = create/resolve/replace/remove)+ 管理面需要的元数据操作 */ +export interface SecretService extends SecretStore { + /** 同 replace:换新明文(新盐、新 nonce),记 rotated_at */ + rotate(secretRef: string, plaintext: string): Promise; + meta(secretRef: string): Promise; +} + +interface Sealed { + ciphertext: Buffer; + nonce: Buffer; + authTag: Buffer; + kdfParams: { salt: string; info: string }; +} + +/** AAD 绑定行 id 与用途:密文被挪到别的行/改用途即解密失败 */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function assertRef(ref: string) { + if (!UUID_RE.test(ref)) throw new SecretError("not_found", String(ref).slice(0, 64)); +} + +const aad = (id: string, purpose: string) => Buffer.from(`umt-secret:${id}:${purpose}`, "utf8"); +const infoFor = (keyVersion: number) => `umt/secret/v${keyVersion}`; + +function seal(masterKey: string, id: string, purpose: string, plaintext: string): Sealed { + const salt = randomBytes(16); + const info = infoFor(SECRET_KEY_VERSION); + const key = deriveKey(masterKey, salt, info); + const nonce = randomBytes(12); + const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce); + cipher.setAAD(aad(id, purpose)); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { ciphertext, nonce, authTag: cipher.getAuthTag(), kdfParams: { salt: salt.toString("base64"), info } }; +} + +function open( + masterKey: string, + row: { id: string; purpose: string; ciphertext: Buffer; nonce: Buffer; auth_tag: Buffer | null; kdf_params: unknown }, +): string { + const params = (row.kdf_params ?? {}) as { salt?: string; info?: string }; + if (!params.salt || !params.info || !row.auth_tag) throw new SecretError("decrypt_failed", row.id); + try { + const key = deriveKey(masterKey, Buffer.from(params.salt, "base64"), params.info); + const decipher = createDecipheriv(SECRET_ALGORITHM, key, row.nonce); + decipher.setAAD(aad(row.id, row.purpose)); + decipher.setAuthTag(row.auth_tag); + return Buffer.concat([decipher.update(row.ciphertext), decipher.final()]).toString("utf8"); + } catch { + throw new SecretError("decrypt_failed", row.id); + } +} + +/** + * secret 表 + AES-256-GCM:每条 secret 用随机盐经 HKDF-SHA256 从 SECRET_MASTER_KEY 派生独立密钥, + * 随机 12 字节 nonce,AAD 绑定 (id, purpose)。明文只在返回值里出现,不缓存、不打日志。 + */ +export function createSecretStore(deps: { db: Kysely; masterKey: string }): SecretService { + const { db, masterKey } = deps; + const replace = async (secretRef: string, plaintext: string) => { + assertRef(secretRef); + const row = await db.selectFrom("secret").select(["id", "purpose"]).where("id", "=", secretRef).executeTakeFirst(); + if (!row) throw new SecretError("not_found", secretRef); + const s = seal(masterKey, row.id, row.purpose, plaintext); + await db + .updateTable("secret") + .set({ + ciphertext: s.ciphertext, + nonce: s.nonce, + auth_tag: s.authTag, + kdf_params: JSON.stringify(s.kdfParams), + key_version: SECRET_KEY_VERSION, + rotated_at: new Date(), + }) + .where("id", "=", secretRef) + .execute(); + }; + return { + async create({ purpose, plaintext, label }) { + const id = randomUUID(); + const s = seal(masterKey, id, purpose, plaintext); + await db + .insertInto("secret") + .values({ + id, + name: label ?? `${purpose}:${id}`, + purpose, + ciphertext: s.ciphertext, + nonce: s.nonce, + auth_tag: s.authTag, + algorithm: SECRET_ALGORITHM, + kdf: SECRET_KDF, + kdf_params: JSON.stringify(s.kdfParams), + key_version: SECRET_KEY_VERSION, + }) + .execute(); + return id; + }, + async resolve(secretRef) { + assertRef(secretRef); + const row = await db + .selectFrom("secret") + .select(["id", "purpose", "ciphertext", "nonce", "auth_tag", "kdf_params"]) + .where("id", "=", secretRef) + .executeTakeFirst(); + if (!row) throw new SecretError("not_found", secretRef); + return open(masterKey, row); + }, + replace, + rotate: replace, + async remove(secretRef) { + if (!UUID_RE.test(secretRef)) return; + await db.deleteFrom("secret").where("id", "=", secretRef).execute(); + }, + async meta(secretRef) { + if (!UUID_RE.test(secretRef)) return null; + const r = await db + .selectFrom("secret") + .select([ + "id", + "name", + "purpose", + "algorithm", + "kdf", + "kdf_params", + "key_version", + "rotated_at", + "created_at", + "updated_at", + ]) + .where("id", "=", secretRef) + .executeTakeFirst(); + if (!r) return null; + return { + id: r.id, + name: r.name, + purpose: r.purpose as SecretPurpose, + algorithm: r.algorithm, + kdf: r.kdf, + kdfParams: (r.kdf_params ?? {}) as Record, + keyVersion: r.key_version, + rotatedAt: r.rotated_at, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; + }, + }; +} + +// ── 设置读取 ── + +/** SSE / 进程间广播的设置变更事件 */ +export const SETTINGS_CHANGED_EVENT = "settings.changed"; + +export interface CachedSettingsReader extends SettingsReader { + /** 丢弃缓存(本进程写库后立即调用;其它进程经 settings.changed 事件失效) */ + invalidate(): void; + /** 订阅变更(失效后触发,参数为变更的分组);返回退订函数 */ + onChange(fn: (keys: SettingsKey[]) => void): () => void; + /** 绕过缓存读库 */ + fresh(): Promise; +} + +/** + * settings 表(key → jsonb)按分组合并缺省值后校验;表里值非法时回退缺省并记警告。 + * 结果缓存 ttlMs(默认 30s);收到 settings.changed 事件(经 PG 总线,跨进程)立即失效。 + */ +export function createSettingsReader(deps: { + db: Kysely; + logger: Logger; + events?: EventBusPort; + ttlMs?: number; +}): CachedSettingsReader { + const ttl = deps.ttlMs ?? 30_000; + let cache: { at: number; value: Promise } | null = null; + const listeners = new Set<(keys: SettingsKey[]) => void>(); + + const load = async (): Promise => { const rows = await deps.db.selectFrom("settings").select(["key", "value"]).execute(); const stored = new Map(rows.map((r) => [r.key, r.value])); const out: Record = {}; @@ -29,5 +230,45 @@ export function createSettingsReader(deps: { db: Kysely; logger: Logger }): } return out as GlobalSettings; }; - return { all, get: async (key) => (await all())[key] }; + + const all = (): Promise => { + const now = Date.now(); + if (!cache || now - cache.at > ttl) { + const value = load(); + cache = { at: now, value }; + // 读失败不缓存 + value.catch(() => { + if (cache?.value === value) cache = null; + }); + } + return cache.value; + }; + + const invalidate = () => { + cache = null; + }; + + deps.events?.subscribe((e) => { + if (e.type !== SETTINGS_CHANGED_EVENT) return; + invalidate(); + const keys = ((e.payload as { keys?: SettingsKey[] } | null)?.keys ?? []) as SettingsKey[]; + for (const l of listeners) { + try { + l(keys); + } catch (err) { + deps.logger.warn({ err }, "settings 变更监听器异常"); + } + } + }); + + return { + all, + get: async (key) => (await all())[key], + invalidate, + fresh: load, + onChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }; } diff --git a/apps/server/src/modules/auth-settings/settings-routes.ts b/apps/server/src/modules/auth-settings/settings-routes.ts new file mode 100644 index 00000000..3ebbca85 --- /dev/null +++ b/apps/server/src/modules/auth-settings/settings-routes.ts @@ -0,0 +1,73 @@ +import { Api, GlobalSettings, type GlobalSettingsUpdate, mergeSettings, type SettingsKey } from "@umt/contracts"; +import { ApiException } from "../../http/errors.ts"; +import type { ModuleApp, ServerContext } from "../types.ts"; +import { audit, endpoint } from "./endpoint.ts"; +import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts"; + +// GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert; +// Pi 上游密钥(pi.apiKey 只写)存为 secret,设置里只留 apiKeySecretRef;变更经 SSE 总线广播 settings.changed, +// 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。 + +const isCached = (s: unknown): s is CachedSettingsReader => + typeof (s as CachedSettingsReader)?.invalidate === "function"; + +export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) { + endpoint(app, { ...Api.settings.get, tags: ["settings"] }, async () => { + if (isCached(ctx.settings)) return await ctx.settings.fresh(); + return await ctx.settings.all(); + }); + + endpoint(app, { ...Api.settings.update, tags: ["settings"] }, async ({ body, actor }) => { + const patch = body as GlobalSettingsUpdate; + const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all(); + const merged = mergeSettings(current, patch); + const oldRef = current.pi.apiKeySecretRef; + let dropRef: string | null = null; + let newRef: string | null = null; + const apiKey = patch.pi?.apiKey; + if (apiKey === null) { + merged.pi.apiKeySecretRef = null; + dropRef = oldRef; + } + const parsed = GlobalSettings.safeParse(merged); + if (!parsed.success) { + const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); + throw new ApiException("validation_failed", msg); + } + const next = parsed.data; + if (typeof apiKey === "string") { + if (oldRef) { + await ctx.secrets.replace(oldRef, apiKey); + next.pi.apiKeySecretRef = oldRef; + } else { + newRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: apiKey }); + next.pi.apiKeySecretRef = newRef; + } + } + const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined); + try { + await ctx.db.transaction().execute(async (trx) => { + for (const key of keys) { + await trx + .insertInto("settings") + .values({ key, value: JSON.stringify(next[key]), updated_at: new Date() }) + .onConflict((oc) => + oc.column("key").doUpdateSet({ value: JSON.stringify(next[key]), updated_at: new Date() }), + ) + .execute(); + } + }); + } catch (err) { + if (newRef) await ctx.secrets.remove(newRef).catch(() => {}); + throw err; + } + if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {}); + if (isCached(ctx.settings)) ctx.settings.invalidate(); + void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys }); + // 审计里不留明文密钥 + const logged: Record = { ...patch }; + if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" }; + await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged }); + return next; + }); +} diff --git a/apps/server/test/auth-settings/admin-api.test.ts b/apps/server/test/auth-settings/admin-api.test.ts new file mode 100644 index 00000000..57d69d72 --- /dev/null +++ b/apps/server/test/auth-settings/admin-api.test.ts @@ -0,0 +1,374 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sql } from "@umt/db"; +import { createMockEmby, createMockQb, type MockEmby, type MockQb } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { DEV_KEY, json } from "../helpers.ts"; +import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts"; + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => { + let h: Harness; + let admin: ReturnType; + let qb: MockQb; + let emby: MockEmby; + const dir = mkdtempSync(join(tmpdir(), "umt-w2a-")); + + beforeAll(async () => { + h = await createHarness({ engine: true }); + admin = bearer(h.app, DEV_KEY); + qb = await createMockQb({ username: "qbuser", password: "qb-secret-pass" }); + emby = await createMockEmby({ apiKey: "emby-secret-key" }); + }, 60_000); + afterAll(async () => { + await qb?.close(); + await emby?.close(); + await h?.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + const noLeak = (secret: string) => { + expect(h.lines.join("\n")).not.toContain(secret); + }; + + describe("API Key", () => { + it("创建一次性返回明文;列表不含明文/哈希;scope 校验;read key 不能写;撤销后 401", async () => { + expect((await admin("POST", "/api-keys", { name: "bad", scopes: ["root"] })).status).toBe(422); + expect((await admin("POST", "/api-keys", { name: "empty", scopes: [] })).status).toBe(422); + const created = await json(await admin("POST", "/api-keys", { name: "reader", scopes: ["read", "read"] })); + const key: string = created.data.plaintextKey; + expect(key).toMatch(/^umt_/); + expect(created.data.apiKey.scopes).toEqual(["read"]); + const list = await json(await admin("GET", "/api-keys")); + const text = JSON.stringify(list); + expect(text).not.toContain(key); + expect(text).not.toContain("keyHash"); + expect(list.data.items[0].name).toBe("reader"); + + const reader = bearer(h.app, key); + expect((await reader("GET", "/settings")).status).toBe(200); + expect((await reader("PATCH", "/settings", { automation: { enabled: false } })).status).toBe(403); + expect((await reader("GET", "/api-keys")).status).toBe(403); + expect((await reader("GET", "/engine/runs")).status).toBe(403); + + const id = created.data.apiKey.id; + expect((await admin("PATCH", `/api-keys/${id}`, { scopes: ["read", "organize.read"] })).status).toBe(200); + expect((await admin("DELETE", `/api-keys/${id}`)).status).toBe(200); + expect((await reader("GET", "/settings")).status).toBe(401); + expect((await admin("DELETE", "/api-keys/00000000-0000-4000-8000-000000000000")).status).toBe(404); + noLeak(key); + }); + + it("游标分页", async () => { + for (const n of ["k1", "k2", "k3"]) await admin("POST", "/api-keys", { name: n, scopes: ["read"] }); + const p1 = await json(await admin("GET", "/api-keys?limit=2")); + expect(p1.data.items).toHaveLength(2); + const p2 = await json(await admin("GET", `/api-keys?limit=2&cursor=${p1.data.nextCursor}`)); + const ids = [...p1.data.items, ...p2.data.items].map((x: { id: string }) => x.id); + expect(new Set(ids).size).toBe(ids.length); + expect((await admin("GET", "/api-keys?cursor=garbage")).status).toBe(400); + }); + }); + + describe("设置", () => { + it("GET 缺省值;PATCH 自动化总开关与只读对账;ctx.settings 缓存失效并广播 settings.changed", async () => { + const g = await json(await admin("GET", "/settings")); + expect(g.data.automation).toEqual({ enabled: true, readOnlyReconcile: false }); + expect((await h.ctx.settings.get("automation")).enabled).toBe(true); + const events: string[] = []; + const off = h.bus.subscribe((e) => events.push(e.type)); + const r = await admin("PATCH", "/settings", { automation: { enabled: false, readOnlyReconcile: true } }); + expect(r.status).toBe(200); + off(); + expect(events).toContain("settings.changed"); + expect(await h.ctx.settings.get("automation")).toEqual({ enabled: false, readOnlyReconcile: true }); + // 其它分组不变 + expect((await json(await admin("GET", "/settings"))).data.cache.hardThresholdPercent).toBe(90); + }); + + it("非法合并结果 → 422(恢复水位必须低于硬阈值)", async () => { + const r = await admin("PATCH", "/settings", { cache: { resumePercent: 95 } }); + expect(r.status).toBe(422); + expect((await json(r)).error.message).toMatch(/恢复水位/); + }); + + it("Pi apiKey 只写:存为 secret,响应/审计/日志不含明文;null 清除并删除 secret", async () => { + const plain = "sk-live-super-secret-0001"; + const r = await json(await admin("PATCH", "/settings", { pi: { apiKey: plain, model: "claude-x" } })); + const ref = r.data.pi.apiKeySecretRef; + expect(ref).toMatch(/^[0-9a-f-]{36}$/); + expect(JSON.stringify(r)).not.toContain(plain); + expect(await h.ctx.secrets.resolve(ref)).toBe(plain); + // 再改一次:原地轮换,ref 不变 + const r2 = await json(await admin("PATCH", "/settings", { pi: { apiKey: `${plain}-2` } })); + expect(r2.data.pi.apiKeySecretRef).toBe(ref); + expect(await h.ctx.secrets.resolve(ref)).toBe(`${plain}-2`); + const audits = await sql<{ + payload: unknown; + }>`select payload from audit_event where action = 'settings.update'`.execute(h.db); + expect(JSON.stringify(audits.rows)).not.toContain(plain); + const r3 = await json(await admin("PATCH", "/settings", { pi: { apiKey: null } })); + expect(r3.data.pi.apiKeySecretRef).toBeNull(); + await expect(h.ctx.secrets.resolve(ref)).rejects.toThrow(/不存在/); + noLeak(plain); + }); + }); + + describe("凭证", () => { + it("/secrets 只写明文,列表只有元数据;重名 409;轮换;被引用时删除 409", async () => { + const c = await json( + await admin("POST", "/secrets", { name: "tmdb", purpose: "metadata_api_key", plaintext: "tmdb-plain-xyz" }), + ); + expect(c.data).toMatchObject({ + name: "tmdb", + purpose: "metadata_api_key", + algorithm: "aes-256-gcm", + kdf: "hkdf-sha256", + }); + expect(JSON.stringify(c)).not.toContain("tmdb-plain-xyz"); + expect((await admin("POST", "/secrets", { name: "tmdb", purpose: "other", plaintext: "y" })).status).toBe(409); + const list = await json(await admin("GET", "/secrets")); + expect(JSON.stringify(list)).not.toMatch(/ciphertext|nonce|authTag|tmdb-plain-xyz/); + const rot = await json(await admin("PATCH", `/secrets/${c.data.id}`, { plaintext: "tmdb-plain-2" })); + expect(rot.data.rotatedAt).toBeTruthy(); + expect(await h.ctx.secrets.resolve(c.data.id)).toBe("tmdb-plain-2"); + expect((await admin("DELETE", `/secrets/${c.data.id}`)).status).toBe(200); + noLeak("tmdb-plain"); + }); + }); + + describe("实例与路径", () => { + it("library_root:登记写哨兵文件;路径不存在 412;test 反映哨兵在线/离线;重名 409", async () => { + const r = await admin("POST", "/library-roots", { name: "电影", localPath: dir, mediaKind: "movie" }); + expect(r.status).toBe(200); + const root = (await json(r)).data; + expect(root).toMatchObject({ + sentinelFile: ".uhd-root", + healthStatus: "healthy", + managementMode: "register_only", + }); + expect(existsSync(join(dir, ".uhd-root"))).toBe(true); + expect((await admin("POST", "/library-roots", { name: "电影", localPath: `${dir}/x` })).status).toBe(412); + expect((await admin("POST", "/library-roots", { name: "电影", localPath: dir })).status).toBe(409); + expect((await json(await admin("POST", `/library-roots/${root.id}/test`))).data.ok).toBe(true); + rmSync(join(dir, ".uhd-root")); + const t = (await json(await admin("POST", `/library-roots/${root.id}/test`))).data; + expect(t).toMatchObject({ ok: false, healthStatus: "offline" }); + expect((await json(await admin("GET", `/library-roots/${root.id}`))).data.healthStatus).toBe("offline"); + expect((await admin("DELETE", `/library-roots/${root.id}`)).status).toBe(200); + expect((await admin("GET", `/library-roots/${root.id}`)).status).toBe(404); + }); + + it("downloader_instance:凭证只写 → secretRef;test 用 qB client 测连通;错密码 → ok=false;删除连带删 secret", async () => { + const c = await json( + await admin("POST", "/downloader-instances", { + name: "qb1", + baseUrl: qb.url, + username: "qbuser", + credential: "qb-secret-pass", + }), + ); + const inst = c.data; + expect(inst.secretRef).toBeTruthy(); + expect(JSON.stringify(c)).not.toContain("qb-secret-pass"); + const t = (await json(await admin("POST", `/downloader-instances/${inst.id}/test`))).data; + expect(t).toMatchObject({ ok: true, version: expect.any(String), healthStatus: "healthy" }); + const got = (await json(await admin("GET", `/downloader-instances/${inst.id}`))).data; + expect(got.healthStatus).toBe("healthy"); + expect(got.versionInfo.app).toBeTruthy(); + + await admin("PATCH", `/downloader-instances/${inst.id}`, { credential: "wrong-pass" }); + const again = (await json(await admin("GET", `/downloader-instances/${inst.id}`))).data; + expect(again.secretRef).toBe(inst.secretRef); + const bad = (await json(await admin("POST", `/downloader-instances/${inst.id}/test`))).data; + expect(bad).toMatchObject({ ok: false, healthStatus: "offline" }); + + expect((await admin("DELETE", `/downloader-instances/${inst.id}`)).status).toBe(200); + await expect(h.ctx.secrets.resolve(inst.secretRef)).rejects.toThrow(/不存在/); + noLeak("qb-secret-pass"); + }); + + it("media_server_instance:无 key 时 test 报未配置;有 key 时 ping 成功", async () => { + const a = (await json(await admin("POST", "/media-server-instances", { name: "emby-nokey", baseUrl: emby.url }))) + .data; + expect((await json(await admin("POST", `/media-server-instances/${a.id}/test`))).data).toMatchObject({ + ok: false, + }); + const b = ( + await json( + await admin("POST", "/media-server-instances", { + name: "emby", + baseUrl: emby.url, + credential: "emby-secret-key", + }), + ) + ).data; + const t = (await json(await admin("POST", `/media-server-instances/${b.id}/test`))).data; + expect(t).toMatchObject({ ok: true, healthStatus: "healthy" }); + expect((await json(await admin("GET", "/media-server-instances"))).data.items).toHaveLength(2); + }); + + it("path_mapping:viewKind 与实例不匹配 422;verify 按本地目录可达判定", async () => { + const d = (await json(await admin("POST", "/downloader-instances", { name: "qb2", baseUrl: qb.url }))).data; + expect( + ( + await admin("POST", "/path-mappings", { + viewKind: "media_server", + downloaderInstanceId: d.id, + remotePrefix: "/downloads", + localPrefix: dir, + }) + ).status, + ).toBe(422); + const m = ( + await json( + await admin("POST", "/path-mappings", { + viewKind: "downloader", + downloaderInstanceId: d.id, + remotePrefix: "/downloads", + localPrefix: dir, + }), + ) + ).data; + expect(m.verifyStatus).toBe("unverified"); + expect((await json(await admin("POST", `/path-mappings/${m.id}/verify`))).data.verifyStatus).toBe("ok"); + await admin("PATCH", `/path-mappings/${m.id}`, { localPrefix: `${dir}/missing` }); + const v = (await json(await admin("POST", `/path-mappings/${m.id}/verify`))).data; + expect(v).toMatchObject({ verifyStatus: "failed" }); + expect(v.verifyDetail.code).toBe("ENOENT"); + expect((await admin("DELETE", `/path-mappings/${m.id}`)).status).toBe(200); + }); + + it("PUT /sites/{id}/credentials:新建/替换账号凭证,不回显,旧 secret 删除;未知站点 404", async () => { + const site = await sql<{ id: string }>`insert into site (key, name, adapter, base_url) + values ('audiences', '观众', 'nexusphp', 'https://audiences.me') returning id`.execute(h.db); + const siteId = site.rows[0]!.id; + const r1 = await json( + await admin("PUT", `/sites/${siteId}/credentials`, { + authKind: "cookie", + credential: "c_secure_uid=1; pass=abc", + }), + ); + expect(r1.data).toMatchObject({ created: true, label: "default", authKind: "cookie" }); + expect(JSON.stringify(r1)).not.toContain("pass=abc"); + expect(await h.ctx.secrets.resolve(r1.data.secretRef)).toBe("c_secure_uid=1; pass=abc"); + const r2 = await json( + await admin("PUT", `/sites/${siteId}/credentials`, { + authKind: "password", + credential: "pw-123456", + username: "me", + }), + ); + expect(r2.data).toMatchObject({ created: false, siteAccountId: r1.data.siteAccountId }); + await expect(h.ctx.secrets.resolve(r1.data.secretRef)).rejects.toThrow(/不存在/); + const acc = await sql<{ auth_kind: string; user_info: { username?: string } }>` + select auth_kind, user_info from site_account where id = ${r1.data.siteAccountId}`.execute(h.db); + expect(acc.rows[0]).toMatchObject({ auth_kind: "password", user_info: { username: "me" } }); + expect( + ( + await admin("PUT", "/sites/00000000-0000-4000-8000-000000000000/credentials", { + authKind: "cookie", + credential: "x", + }) + ).status, + ).toBe(404); + noLeak("pass=abc"); + noLeak("pw-123456"); + }); + }); + + describe("审计查询", () => { + it("按 action 前缀 / target / actor 过滤,游标翻页", async () => { + const all = await json(await admin("GET", "/audit-events?action=api_key.*&limit=200")); + expect(all.data.items.length).toBeGreaterThanOrEqual(4); + expect(all.data.items.every((e: { action: string }) => e.action.startsWith("api_key."))).toBe(true); + const byActor = await json(await admin("GET", "/audit-events?actorType=user&actorId=dev-admin&limit=200")); + expect(byActor.data.items.length).toBeGreaterThan(0); + const p1 = await json(await admin("GET", "/audit-events?limit=3")); + const p2 = await json(await admin("GET", `/audit-events?limit=3&cursor=${p1.data.nextCursor}`)); + const ids = [...p1.data.items, ...p2.data.items].map((e: { id: string }) => e.id); + expect(new Set(ids).size).toBe(6); + expect(p1.data.items[0].at >= p2.data.items[0].at).toBe(true); + const t = await json(await admin("GET", "/audit-events?targetType=site_account")); + expect(t.data.items.map((e: { action: string }) => e.action)).toEqual([ + "site_account.credentials_set", + "site_account.credentials_set", + ]); + }); + }); + + describe("引擎面板", () => { + const ow = () => h.engineSchema; + beforeAll(async () => { + const t = (n: string) => sql.raw(`"${ow()}".${n}`); + const run = (id: string, status: string, name: string, at: string, extra = "") => + sql`insert into ${t("workflow_runs")} (namespace_id, id, workflow_name, version, status, config, context, input, attempts, + worker_id, available_at, started_at, created_at, updated_at) + values ('default', ${id}, ${name}, 'v1', ${status}, '{"retry":1}'::jsonb, '{"secret":"ctx"}'::jsonb, + ${JSON.stringify({ url: "https://x/rss?passkey=abc123", password: "p@ss", n: 1, extra })}::jsonb, 1, + ${status === "running" ? "worker-a" : null}, ${new Date(at)}, ${new Date(at)}, ${new Date(at)}, ${new Date(at)})`.execute( + h.db, + ); + await run("r1", "completed", "ingestFeed", "2026-09-01T00:00:00Z"); + await run("r2", "failed", "ingestFeed", "2026-09-02T00:00:00Z"); + await run("r3", "running", "submitDownload", "2026-09-03T00:00:00Z"); + await run("r4", "pending", "submitDownload", "2026-09-04T00:00:00Z"); + await sql`insert into ${t("step_attempts")} (namespace_id, id, workflow_run_id, step_name, kind, status, config, context, output, + started_at, created_at, updated_at) + values ('default', 's1', 'r3', 'wait-done', 'signal-wait', 'running', '{}'::jsonb, + '{"kind":"signal-wait","signal":"umt:r3:done","timeoutAt":"2026-09-05T00:00:00.000Z"}'::jsonb, null, now(), now(), now()), + ('default', 's0', 'r3', 'login', 'function', 'completed', '{}'::jsonb, null, '{"token":"tkn-1","ok":true}'::jsonb, now(), now() - interval '1 minute', now())`.execute( + h.db, + ); + await sql`insert into ${t("workflow_signals")} (namespace_id, id, signal, data, workflow_run_id, step_attempt_id) + values ('default', 'sig1', 'umt:r3:done', '{"cookie":"c=1"}'::jsonb, 'r3', 's1')`.execute(h.db); + }); + + it("runs 列表:状态/名称/时间筛选 + 游标", async () => { + const all = await json(await admin("GET", "/engine/runs")); + expect(all.data.items.map((r: { id: string }) => r.id)).toEqual(["r4", "r3", "r2", "r1"]); + expect(JSON.stringify(all)).not.toMatch(/config|context|passkey/); + const byName = await json(await admin("GET", "/engine/runs?workflowName=ingestFeed&status=failed")); + expect(byName.data.items.map((r: { id: string }) => r.id)).toEqual(["r2"]); + const ranged = await json(await admin("GET", "/engine/runs?from=2026-09-02T00:00:00Z&to=2026-09-04T00:00:00Z")); + expect(ranged.data.items.map((r: { id: string }) => r.id)).toEqual(["r3", "r2"]); + const p1 = await json(await admin("GET", "/engine/runs?limit=3")); + const p2 = await json(await admin("GET", `/engine/runs?limit=3&cursor=${p1.data.nextCursor}`)); + expect(p2.data.items.map((r: { id: string }) => r.id)).toEqual(["r1"]); + expect(p2.data.nextCursor).toBeNull(); + expect((await admin("GET", "/engine/runs?status=bogus")).status).toBe(422); + }); + + it("run 详情:step attempts + signals;敏感字段脱敏,不返回 config/context", async () => { + const d = (await json(await admin("GET", "/engine/runs/r3"))).data; + expect(d.input).toMatchObject({ password: "[REDACTED]", n: 1 }); + expect(d.input.url).not.toContain("abc123"); + expect(d.stepAttempts.map((s: { id: string }) => s.id)).toEqual(["s0", "s1"]); + expect(d.stepAttempts[0].output).toEqual({ token: "[REDACTED]", ok: true }); + expect(d.stepAttempts[1]).toMatchObject({ waitSignal: "umt:r3:done", timeoutAt: "2026-09-05T00:00:00.000Z" }); + expect(d.signals[0]).toMatchObject({ signal: "umt:r3:done", data: { cookie: "[REDACTED]" } }); + expect(JSON.stringify(d)).not.toMatch(/"config"|"context"|"retry"|"ctx"/); + expect((await admin("GET", "/engine/runs/nope")).status).toBe(404); + }); + + it("stats:按状态计数、最老待处理、活跃 worker 快照", async () => { + const s = (await json(await admin("GET", "/engine/stats"))).data; + expect(s.byStatus).toMatchObject({ pending: 1, running: 1, completed: 1, failed: 1, canceled: 0 }); + expect(s.oldestPending).toMatchObject({ id: "r4", workflowName: "submitDownload" }); + expect(s.activeWorkers).toEqual([{ workerId: "worker-a", running: 1, since: "2026-09-03T00:00:00.000Z" }]); + }); + + it("cancel 走 TaskEngine.cancel 并审计;未知 run 404", async () => { + const r = await json(await admin("POST", "/engine/runs/r4/cancel", { reason: "测试" })); + expect(r.data).toEqual({ canceled: true }); + expect(h.cancel).toHaveBeenCalledWith("r4", "测试"); + expect((await admin("POST", "/engine/runs/nope/cancel", {})).status).toBe(404); + const a = await sql<{ + n: number; + }>`select count(*)::int as n from audit_event where action = 'engine.run_cancel'`.execute(h.db); + expect(a.rows[0]!.n).toBe(1); + }); + }); +}); diff --git a/apps/server/test/auth-settings/harness.ts b/apps/server/test/auth-settings/harness.ts new file mode 100644 index 00000000..53d73c9b --- /dev/null +++ b/apps/server/test/auth-settings/harness.ts @@ -0,0 +1,176 @@ +import { type DB, migrateToLatest } from "@umt/db"; +import { migrateEngineSchema } from "@umt/tasks"; +import { Kysely, PostgresDialect } from "kysely"; +import pg from "pg"; +import { vi } from "vitest"; +import { ConfigSchema } from "../../src/config.ts"; +import { createOutboxPort } from "../../src/context.ts"; +import { createApp } from "../../src/http/app.ts"; +import { EventBus } from "../../src/http/sse.ts"; +import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; +import { createAuthSettingsModule } from "../../src/modules/auth-settings/index.ts"; +import { createSecretStore, createSettingsReader } from "../../src/modules/auth-settings/provided.ts"; +import type { ServerContext } from "../../src/modules/types.ts"; +import { DEV_KEY, memoryLogger } from "../helpers.ts"; + +// 真实 PG 的 W2-A 测试夹具:独立业务 schema + 独立引擎 schema;任务引擎用假对象(只测 cancel 透传)。 + +export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt"; +export const MASTER_KEY = "test-master-key-0123456789"; + +export async function pgAvailable() { + if (process.env.UMT_SKIP_DB_TESTS === "1") return false; + const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 }); + try { + await c.connect(); + await c.end(); + return true; + } catch { + return false; + } +} + +/** + * Kysely 迁移器会反射库里所有 schema 的表;别的测试文件同时 drop schema 时反射会报"schema 不存在"。 + * 同一进程内的测试文件用 PG 咨询锁把"迁移 / 删 schema"串行化,另加重试兜底(其它进程的测试也在用同一个库)。 + */ +const SCHEMA_LOCK = 0x756d7431; +export async function withSchemaLock(fn: () => Promise): Promise { + const c = new pg.Client({ connectionString: PG_URL }); + await c.connect(); + try { + await c.query("select pg_advisory_lock($1)", [SCHEMA_LOCK]); + for (let attempt = 1; ; attempt++) { + try { + return await fn(); + } catch (err) { + if (attempt >= 4 || !/does not exist/.test(String((err as Error)?.message))) throw err; + await new Promise((r) => setTimeout(r, 200 * attempt)); + } + } + } finally { + await c.query("select pg_advisory_unlock($1)", [SCHEMA_LOCK]).catch(() => {}); + await c.end(); + } +} + +async function adminQuery(q: string) { + const c = new pg.Client({ connectionString: PG_URL }); + await c.connect(); + try { + await c.query(q); + } finally { + await c.end(); + } +} + +export async function createHarness(opts: { engine?: boolean } = {}) { + const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`; + const schema = `w2a_test_${uniq}`; + const engineSchema = `${schema}_ow`; + await adminQuery(`create schema ${schema}`); + const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ connectionString: PG_URL, max: 5, options: `-c search_path=${schema},public` }), + }), + }); + await withSchemaLock(async () => { + await migrateToLatest(db, { migrationTableSchema: schema }); + if (opts.engine) await migrateEngineSchema(PG_URL, engineSchema); + }); + const { logger, lines } = memoryLogger(); + const bus = new EventBus(100); + const config = ConfigSchema.parse({ + DATABASE_URL: PG_URL, + SECRET_MASTER_KEY: MASTER_KEY, + NODE_ENV: "development", + DEV_ADMIN_KEY: DEV_KEY, + }); + const cancel = vi.fn(async (_runId: string, _reason?: string) => true); + const ctx = { + role: "all", + db, + config, + logger, + events: bus, + engine: { cancel } as unknown as ServerContext["engine"], + outbox: createOutboxPort(), + secrets: createSecretStore({ db, masterKey: MASTER_KEY }), + settings: createSettingsReader({ db, logger, events: bus }), + capabilities: createCapabilityRegistry({ logger }), + } as ServerContext; + const mod = createAuthSettingsModule({ setupTokenOnStart: false, engineSchema }); + const app = createApp({ config, logger, bus, db, role: "all", version: "0.0.0-test", modules: [mod], ctx }); + return { + db, + app, + ctx, + bus, + lines, + cancel, + schema, + engineSchema, + async close() { + await db.destroy(); + await withSchemaLock(async () => { + await adminQuery(`drop schema if exists ${schema} cascade`); + await adminQuery(`drop schema if exists ${engineSchema} cascade`); + }); + }, + }; +} +export type Harness = Awaited>; + +/** 极简 cookie jar:记住 Set-Cookie,请求时带上;写方法自动带 CSRF 头 */ +export class Browser { + cookies = new Map(); + constructor( + private readonly app: Harness["app"], + readonly ip = "203.0.113.10", + ) {} + + absorb(res: Response) { + for (const sc of res.headers.getSetCookie()) { + const [pair] = sc.split(";"); + const i = pair!.indexOf("="); + const k = pair!.slice(0, i).trim(); + const v = pair!.slice(i + 1).trim(); + if (!v || /max-age=0/i.test(sc)) this.cookies.delete(k); + else this.cookies.set(k, decodeURIComponent(v)); + } + } + + async req( + method: string, + path: string, + body?: unknown, + opts: { csrf?: boolean; headers?: Record } = {}, + ) { + const headers: Record = { "x-real-ip": this.ip, ...opts.headers }; + if (this.cookies.size) + headers.cookie = [...this.cookies].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("; "); + if (body !== undefined) headers["content-type"] = "application/json"; + const csrf = this.cookies.get("umt_csrf"); + if (method !== "GET" && opts.csrf !== false && csrf) headers["x-csrf-token"] = csrf; + const res = await this.app.request(`/api/v1${path}`, { + method, + headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + this.absorb(res); + return res; + } +} + +/** Bearer 调用 */ +export function bearer(app: Harness["app"], key: string) { + return (method: string, path: string, body?: unknown) => + app.request(`/api/v1${path}`, { + method, + headers: { + authorization: `Bearer ${key}`, + ...(body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); +} diff --git a/apps/server/test/auth-settings/login.test.ts b/apps/server/test/auth-settings/login.test.ts new file mode 100644 index 00000000..186d5ceb --- /dev/null +++ b/apps/server/test/auth-settings/login.test.ts @@ -0,0 +1,218 @@ +import { sql } from "@umt/db"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + ensureSetupToken, + MAX_FAILURES_PER_ACCOUNT, + MAX_FAILURES_PER_IP, +} from "../../src/modules/auth-settings/auth-service.ts"; +import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts"; +import { DEV_KEY, json } from "../helpers.ts"; +import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts"; + +const hasPg = await pgAvailable(); +const PASSWORD = "correct horse battery"; + +describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => { + let h: Harness; + beforeAll(async () => { + h = await createHarness(); + }, 60_000); + afterAll(async () => { + await h?.close(); + }); + beforeEach(async () => { + await sql`delete from login_attempt`.execute(h.db); + }); + + const login = (b: Browser, password = PASSWORD, extra: Record = {}) => + b.req("POST", "/auth/login", { username: "admin", password, ...extra }); + + it("未初始化:session 探测免鉴权;setup token 只打印一次到日志;错 token 401;并发初始化只有一个成功", async () => { + const b = new Browser(h.app); + const s0 = await json(await b.req("GET", "/auth/session")); + expect(s0.data).toMatchObject({ initialized: false, authenticated: false, actor: null }); + expect((await login(b)).status).toBe(412); + + const token = await ensureSetupToken(h.ctx); + expect(token).toBeTruthy(); + expect(h.lines.filter((l) => l.includes(token!))).toHaveLength(1); + // 库里只有哈希 + const row = await sql<{ setup_token_hash: string }>`select setup_token_hash from admin_user`.execute(h.db); + expect(row.rows[0]!.setup_token_hash).not.toContain(token!); + + const bad = await b.req("POST", "/auth/setup", { + setupToken: "x".repeat(32), + username: "admin", + password: PASSWORD, + }); + expect(bad.status).toBe(401); + // 密码太短 → 422 + expect( + (await b.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: "short" })).status, + ).toBe(422); + + const [r1, r2] = await Promise.all([ + new Browser(h.app, "198.51.100.1").req("POST", "/auth/setup", { + setupToken: token, + username: "admin", + password: PASSWORD, + }), + new Browser(h.app, "198.51.100.2").req("POST", "/auth/setup", { + setupToken: token, + username: "admin", + password: PASSWORD, + }), + ]); + expect([r1.status, r2.status].sort()).toEqual([200, 409]); + const ok = r1.status === 200 ? r1 : r2; + const setCookies = ok.headers.getSetCookie(); + const sess = setCookies.find((c) => c.startsWith("umt_session="))!; + expect(sess).toMatch(/HttpOnly/i); + expect(sess).toMatch(/SameSite=Lax/i); + expect(setCookies.find((c) => c.startsWith("umt_csrf="))).not.toMatch(/HttpOnly/i); + // 已初始化后不再生成 token + expect(await ensureSetupToken(h.ctx)).toBeNull(); + const audits = await sql<{ + n: number; + }>`select count(*)::int as n from audit_event where action = 'admin.setup'`.execute(h.db); + expect(audits.rows[0]!.n).toBe(1); + }); + + it("登录:错密码 401;正确密码下发 cookie;会话可访问 admin 端点;未登录 401", async () => { + const b = new Browser(h.app); + expect((await login(b, "wrong-password-1")).status).toBe(401); + expect((await b.req("GET", "/settings")).status).toBe(401); + const res = await login(b); + expect(res.status).toBe(200); + expect((await json(res)).data).toMatchObject({ + status: "ok", + session: { authenticated: true, actor: { type: "admin", name: "admin" } }, + }); + const s = await json(await b.req("GET", "/auth/session")); + expect(s.data.authenticated).toBe(true); + expect(s.data.expiresAt).toBeTruthy(); + expect((await b.req("GET", "/settings")).status).toBe(200); + // whoami 在 cookie 会话下同样可用 + expect((await json(await b.req("GET", "/auth/whoami"))).data.kind).toBe("admin_session"); + // 库里只存 sha256 + const raw = b.cookies.get("umt_session")!; + const rows = await sql<{ + n: number; + }>`select count(*)::int as n from admin_session where session_hash = ${raw}`.execute(h.db); + expect(rows.rows[0]!.n).toBe(0); + }); + + it("CSRF:cookie 会话的写请求缺少或伪造 x-csrf-token → 403;带正确值通过;Bearer 不需要", async () => { + const b = new Browser(h.app); + await login(b); + const patch = { automation: { enabled: true } }; + expect((await b.req("PATCH", "/settings", patch, { csrf: false })).status).toBe(403); + expect( + (await b.req("PATCH", "/settings", patch, { csrf: false, headers: { "x-csrf-token": "forged" } })).status, + ).toBe(403); + expect((await b.req("PATCH", "/settings", patch)).status).toBe(200); + expect((await bearer(h.app, DEV_KEY)("PATCH", "/settings", patch)).status).toBe(200); + }); + + it("限速:同 IP 失败 5 次后第 6 次(即使密码正确)→ 429,且被拦请求不落表", async () => { + const b = new Browser(h.app, "203.0.113.77"); + for (let i = 0; i < MAX_FAILURES_PER_IP; i++) expect((await login(b, `bad-${i}-password`)).status).toBe(401); + expect((await login(b)).status).toBe(429); + expect((await login(b)).status).toBe(429); + const n = await sql<{ n: number }>`select count(*)::int as n from login_attempt where ip = '203.0.113.77'`.execute( + h.db, + ); + expect(n.rows[0]!.n).toBe(MAX_FAILURES_PER_IP); + // 其它 IP 不受影响 + expect((await login(new Browser(h.app, "203.0.113.78"))).status).toBe(200); + }); + + it("限速:同账号跨 IP 累计失败 15 次 → 新 IP 也 429(防轮换 IP)", async () => { + for (let i = 0; i < MAX_FAILURES_PER_ACCOUNT; i++) { + expect((await login(new Browser(h.app, `192.0.2.${i + 1}`), `bad-${i}-pw`)).status).toBe(401); + } + expect((await login(new Browser(h.app, "192.0.2.99"))).status).toBe(429); + }); + + it("登出吊销会话;过期会话 401", async () => { + const b = new Browser(h.app); + await login(b); + const token = b.cookies.get("umt_session")!; + expect((await b.req("POST", "/auth/logout")).status).toBe(200); + expect(b.cookies.has("umt_session")).toBe(false); + b.cookies.set("umt_session", token); + expect((await b.req("GET", "/settings")).status).toBe(401); + + const c = new Browser(h.app); + await login(c); + await sql`update admin_session set expires_at = now() - interval '1 second' where revoked_at is null`.execute(h.db); + expect((await c.req("GET", "/settings")).status).toBe(401); + }); + + it("改密:校验旧密码,吊销其它会话、保留当前会话;API Key 不能改密", async () => { + const a = new Browser(h.app); + const b = new Browser(h.app); + await login(a); + await login(b); + expect( + (await a.req("POST", "/auth/password", { currentPassword: "nope-nope-nope", newPassword: "another password 1" })) + .status, + ).toBe(401); + const r = await a.req("POST", "/auth/password", { currentPassword: PASSWORD, newPassword: "another password 1" }); + expect(r.status).toBe(200); + expect((await json(r)).data.revokedSessions).toBeGreaterThanOrEqual(1); + expect((await a.req("GET", "/settings")).status).toBe(200); + expect((await b.req("GET", "/settings")).status).toBe(401); + expect((await login(new Browser(h.app))).status).toBe(401); + expect((await login(new Browser(h.app), "another password 1")).status).toBe(200); + // 改回来,后续用例用 PASSWORD + expect( + (await a.req("POST", "/auth/password", { currentPassword: "another password 1", newPassword: PASSWORD })).status, + ).toBe(200); + expect( + ( + await bearer(h.app, DEV_KEY)("POST", "/auth/password", { + currentPassword: PASSWORD, + newPassword: "x".repeat(12), + }) + ).status, + ).toBe(403); + const audits = await sql<{ + n: number; + }>`select count(*)::int as n from audit_event where action = 'admin.password_change'`.execute(h.db); + expect(audits.rows[0]!.n).toBe(2); + }); + + it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => { + const a = new Browser(h.app); + await login(a); + expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({ enabled: false, pending: false }); + const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data; + expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//); + const secret: string = begin.secret; + expect((await a.req("POST", "/auth/totp", { action: "enable", code: "000000" })).status).toBe(401); + const code0 = totpAt(secret, totpCounter()); + expect((await a.req("POST", "/auth/totp", { action: "enable", code: code0 })).status).toBe(200); + + const b = new Browser(h.app, "203.0.113.200"); + const step1 = await json(await login(b)); + expect(step1.data).toEqual({ status: "totp_required" }); + expect(b.cookies.has("umt_session")).toBe(false); + // 同一时间窗的码已在 enable 时消费 → 重放失败;下一个窗口的码可用 + expect((await login(b, PASSWORD, { totpCode: code0 })).status).toBe(401); + const next = totpAt(secret, totpCounter() + 1); + expect((await login(b, PASSWORD, { totpCode: next })).status).toBe(200); + expect((await login(new Browser(h.app, "203.0.113.201"), PASSWORD, { totpCode: next })).status).toBe(401); + const fails = await sql<{ + n: number; + }>`select count(*)::int as n from login_attempt where reason = 'bad_totp'`.execute(h.db); + expect(fails.rows[0]!.n).toBe(2); + + // 停用需要密码 + 新验证码(计数器已到 now+1,用 now+2 不在窗口内 → 这里直接把计数器回拨模拟时间流逝) + await sql`update admin_user set totp_last_counter = totp_last_counter - 5`.execute(h.db); + const code = totpAt(secret, totpCounter()); + expect((await a.req("POST", "/auth/totp", { action: "disable", password: "bad-password", code })).status).toBe(401); + expect((await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code })).status).toBe(200); + expect((await json(await login(new Browser(h.app, "203.0.113.202")))).data.status).toBe("ok"); + }); +}); diff --git a/apps/server/test/auth-settings/secrets.test.ts b/apps/server/test/auth-settings/secrets.test.ts new file mode 100644 index 00000000..f1e5036a --- /dev/null +++ b/apps/server/test/auth-settings/secrets.test.ts @@ -0,0 +1,97 @@ +import { sql } from "@umt/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + base32Decode, + base32Encode, + hashPassword, + totpAt, + verifyPassword, + verifyTotp, +} from "../../src/modules/auth-settings/crypto.ts"; +import { createSecretStore, SecretError } from "../../src/modules/auth-settings/provided.ts"; +import { createHarness, type Harness, MASTER_KEY, pgAvailable } from "./harness.ts"; + +describe("密码学原语", () => { + it("argon2id(hash-wasm)输出标准 PHC 串,校验正反", async () => { + const h = await hashPassword("pässwörd-123"); + expect(h).toMatch(/^\$argon2id\$v=19\$m=19456,t=2,p=1\$/); + expect(await verifyPassword("pässwörd-123", h)).toBe(true); + expect(await verifyPassword("password-123", h)).toBe(false); + expect(await verifyPassword("x", "not-a-hash")).toBe(false); + }); + + it("TOTP:RFC 6238 测试向量(SHA1)+ 防重放", () => { + const secret = base32Encode(Buffer.from("12345678901234567890")); + expect(base32Decode(secret).toString()).toBe("12345678901234567890"); + // RFC 6238 附录 B:T=59s → 94287082(8 位),6 位取后 6 位 + expect(totpAt(secret, 1)).toBe("287082"); + expect(totpAt(secret, Math.floor(1111111109 / 30))).toBe("081804"); + const now = 1_700_000_000_000; + const c = Math.floor(now / 30_000); + expect(verifyTotp(secret, totpAt(secret, c), null, now)).toBe(c); + expect(verifyTotp(secret, totpAt(secret, c - 1), null, now)).toBe(c - 1); + expect(verifyTotp(secret, totpAt(secret, c), c, now)).toBeNull(); + expect(verifyTotp(secret, totpAt(secret, c + 3), null, now)).toBeNull(); + }); +}); + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("SecretStore(真实 PG)", () => { + let h: Harness; + beforeAll(async () => { + h = await createHarness(); + }, 60_000); + afterAll(async () => { + await h?.close(); + }); + + it("create/resolve/replace(rotate)/remove 往返;库里无明文;每次加密盐与 nonce 不同", async () => { + const s = h.ctx.secrets; + const ref = await s.create({ purpose: "site_cookie", plaintext: "uid=1; pass=topsecret" }); + expect(await s.resolve(ref)).toBe("uid=1; pass=topsecret"); + const raw = await sql<{ ciphertext: Buffer; nonce: Buffer; kdf_params: { salt: string } }>` + select ciphertext, nonce, kdf_params from secret where id = ${ref}`.execute(h.db); + const before = raw.rows[0]!; + expect(before.ciphertext.toString("utf8")).not.toContain("topsecret"); + await s.replace(ref, "uid=1; pass=rotated"); + expect(await s.resolve(ref)).toBe("uid=1; pass=rotated"); + const after = ( + await sql<{ nonce: Buffer; kdf_params: { salt: string }; rotated_at: Date | null }>` + select nonce, kdf_params, rotated_at from secret where id = ${ref}`.execute(h.db) + ).rows[0]!; + expect(after.nonce.equals(before.nonce)).toBe(false); + expect(after.kdf_params.salt).not.toBe(before.kdf_params.salt); + expect(after.rotated_at).toBeTruthy(); + await s.remove(ref); + await expect(s.resolve(ref)).rejects.toBeInstanceOf(SecretError); + }); + + it("主密钥不对 / 密文挪到别的行 / 改用途 → 解密失败,错误信息不含明文", async () => { + const s = h.ctx.secrets; + const a = await s.create({ purpose: "site_api_key", plaintext: "alpha-plain" }); + const b = await s.create({ purpose: "site_api_key", plaintext: "bravo-plain" }); + const other = createSecretStore({ db: h.db, masterKey: `${MASTER_KEY}-other` }); + await expect(other.resolve(a)).rejects.toThrow(/解密失败/); + await sql`update secret set ciphertext = (select ciphertext from secret where id = ${a}), + nonce = (select nonce from secret where id = ${a}), auth_tag = (select auth_tag from secret where id = ${a}), + kdf_params = (select kdf_params from secret where id = ${a}) where id = ${b}`.execute(h.db); + const err = await s.resolve(b).catch((e: Error) => e); + expect(err).toBeInstanceOf(SecretError); + expect(String((err as Error).message)).not.toMatch(/alpha|bravo/); + await sql`update secret set purpose = 'other' where id = ${a}`.execute(h.db); + await expect(s.resolve(a)).rejects.toThrow(/解密失败/); + await expect(s.resolve("not-a-uuid")).rejects.toThrow(/不存在/); + }); + + it("resolve 出的明文经 logger 记录时也不会以敏感字段名泄漏;SecretError 进日志只有 ref", async () => { + const ref = await h.ctx.secrets.create({ purpose: "downloader_password", plaintext: "qb-pass-777" }); + const password = await h.ctx.secrets.resolve(ref); + h.ctx.logger.info({ password, secretRef: ref }, "连接下载器"); + const err = await h.ctx.secrets.resolve("00000000-0000-4000-8000-000000000000").catch((e: Error) => e); + h.ctx.logger.error({ err }, "取凭证失败"); + const out = h.lines.join("\n"); + expect(out).not.toContain("qb-pass-777"); + expect(out).toContain("[REDACTED]"); + }); +}); diff --git a/apps/server/test/auth-settings/sse-pg.test.ts b/apps/server/test/auth-settings/sse-pg.test.ts new file mode 100644 index 00000000..6f7615a1 --- /dev/null +++ b/apps/server/test/auth-settings/sse-pg.test.ts @@ -0,0 +1,147 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createApp } from "../../src/http/app.ts"; +import { PgEventBus } from "../../src/http/sse-pg.ts"; +import { DEV_KEY } from "../helpers.ts"; +import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts"; + +// SSE 多进程:两个 Deno 子进程(订阅者 / 发布者)通过 sse_event + LISTEN/NOTIFY 互通; +// 再验证 API 进程的 GET /events 能推送另一个进程发布的事件,以及 ?after= 补读与裁剪后的 resync。 + +const hasPg = await pgAvailable(); +const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url)); + +function proc(args: string[]) { + const child = spawn(process.execPath, ["run", "-A", "test/fixtures/sse-bus-proc.ts", ...args], { + cwd: SERVER_DIR, + env: { ...process.env, DATABASE_URL: PG_URL }, + stdio: ["ignore", "pipe", "pipe"], + }); + const lines: string[] = []; + let buf = ""; + let err = ""; + child.stdout!.on("data", (d: Buffer) => { + buf += d.toString(); + const parts = buf.split("\n"); + buf = parts.pop() ?? ""; + lines.push(...parts.filter(Boolean)); + }); + child.stderr!.on("data", (d: Buffer) => { + err += d.toString(); + }); + const exited = new Promise((resolve) => child.on("exit", (code) => resolve(code ?? -1))); + return { child, lines, exited, stderr: () => err }; +} + +async function until(cond: () => boolean, ms: number, what: string) { + const deadline = Date.now() + ms; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`等待超时:${what}`); + await new Promise((r) => setTimeout(r, 50)); + } +} + +describe.skipIf(!hasPg)("PgEventBus 多进程(真实 PG)", () => { + let h: Harness; + let bus: PgEventBus; + const channel = `umt_events_t${process.pid}${Math.random().toString(36).slice(2, 6)}`; + const children: ChildProcess[] = []; + + beforeAll(async () => { + h = await createHarness(); + bus = await PgEventBus.start({ + db: h.db, + connectionString: PG_URL, + logger: h.ctx.logger, + channel, + retain: 30, + pruneEvery: 1000, + }); + }, 60_000); + afterAll(async () => { + for (const c of children) c.kill(); + await bus?.stop(); + await h?.close(); + }); + + it("子进程 A 订阅、子进程 B 发布:A 按 seq 顺序收齐;本进程也能补读", async () => { + const sub = proc(["sub", h.schema, channel, "20"]); + children.push(sub.child); + await until(() => sub.lines.includes("READY"), 30_000, "订阅进程就绪"); + const seenHere: number[] = []; + const off = bus.subscribe((e) => seenHere.push(e.seq)); + const pub = proc(["pub", h.schema, channel, "20"]); + children.push(pub.child); + expect(await pub.exited).toBe(0); + expect(await sub.exited).toBe(0); + const events = sub.lines.filter((l) => l.startsWith("{")).map((l) => JSON.parse(l)); + expect(events).toHaveLength(20); + expect(events.map((e) => e.payload.i)).toEqual([...Array(20).keys()]); + const seqs = events.map((e) => e.seq as number); + expect([...seqs].sort((a, b) => a - b)).toEqual(seqs); + expect(new Set(events.map((e) => e.payload.pid)).size).toBe(1); + await until(() => seenHere.length >= 20, 5_000, "本进程收到"); + off(); + const since = await bus.since(seqs[9]!); + expect(since.events.map((e) => e.seq)).toEqual(seqs.slice(10)); + expect(since.gap).toBe(false); + expect(since.latestSeq).toBe(seqs[19]); + }, 60_000); + + it("API 进程的 GET /events 推送其它进程发布的事件;裁剪后旧游标收到 resync", async () => { + const app = createApp({ + config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, + logger: h.ctx.logger, + bus, + role: "api", + version: "t", + }); + const { latestSeq } = await bus.since(0); + const ac = new AbortController(); + const res = await app.request(`/api/v1/events?after=${latestSeq}`, { + headers: { authorization: `Bearer ${DEV_KEY}` }, + signal: ac.signal, + }); + const reader = res.body!.getReader(); + let text = ""; + const reading = (async () => { + for (;;) { + const { value, done } = await reader.read(); + if (done) return; + text += new TextDecoder().decode(value); + } + })(); + const pub = proc(["pub", h.schema, channel, "3"]); + children.push(pub.child); + expect(await pub.exited).toBe(0); + await until(() => (text.match(/event: test\.ping/g) ?? []).length >= 3, 10_000, "SSE 推送"); + ac.abort(); + await reader.cancel().catch(() => {}); + await reading.catch(() => {}); + + // 裁剪到只剩 30 条后,after=1 已不可补读 → resync + const more = proc(["pub", h.schema, channel, "40"]); + children.push(more.child); + expect(await more.exited).toBe(0); + await bus.prune(); + const s = await bus.since(1); + expect(s.gap).toBe(true); + expect(s.events.length).toBe(30); + const ac2 = new AbortController(); + const res2 = await app.request("/api/v1/events?after=1", { + headers: { authorization: `Bearer ${DEV_KEY}` }, + signal: ac2.signal, + }); + const r2 = res2.body!.getReader(); + const first = new TextDecoder().decode((await r2.read()).value); + expect(first).toContain("event: system.resync"); + ac2.abort(); + await r2.cancel().catch(() => {}); + }, 60_000); + + it("发布失败不抛(返回 null)", async () => { + // BigInt 无法 JSON 序列化 → 发布内部出错 + await expect(bus.publish("x", { n: 1n })).resolves.toBeNull(); + }); +}); diff --git a/apps/server/test/bootstrap.test.ts b/apps/server/test/bootstrap.test.ts index 5135f468..9cadfab9 100644 --- a/apps/server/test/bootstrap.test.ts +++ b/apps/server/test/bootstrap.test.ts @@ -10,6 +10,7 @@ import { type Bootstrapped, bootstrap } from "../src/context.ts"; import { EventBus } from "../src/http/sse.ts"; import type { ServerModule } from "../src/modules/types.ts"; import { type RunningWorker, startWorker } from "../src/worker/index.ts"; +import { withSchemaLock } from "./auth-settings/harness.ts"; import { memoryLogger } from "./helpers.ts"; // 真实 PG:独立业务 schema + 独立引擎 schema,验证 bootstrap → worker → outbox → 模块任务 的整条装配。 @@ -71,7 +72,7 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => { pool: new pg.Pool({ connectionString: url, max: 5, options: `-c search_path=${biz},public` }), }), }); - await migrateToLatest(db, { migrationTableSchema: biz }); + await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: biz })); await db .insertInto("settings") .values({ key: "cache", value: JSON.stringify({ hardThresholdPercent: 95, resumePercent: 90 }) }) @@ -98,18 +99,20 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => { await worker?.stop(); await booted?.engine.stop(); await db?.destroy(); - const admin = new pg.Client({ connectionString: url }); - await admin.connect(); - await admin.query(`drop schema if exists ${biz} cascade`); - await admin.query(`drop schema if exists ${ow} cascade`); - await admin.end(); + await withSchemaLock(async () => { + const admin = new pg.Client({ connectionString: url }); + await admin.connect(); + await admin.query(`drop schema if exists ${biz} cascade`); + await admin.query(`drop schema if exists ${ow} cascade`); + await admin.end(); + }); }, 60_000); it("settings 读取合并缺省值", async () => { const cache = await booted.ctx.settings.get("cache"); expect(cache).toMatchObject({ hardThresholdPercent: 95, resumePercent: 90, checkIntervalSec: 60 }); expect((await booted.ctx.settings.all()).automation.enabled).toBe(true); - await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/W2-A/); + await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/不存在/); }); it("业务事务内 emit → 中继路由 → worker 执行模块任务;同幂等键只投一次;非法 payload 拒写", async () => { diff --git a/apps/server/test/fixtures/sse-bus-proc.ts b/apps/server/test/fixtures/sse-bus-proc.ts new file mode 100644 index 00000000..5a7665fb --- /dev/null +++ b/apps/server/test/fixtures/sse-bus-proc.ts @@ -0,0 +1,43 @@ +// SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。 +// 用法:deno run -A test/fixtures/sse-bus-proc.ts +import type { DB } from "@umt/db"; +import { Kysely, PostgresDialect } from "kysely"; +import pg from "pg"; +import { PgEventBus } from "../../src/http/sse-pg.ts"; +import { createLogger } from "../../src/logger.ts"; + +const [mode, schema, channel, nRaw] = Deno.args; +const n = Number(nRaw); +const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt"; +const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }), + }), +}); +const bus = await PgEventBus.start({ + db, + connectionString: url, + logger: createLogger({ level: "warn" }), + channel: channel!, +}); + +if (mode === "sub") { + let got = 0; + const done = new Promise((resolve) => { + bus.subscribe((e) => { + console.log(JSON.stringify(e)); + if (++got >= n) resolve(); + }); + }); + console.log("READY"); + const timeout = setTimeout(() => { + console.error("timeout"); + Deno.exit(3); + }, 20_000); + await done; + clearTimeout(timeout); +} else { + for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: Deno.pid }); +} +await bus.stop(); +await db.destroy(); diff --git a/apps/server/test/sse.test.ts b/apps/server/test/sse.test.ts index 87c42f7c..986c03e5 100644 --- a/apps/server/test/sse.test.ts +++ b/apps/server/test/sse.test.ts @@ -5,11 +5,11 @@ import { DEV_KEY, testApp } from "./helpers.ts"; describe("EventBus", () => { it("序号自增并按 after 补读,淘汰后标记 gap", () => { const bus = new EventBus(3); - for (let i = 0; i < 5; i++) bus.publish("t", { i }); + for (let i = 0; i < 5; i++) bus.publishSync("t", { i }); expect(bus.latestSeq).toBe(5); - expect(bus.since(3).events.map((e) => e.seq)).toEqual([4, 5]); - expect(bus.since(3).gap).toBe(false); - expect(bus.since(1).gap).toBe(true); + expect(bus.sinceSync(3).events.map((e) => e.seq)).toEqual([4, 5]); + expect(bus.sinceSync(3).gap).toBe(false); + expect(bus.sinceSync(1).gap).toBe(true); }); }); @@ -21,8 +21,8 @@ describe("GET /api/v1/events", () => { it("补读 after 之后的事件", async () => { const { app, bus } = testApp(); - bus.publish("a", 1); - bus.publish("b", 2); + bus.publishSync("a", 1); + bus.publishSync("b", 2); const ac = new AbortController(); const res = await app.request("/api/v1/events?after=1", { headers: { Authorization: `Bearer ${DEV_KEY}` }, diff --git a/deno.json b/deno.json index 12c57812..eba27fed 100644 --- a/deno.json +++ b/deno.json @@ -26,6 +26,7 @@ "@xmldom/xmldom": "npm:@xmldom/xmldom@0.9.12", "cheerio": "npm:cheerio@1.2.0", "fast-xml-parser": "npm:fast-xml-parser@5.11.1", + "hash-wasm": "npm:hash-wasm@4.12.0", "hono": "npm:hono@4.13.9", "hono/": "npm:/hono@4.13.9/", "kysely": "npm:kysely@0.29.6", diff --git a/deno.lock b/deno.lock index 92a5b5c9..6aacbc75 100644 --- a/deno.lock +++ b/deno.lock @@ -24,6 +24,7 @@ "npm:clsx@^2.1.1": "2.1.1", "npm:date-fns@^4.1.0": "4.4.0", "npm:fast-xml-parser@5.11.1": "5.11.1", + "npm:hash-wasm@4.12.0": "4.12.0", "npm:hono@4.13.9": "4.13.9", "npm:isbot@^5.1.31": "5.2.2", "npm:kysely@0.29.6": "0.29.6", @@ -3508,6 +3509,9 @@ "has-symbols@1.1.0": { "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, + "hash-wasm@4.12.0": { + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==" + }, "hasown@2.0.4": { "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dependencies": [ @@ -5383,6 +5387,7 @@ "npm:@xmldom/xmldom@0.9.12", "npm:cheerio@1.2.0", "npm:fast-xml-parser@5.11.1", + "npm:hash-wasm@4.12.0", "npm:hono@4.13.9", "npm:kysely@0.29.6", "npm:nunjucks@3.2.4", diff --git a/docs/runbook/auth.md b/docs/runbook/auth.md new file mode 100644 index 00000000..357dcb1e --- /dev/null +++ b/docs/runbook/auth.md @@ -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(...):`。容器里:`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 ` → 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=`(或 `Last-Event-ID`)从表里补读;表只保留最近 10000 条(每 200 次发布裁剪),起点已被裁剪时先发 `system.resync`。 +- `ctx.events.publish(type, payload)` 返回 `Promise`,永不 reject(失败记日志),可以不 await。 +- 测试/无库场景用进程内 `EventBus`(`publishSync` / `sinceSync` 便于断言)。 diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts new file mode 100644 index 00000000..34c006ae --- /dev/null +++ b/packages/contracts/src/auth.ts @@ -0,0 +1,143 @@ +import { z } from "zod"; +import { ApiScope, Timestamp } from "./common.ts"; + +// 管理员登录门禁(任务书 §3.1-14,做法见 docs/design/auth-reference.md): +// 单管理员账号密码 + 服务端会话(cookie umt_session)+ HMAC 派生 CSRF 双提交 + 可选 TOTP; +// 外部程序走 API Key(Bearer)。两种调用者在服务端统一为 Actor。 + +/** 会话 cookie 名(HttpOnly,SameSite=Lax) */ +export const SESSION_COOKIE = "umt_session"; +/** CSRF cookie 名(非 HttpOnly,前端读出后放进 CSRF_HEADER) */ +export const CSRF_COOKIE = "umt_csrf"; +/** 写方法(POST/PUT/PATCH/DELETE)用 cookie 会话调用时必须带的请求头 */ +export const CSRF_HEADER = "x-csrf-token"; + +/** 统一调用者:控制台会话(admin)或外部 API Key */ +export const ActorKind = z.enum(["admin", "api_key"]); +export type ActorKind = z.infer; +export const Actor = z.object({ + type: ActorKind, + /** admin:admin_user.id(开发模式 DEV_ADMIN_KEY 为 "dev-admin");api_key:api_key.id */ + id: z.string(), + name: z.string(), + scopes: z.array(ApiScope), +}); +export type Actor = z.infer; + +const Username = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_.@-]+$/, "用户名只能包含字母、数字与 _ . @ -"); +/** 密码:长度 10–256(argon2id 存储,不限制字符集) */ +export const Password = z.string().min(10, "密码至少 10 个字符").max(256); + +/** POST /auth/setup:首次初始化管理员(setup token 在首次启动时打印到服务端日志一次) */ +export const AdminSetupRequest = z.object({ + setupToken: z.string().min(16).max(256), + username: Username, + password: Password, +}); +export type AdminSetupRequest = z.infer; + +/** 6 位 TOTP 码 */ +export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字"); + +/** POST /auth/login */ +export const LoginRequest = z.object({ + username: z.string().min(1).max(64), + password: z.string().min(1).max(256), + /** 开启 TOTP 后第二步提交 */ + totpCode: TotpCode.optional(), +}); +export type LoginRequest = z.infer; + +/** GET /auth/session(免鉴权,SPA 探测登录态与是否需要初始化) */ +export const SessionInfo = z.object({ + initialized: z.boolean(), + authenticated: z.boolean(), + actor: Actor.nullable(), + /** 仅 cookie 会话有值 */ + expiresAt: Timestamp.nullable(), + totpEnabled: z.boolean(), +}); +export type SessionInfo = z.infer; + +/** + * POST /auth/login 结果:ok=登录成功(cookie 已下发);totp_required=密码正确但需第二步验证码 + * (不计入失败次数,前端保留用户名/密码并追加验证码重新提交) + */ +export const LoginResult = z.discriminatedUnion("status", [ + z.object({ status: z.literal("ok"), session: SessionInfo }), + z.object({ status: z.literal("totp_required") }), +]); +export type LoginResult = z.infer; + +/** POST /auth/password:校验旧密码 → 改密 → 吊销其它会话 */ +export const PasswordChangeRequest = z.object({ + currentPassword: z.string().min(1).max(256), + newPassword: Password, +}); +export type PasswordChangeRequest = z.infer; +export const PasswordChangeResult = z.object({ revokedSessions: z.number().int().nonnegative() }); +export type PasswordChangeResult = z.infer; + +/** GET /auth/totp */ +export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean() }); +export type TotpStatus = z.infer; +/** + * POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认; + * disable 需要当前密码 + 验证码 + */ +export const TotpAction = z.discriminatedUnion("action", [ + z.object({ action: z.literal("begin") }), + z.object({ action: z.literal("enable"), code: TotpCode }), + z.object({ action: z.literal("disable"), password: z.string().min(1).max(256), code: TotpCode }), +]); +export type TotpAction = z.infer; +export const TotpActionResult = z.object({ + status: TotpStatus, + /** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */ + otpauthUri: z.string().optional(), + secret: z.string().optional(), +}); +export type TotpActionResult = z.infer; + +/** + * 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。 + * 新增动作只在这里加。 + */ +export const AuditAction = z.enum([ + "admin.setup", + "admin.login_success", + "admin.logout", + "admin.password_change", + "admin.totp_enable", + "admin.totp_disable", + "api_key.create", + "api_key.update", + "api_key.revoke", + "settings.update", + "secret.create", + "secret.rotate", + "secret.delete", + "library_root.create", + "library_root.update", + "library_root.delete", + "library_root.test", + "downloader_instance.create", + "downloader_instance.update", + "downloader_instance.delete", + "downloader_instance.test", + "media_server_instance.create", + "media_server_instance.update", + "media_server_instance.delete", + "media_server_instance.test", + "path_mapping.create", + "path_mapping.update", + "path_mapping.delete", + "path_mapping.verify", + "site_account.credentials_set", + "engine.run_cancel", +]); +export type AuditAction = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4057ce60..72ea350c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,6 +2,7 @@ export * from "./agent.ts"; export * from "./agent-events.ts"; export * from "./api.ts"; export * from "./assets.ts"; +export * from "./auth.ts"; export * from "./capabilities-io.ts"; export * from "./capability.ts"; export * from "./common.ts"; diff --git a/packages/contracts/src/instances.ts b/packages/contracts/src/instances.ts index 0c4e770d..9ab3680e 100644 --- a/packages/contracts/src/instances.ts +++ b/packages/contracts/src/instances.ts @@ -159,3 +159,37 @@ export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOw message: "viewKind 与实例 ID 不匹配", }); export type PathMappingUpdate = z.infer; + +/** POST /{downloader,media-server}-instances/{id}/test 与 /library-roots/{id}/test 结果 */ +export const InstanceTestResult = z.object({ + ok: z.boolean(), + version: z.string().nullable(), + message: z.string().nullable(), + healthStatus: InstanceHealth, +}); +export type InstanceTestResult = z.infer; + +/** + * PUT /sites/{id}/credentials:站点账号凭证录入(cookie / passkey / apiKey / 密码)。 + * 按 (siteId, label) 建立或更新 site_account;明文加密为 secret 后丢弃,响应不回显。 + */ +export const SiteCredentialsPut = z.object({ + label: z.string().min(1).max(80).default("default"), + authKind: z.enum(["cookie", "passkey", "api_key", "password"]), + /** 只写明文 */ + credential: Credential, + /** authKind=password 时的登录名(存入 user_info.username) */ + username: z.string().max(200).optional(), + enabled: z.boolean().optional(), +}); +export type SiteCredentialsPut = z.infer; +export const SiteCredentialsResult = z.object({ + siteAccountId: Id, + siteId: Id, + label: z.string(), + authKind: z.string(), + secretRef: Id, + /** true:新建账号;false:替换已有凭证 */ + created: z.boolean(), +}); +export type SiteCredentialsResult = z.infer; diff --git a/packages/contracts/src/ops.ts b/packages/contracts/src/ops.ts index 971a7a6c..c98d6d5a 100644 --- a/packages/contracts/src/ops.ts +++ b/packages/contracts/src/ops.ts @@ -192,3 +192,115 @@ export const SecretCreate = z.object({ export type SecretCreate = z.infer; export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) }); export type SecretUpdate = z.infer; + +/** GET /audit-events 过滤:按 actor / action / target;游标分页(新 → 旧) */ +export const AuditEventQuery = z.object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + actorType: z.string().max(40).optional(), + actorId: z.string().max(200).optional(), + /** 精确匹配;以 `.*` 结尾时按前缀匹配(如 `admin.*`) */ + action: z.string().max(80).optional(), + targetType: z.string().max(80).optional(), + targetId: z.string().max(200).optional(), +}); +export type AuditEventQuery = z.infer; + +// ── 引擎面板(只读直查 openworkflow schema,仅 admin;docs/design/openworkflow-dashboard-reference.md) ── + +/** 引擎 run 状态(含 legacy sleeping/succeeded) */ +export const EngineRunStatus = z.enum([ + "pending", + "running", + "sleeping", + "succeeded", + "completed", + "failed", + "canceled", +]); +export type EngineRunStatus = z.infer; +export const EngineStepKind = z.enum(["function", "sleep", "workflow", "signal-send", "signal-wait"]); +export type EngineStepKind = z.infer; + +/** 引擎 ID 是 openworkflow 自己生成的文本 ID(非本地 UUID) */ +const EngineId = z.string().min(1).max(200); + +export const EngineRun = z.object({ + id: EngineId, + workflowName: z.string(), + version: z.string().nullable(), + status: z.string(), + attempts: z.number().int(), + workerId: z.string().nullable(), + parentStepAttemptId: z.string().nullable(), + availableAt: Timestamp.nullable(), + deadlineAt: Timestamp.nullable(), + startedAt: Timestamp.nullable(), + finishedAt: Timestamp.nullable(), + createdAt: Timestamp, + updatedAt: Timestamp, +}); +export type EngineRun = z.infer; + +export const EngineRunQuery = z.object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + status: EngineRunStatus.optional(), + workflowName: z.string().max(200).optional(), + /** created_at 下界(含) */ + from: Timestamp.optional(), + /** created_at 上界(不含) */ + to: Timestamp.optional(), +}); +export type EngineRunQuery = z.infer; + +/** step attempt:不含 config;context 只露 signal-wait 的信号名与超时(排查卡等待用) */ +export const EngineStepAttempt = z.object({ + id: EngineId, + stepName: z.string(), + kind: z.string(), + status: z.string(), + output: z.unknown(), + error: z.unknown(), + waitSignal: z.string().nullable(), + timeoutAt: z.string().nullable(), + childWorkflowRunId: z.string().nullable(), + startedAt: Timestamp.nullable(), + finishedAt: Timestamp.nullable(), + createdAt: Timestamp, +}); +export type EngineStepAttempt = z.infer; + +export const EngineSignal = z.object({ + id: EngineId, + signal: z.string(), + data: z.unknown(), + stepAttemptId: z.string(), + createdAt: Timestamp, +}); +export type EngineSignal = z.infer; + +/** run 详情:input/output/error 经敏感字段脱敏;不返回 config/context */ +export const EngineRunDetail = EngineRun.extend({ + input: z.unknown(), + output: z.unknown(), + error: z.unknown(), + stepAttempts: z.array(EngineStepAttempt), + signals: z.array(EngineSignal), +}); +export type EngineRunDetail = z.infer; + +export const EngineStats = z.object({ + /** 全表按状态计数(不受列表筛选影响) */ + byStatus: z.record(z.string(), z.number().int().nonnegative()), + /** 最老的待处理 run(pending,按 available_at/created_at) */ + oldestPending: z + .object({ id: EngineId, workflowName: z.string(), since: Timestamp, ageSec: z.number().nonnegative() }) + .nullable(), + /** 当前持有租约的 worker 快照(非权威注册表:引擎没有 worker 表) */ + activeWorkers: z.array(z.object({ workerId: z.string(), running: z.number().int(), since: Timestamp.nullable() })), +}); +export type EngineStats = z.infer; + +export const EngineCancelRequest = z.object({ reason: z.string().max(500).optional() }); +export const EngineCancelResult = z.object({ canceled: z.boolean() }); diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index f4af6cef..8420afd3 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -82,7 +82,16 @@ const ROWS: Record }; /** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */ -const INTERNAL_TABLES = ["TasksRunMeta", "TasksFact", "TasksOpLedger"]; +const INTERNAL_TABLES = [ + "TasksRunMeta", + "TasksFact", + "TasksOpLedger", + // W2-A 登录门禁与 SSE 总线的服务端内部表:经 contracts/auth.ts 的专用 DTO 暴露,不做 Row 对照 + "AdminUser", + "AdminSession", + "LoginAttempt", + "SseEvent", +]; const INTERNAL_ENUMS = ["TasksOpLedgerStatus"]; /** DB 枚举类型名 → contracts 枚举 */ diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 360b6990..cd0874d8 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -32,6 +32,7 @@ const STRING_CANDIDATES = [ "CatalogLookupInput", "catalog.lookup", "测试 Test", + "123456", ]; const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000]; @@ -92,6 +93,7 @@ const OVERRIDES: Record) => Record v, CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }), GlobalSettings: () => C.DEFAULT_SETTINGS, + EngineStats: (v) => ({ ...v, byStatus: { pending: 1 } }), TypedEventEnvelope: () => ({ seq: 1, at: "2026-09-27T08:00:00Z", diff --git a/packages/db/migrations/0015_admin_auth.ts b/packages/db/migrations/0015_admin_auth.ts new file mode 100644 index 00000000..53fda748 --- /dev/null +++ b/packages/db/migrations/0015_admin_auth.ts @@ -0,0 +1,71 @@ +import type { Kysely } from "kysely"; +import { dropTables, entityTable, execDdl } from "../src/migration-helpers.ts"; + +// 管理员登录门禁(任务书 §3.1-14;docs/design/auth-reference.md): +// admin_user 单行(singleton 唯一)、admin_session(只存 sha256(token),软删除吊销)、 +// login_attempt(IP + 账号双维度限速落表,多进程/重启一致);另补 audit_event 的 actor/action 查询索引。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +${entityTable( + "admin_user", + ` + singleton boolean not null default true check (singleton), + username text, + password_hash text, + setup_token_hash text, + password_changed_at timestamptz, + totp_secret_ref uuid references secret (id) on delete set null, + totp_pending_secret_ref uuid references secret (id) on delete set null, + totp_enabled_at timestamptz, + totp_last_counter bigint, + constraint admin_user_singleton_key unique (singleton), + constraint admin_user_initialized_check check ((username is null) = (password_hash is null))`, +)} + +${entityTable( + "admin_session", + ` + admin_user_id uuid not null references admin_user (id) on delete cascade, + session_hash text not null, + ip text, + user_agent text, + last_seen_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz, + revoke_reason text, + constraint admin_session_hash_key unique (session_hash)`, +)} +create index admin_session_active_idx on admin_session (admin_user_id) where revoked_at is null; + +create table login_attempt ( + id bigserial primary key, + at timestamptz not null default now(), + ip text not null, + username text not null, + success boolean not null, + reason text +); +create index login_attempt_ip_idx on login_attempt (ip, at) where not success; +create index login_attempt_username_idx on login_attempt (username, at) where not success; + +create index audit_event_actor_idx on audit_event (actor_type, actor_id, at); +create index audit_event_action_idx on audit_event (action, at); +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + ` +drop index if exists audit_event_action_idx; +drop index if exists audit_event_actor_idx; +${dropTables(["login_attempt", "admin_session", "admin_user"])} +`, + ); +} diff --git a/packages/db/migrations/0016_sse_event.ts b/packages/db/migrations/0016_sse_event.ts new file mode 100644 index 00000000..91cf2bc9 --- /dev/null +++ b/packages/db/migrations/0016_sse_event.ts @@ -0,0 +1,26 @@ +import type { Kysely } from "kysely"; +import { dropTables, execDdl } from "../src/migration-helpers.ts"; + +// SSE 多进程总线(W2-A):事件先落 sse_event(seq 全局单调,发布时持咨询锁保证提交顺序 = seq 顺序), +// 再 pg_notify('umt_events', seq);各进程 LISTEN 后按 seq 补读并推给本进程的 SSE 连接。 +// 只保留最近 N 条供 ?after= 补读,由发布方定期裁剪。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +create table sse_event ( + seq bigserial primary key, + type text not null, + at timestamptz not null default now(), + payload jsonb not null default 'null'::jsonb +); +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, dropTables(["sse_event"])); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 0856d6a5..2abb363d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -234,6 +234,35 @@ export interface AcquisitionSlotTable { reserved_plan_id: string | null; } +export interface AdminSessionTable { + id: Generated; + admin_user_id: string; + session_hash: string; + ip: string | null; + user_agent: string | null; + last_seen_at: GeneratedTimestamptz; + expires_at: Timestamptz; + revoked_at: Timestamptz | null; + revoke_reason: string | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + +export interface AdminUserTable { + id: Generated; + singleton: Generated; + username: string | null; + password_hash: string | null; + setup_token_hash: string | null; + password_changed_at: Timestamptz | null; + totp_secret_ref: string | null; + totp_pending_secret_ref: string | null; + totp_enabled_at: Timestamptz | null; + totp_last_counter: number | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface AgentRunTable { id: Generated; task_id: string | null; @@ -576,6 +605,15 @@ export interface LibraryRootTable { updated_at: GeneratedTimestamptz; } +export interface LoginAttemptTable { + id: Generated; + at: GeneratedTimestamptz; + ip: string; + username: string; + success: boolean; + reason: string | null; +} + export interface MediaAssetTable { id: Generated; media_entity_id: string | null; @@ -923,6 +961,13 @@ export interface SourceReleaseTable { updated_at: GeneratedTimestamptz; } +export interface SseEventTable { + seq: Generated; + type: string; + at: GeneratedTimestamptz; + payload: Generated; +} + export interface SubscriptionTable { id: Generated; media_entity_id: string | null; @@ -1019,6 +1064,8 @@ export interface TitleAliasTable { export interface DB { acquisition_plan: AcquisitionPlanTable; acquisition_slot: AcquisitionSlotTable; + admin_session: AdminSessionTable; + admin_user: AdminUserTable; agent_run: AgentRunTable; agent_run_event: AgentRunEventTable; api_key: ApiKeyTable; @@ -1037,6 +1084,7 @@ export interface DB { feed_source: FeedSourceTable; identity_revision: IdentityRevisionTable; library_root: LibraryRootTable; + login_attempt: LoginAttemptTable; media_asset: MediaAssetTable; media_entity: MediaEntityTable; media_server_instance: MediaServerInstanceTable; @@ -1056,6 +1104,7 @@ export interface DB { site: SiteTable; site_account: SiteAccountTable; source_release: SourceReleaseTable; + sse_event: SseEventTable; subscription: SubscriptionTable; task: TaskTable; tasks_fact: TasksFactTable; @@ -1070,6 +1119,12 @@ export type AcquisitionPlanUpdate = Updateable; export type AcquisitionSlotRow = Selectable; export type NewAcquisitionSlot = Insertable; export type AcquisitionSlotUpdate = Updateable; +export type AdminSessionRow = Selectable; +export type NewAdminSession = Insertable; +export type AdminSessionUpdate = Updateable; +export type AdminUserRow = Selectable; +export type NewAdminUser = Insertable; +export type AdminUserUpdate = Updateable; export type AgentRunRow = Selectable; export type NewAgentRun = Insertable; export type AgentRunUpdate = Updateable; @@ -1124,6 +1179,9 @@ export type IdentityRevisionUpdate = Updateable; export type LibraryRootRow = Selectable; export type NewLibraryRoot = Insertable; export type LibraryRootUpdate = Updateable; +export type LoginAttemptRow = Selectable; +export type NewLoginAttempt = Insertable; +export type LoginAttemptUpdate = Updateable; export type MediaAssetRow = Selectable; export type NewMediaAsset = Insertable; export type MediaAssetUpdate = Updateable; @@ -1181,6 +1239,9 @@ export type SiteAccountUpdate = Updateable; export type SourceReleaseRow = Selectable; export type NewSourceRelease = Insertable; export type SourceReleaseUpdate = Updateable; +export type SseEventRow = Selectable; +export type NewSseEvent = Insertable; +export type SseEventUpdate = Updateable; export type SubscriptionRow = Selectable; export type NewSubscription = Insertable; export type SubscriptionUpdate = Updateable; diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index c3c06f92..c876f187 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -68,6 +68,11 @@ const EXPECTED_TABLES = [ "tasks_fact", "tasks_op_ledger", "agent_run_event", + // 登录门禁(0015)与 SSE 多进程总线(0016) + "admin_user", + "admin_session", + "login_attempt", + "sse_event", ].sort(); /** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */ @@ -79,6 +84,8 @@ const NON_ENTITY_TABLES = [ "tasks_fact", "tasks_op_ledger", "agent_run_event", + "login_attempt", + "sse_event", ]; describe.skipIf(skip)("schema migrations (real PG)", () => {