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

@@ -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<DB>;
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<DB>): Promise<AdminRow | null> {
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<AuthDeps, "db" | "logger">): Promise<string | null> {
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<DB>, col: "ip" | "username", value: string): Promise<number> {
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<DB>, ip: string, username: string): Promise<void> {
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<DB>,
a: { ip: string; username: string; success: boolean; reason?: string },
): Promise<void> {
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<DB>,
adminUserId: string,
meta: { ip: string; userAgent: string | null },
): Promise<CreatedSession> {
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<DB>, id: string, reason: string): Promise<void> {
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<DB>,
adminUserId: string,
keepId: string | null,
reason: string,
): Promise<number> {
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<AdminRow> {
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<LoginOutcome> {
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<boolean> {
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<number> {
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<void> {
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<void> {
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<DB>,
actor: Actor | undefined,
expiresAt: Date | null,
): Promise<SessionInfo> {
const admin = await loadAdmin(db);
return {
initialized: !!admin?.password_hash,
authenticated: !!actor,
actor: actor ?? null,
expiresAt: expiresAt?.toISOString() ?? null,
totpEnabled: !!admin?.totp_enabled_at,
};
}