import { randomBytes } from "node:crypto"; import { type Actor, RECOVERY_CODE_COUNT, type SessionInfo, type TotpStatus } 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(防重放计数器)+ 一次性恢复码(argon2id 哈希存库)。 // Passkey 见 passkey.ts。 /** 固定会话时长 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", "初始化口令无效。使用服务端日志里最新打印的那个"); } const admin = await loadAdmin(deps.db); if (!admin) throw new Error("admin_user 行丢失"); return admin; } export type LoginOutcome = | { status: "ok"; admin: AdminRow; secondFactor: "none" | "totp" | "recovery_code" } | { status: "totp_required" }; export async function verifyLogin( deps: AuthDeps, input: { username: string; password: string; totpCode?: string | undefined; recoveryCode?: 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", "用户名或密码错误"); } let secondFactor: "none" | "totp" | "recovery_code" = "none"; if (admin.totp_enabled_at && admin.totp_secret_ref) { // 密码正确但缺第二步:不计失败 if (!input.totpCode && !input.recoveryCode) return { status: "totp_required" }; if (input.totpCode) { if (!(await consumeTotp(deps, admin, input.totpCode))) { await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" }); throw new ApiException("unauthorized", "验证码错误"); } secondFactor = "totp"; } else { if (!(await consumeRecoveryCode(deps.db, admin.id, input.recoveryCode ?? ""))) { await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_recovery_code" }); throw new ApiException("unauthorized", "恢复码错误或已用过"); } secondFactor = "recovery_code"; } } await recordAttempt(deps.db, { ip, username: input.username, success: true }); return { status: "ok", admin, secondFactor }; } // ── 恢复码 ── const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /** 恢复码归一化:去空格与连字符、转大写(展示为 XXXXX-XXXXX,输入时怎么写都行) */ export const normalizeRecoveryCode = (code: string) => code.replace(/[\s-]+/g, "").toUpperCase(); function newRecoveryCode(): string { const bytes = randomBytes(10); let out = ""; for (const b of bytes) out += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length]; return `${out.slice(0, 5)}-${out.slice(5)}`; } /** 重新生成恢复码(旧的全部作废);返回明文(只此一次),库里只存 argon2id 哈希 */ export async function regenerateRecoveryCodes(db: Kysely, adminUserId: string): Promise { const codes = Array.from({ length: RECOVERY_CODE_COUNT }, newRecoveryCode); const hashes = await Promise.all(codes.map((c) => hashPassword(normalizeRecoveryCode(c)))); await db.transaction().execute(async (trx) => { await trx.deleteFrom("admin_recovery_code").where("admin_user_id", "=", adminUserId).execute(); await trx .insertInto("admin_recovery_code") .values(hashes.map((h) => ({ admin_user_id: adminUserId, code_hash: h }))) .execute(); }); return codes; } /** 校验并作废一个恢复码(条件更新:并发提交同一个码只有一个成功) */ export async function consumeRecoveryCode(db: Kysely, adminUserId: string, code: string): Promise { const norm = normalizeRecoveryCode(code); if (norm.length < 10) return false; const rows = await db .selectFrom("admin_recovery_code") .select(["id", "code_hash"]) .where("admin_user_id", "=", adminUserId) .where("used_at", "is", null) .execute(); for (const r of rows) { if (!(await verifyPassword(norm, r.code_hash))) continue; const u = await db .updateTable("admin_recovery_code") .set({ used_at: new Date() }) .where("id", "=", r.id) .where("used_at", "is", null) .executeTakeFirst(); return Number(u.numUpdatedRows) > 0; } return false; } async function remainingRecoveryCodes(db: Kysely, adminUserId: string): Promise { const r = await db .selectFrom("admin_recovery_code") .select(sql`count(*)::int`.as("n")) .where("admin_user_id", "=", adminUserId) .where("used_at", "is", null) .executeTakeFirst(); return r?.n ?? 0; } /** 校验并消费 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 async function totpStatus(db: Kysely, admin: AdminRow | null): Promise { const enabled = !!admin?.totp_enabled_at; return { enabled, pending: !!admin?.totp_pending_secret_ref, recoveryCodesRemaining: enabled && admin ? await remainingRecoveryCodes(db, admin.id) : 0, }; } export interface TotpSetupInfo { uri: string; secret: string; issuer: string; account: string; } function otpauthUri(secret: string, account: string): string { const label = encodeURIComponent(`${TOTP_ISSUER}:${account}`); return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`; } export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise { 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 account = admin.username ?? "admin"; return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account }; } /** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */ export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise { if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定"); if (admin.totp_pending_secret_ref) { try { const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref); const account = admin.username ?? "admin"; return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account }; } catch { // 待确认密钥读不出来(被删 / 主密钥不匹配):重新生成 } } return totpBegin(deps, admin); } /** 确认开启;返回新生成的恢复码明文(只此一次) */ 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(() => {}); return await regenerateRecoveryCodes(deps.db, admin.id); } /** 用当前密码重新生成恢复码(TOTP 必须已开启) */ export async function totpRegenerateRecoveryCodes( deps: AuthDeps, admin: AdminRow, input: { password: string }, ): Promise { if (!admin.totp_enabled_at) throw new ApiException("precondition_failed", "TOTP 未启用"); if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) { throw new ApiException("unauthorized", "密码错误"); } return await regenerateRecoveryCodes(deps.db, admin.id); } 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", "密码错误"); } // 6 位数字按验证码校验,其余按恢复码(验证器丢了也能关) const ok = /^\d{6}$/.test(input.code) ? await consumeTotp(deps, admin, input.code) : await consumeRecoveryCode(deps.db, admin.id, input.code); if (!ok) 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.db.deleteFrom("admin_recovery_code").where("admin_user_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, passkeyEnabled: admin?.password_hash ? !!(await db.selectFrom("admin_passkey").select("id").where("admin_user_id", "=", admin.id).executeTakeFirst()) : false, }; }