Files
UHD_Media_Tool/apps/server/src/modules/auth-settings/auth-service.ts
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:34:51 +08:00

466 lines
18 KiB
TypeScript

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<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", "初始化口令无效。使用服务端日志里最新打印的那个");
}
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<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", "用户名或密码错误");
}
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<DB>, adminUserId: string): Promise<string[]> {
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<DB>, adminUserId: string, code: string): Promise<boolean> {
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<DB>, adminUserId: string): Promise<number> {
const r = await db
.selectFrom("admin_recovery_code")
.select(sql<number>`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<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 async function totpStatus(db: Kysely<DB>, admin: AdminRow | null): Promise<TotpStatus> {
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<TotpSetupInfo> {
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<TotpSetupInfo> {
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<string[]> {
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<string[]> {
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<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", "密码错误");
}
// 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<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,
passkeyEnabled: admin?.password_hash
? !!(await db.selectFrom("admin_passkey").select("id").where("admin_user_id", "=", admin.id).executeTakeFirst())
: false,
};
}