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

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

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

No files matched your search

+149 -22
View File
@@ -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<ApiKeyRecord | null>;
touch(id: string): Promise<void>;
/** 按 sha256(token) 查会话(未过滤过期/吊销,由中间件判断) */
findSession?(hash: string): Promise<SessionRecord | null>;
/** 节流更新 last_seen_at */
touchSession?(id: string): Promise<void>;
}
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<DB>): ApiKeyStore {
return {
@@ -39,6 +63,26 @@ export function kyselyApiKeyStore(db: Kysely<DB>): 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<AuthEnv> {
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 <key>`:API Key(开发模式另认 DEV_ADMIN_KEY);
* - 否则 cookie `umt_session`:管理员会话(未过期、未吊销),写方法还要校验 `x-csrf-token` == HMAC 派生值;
* - PUBLIC_AUTH_PATHS 免鉴权(有有效会话时照样挂 actor)。
*/
export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
const setActor = (
c: Parameters<MiddlewareHandler<AuthEnv>>[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 <key>");
}
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<AuthEnv> {
return async (c, next) => {
const p = c.get("principal");
@@ -97,3 +214,13 @@ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
return next();
};
}
/** 只允许控制台管理员会话(或开发模式 DEV_ADMIN_KEY),拒绝外部 API Key —— 用于改密、TOTP 等账号操作 */
export function requireAdminSession(): MiddlewareHandler<AuthEnv> {
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();
};
}