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

+253 -12
View File
@@ -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<DB>; masterKey: string }): SecretStore {
const notYet = async (): Promise<never> => {
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<DB>; logger: Logger }): SettingsReader {
const all = async (): Promise<GlobalSettings> => {
export interface SecretMeta {
id: string;
name: string;
purpose: SecretPurpose;
algorithm: string;
kdf: string;
kdfParams: Record<string, unknown>;
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<void>;
meta(secretRef: string): Promise<SecretMeta | null>;
}
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<DB>; 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<string, unknown>,
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<GlobalSettings>;
}
/**
* settings 表(key → jsonb)按分组合并缺省值后校验;表里值非法时回退缺省并记警告。
* 结果缓存 ttlMs(默认 30s);收到 settings.changed 事件(经 PG 总线,跨进程)立即失效。
*/
export function createSettingsReader(deps: {
db: Kysely<DB>;
logger: Logger;
events?: EventBusPort;
ttlMs?: number;
}): CachedSettingsReader {
const ttl = deps.ttlMs ?? 30_000;
let cache: { at: number; value: Promise<GlobalSettings> } | null = null;
const listeners = new Set<(keys: SettingsKey[]) => void>();
const load = async (): Promise<GlobalSettings> => {
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<string, unknown> = {};
@@ -29,5 +230,45 @@ export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }):
}
return out as GlobalSettings;
};
return { all, get: async (key) => (await all())[key] };
const all = (): Promise<GlobalSettings> => {
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);
},
};
}