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, }; }