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:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user