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