feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation

- GET /auth/totp/setup returns the pending secret and otpauth URL (created
  on first call, reused on refresh) for the console to render as a QR code.
- Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in
  admin_recovery_code); login accepts recoveryCode in place of totpCode,
  disable accepts either, and recovery_codes regenerates with the password.
- Passkeys via @simplewebauthn/server: register/list/rename/delete under
  the console session, public login options/verify that reuse the login
  rate limit and session cookies. Challenges are one-time rows in
  webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID /
  WEBAUTHN_ORIGINS, falling back to the request host. Signature counter
  regressions are rejected. SessionInfo gains passkeyEnabled.
- --role rotate-master-key re-encrypts the secret table from
  SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction,
  skips rows already on the new key (resumable, swap keys to roll back),
  logs progress and audits secret.master_key_rotate. Secrets now record
  the master key fingerprint in kdf_params.kid.
- Runbook: recovery codes, passkey configuration, rotation steps and
  rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:22:07 +08:00
1 parent 5c6f483420
commit b0e5d539ed
21 files changed
+1616 -80

No files matched your search

@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import type { Actor, SessionInfo } from "@umt/contracts";
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";
@@ -17,7 +17,8 @@ import {
// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md):
// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)+ 一次性恢复码(argon2id 哈希存库)。
// Passkey 见 passkey.ts。
/** 固定会话时长 12 小时,无滑动续期 */
export const SESSION_TTL_MS = 12 * 3600_000;
@@ -197,11 +198,13 @@ export async function setupAdmin(
return admin;
}
export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" };
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 },
input: { username: string; password: string; totpCode?: string | undefined; recoveryCode?: string | undefined },
ip: string,
): Promise<LoginOutcome> {
await assertNotRateLimited(deps.db, ip, input.username);
@@ -216,16 +219,87 @@ export async function verifyLogin(
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) 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", "验证码错误");
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 };
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 计数器(条件更新防并发重放) */
@@ -267,23 +341,55 @@ export async function changePassword(
// ── TOTP 管理 ──
export function totpStatus(admin: AdminRow | null) {
return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref };
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 async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: string; secret: string }> {
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 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 };
const account = admin.username ?? "admin";
return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account };
}
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
/** 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);
@@ -300,6 +406,20 @@ export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string):
.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(
@@ -311,13 +431,18 @@ export async function totpDisable(
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", "验证码错误");
// 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(() => {});
}
@@ -333,5 +458,8 @@ export async function sessionInfo(
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,
};
}