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

+95 -4
View File
@@ -43,12 +43,24 @@ export type AdminSetupRequest = z.infer<typeof AdminSetupRequest>;
/** 6 位 TOTP 码 */
export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字");
/** 备用恢复码:10 位字母数字(不含易混的 I/O/0/1),展示为 XXXXX-XXXXX;输入时忽略大小写、空格与连字符 */
export const RecoveryCode = z
.string()
.trim()
.min(10)
.max(32)
.regex(/^[A-Za-z0-9\s-]+$/, "恢复码格式不正确");
/** 启用 TOTP 时一次性生成的恢复码个数 */
export const RECOVERY_CODE_COUNT = 8;
/** POST /auth/login */
export const LoginRequest = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(256),
/** 开启 TOTP 后第二步提交 */
totpCode: TotpCode.optional(),
/** 开启 TOTP 后第二步也可以用一次性恢复码代替验证码(用过即作废) */
recoveryCode: RecoveryCode.optional(),
});
export type LoginRequest = z.infer<typeof LoginRequest>;
@@ -60,6 +72,8 @@ export const SessionInfo = z.object({
/** 仅 cookie 会话有值 */
expiresAt: Timestamp.nullable(),
totpEnabled: z.boolean(),
/** 已登记至少一把 Passkey(登录页据此显示"用 Passkey 登录") */
passkeyEnabled: z.boolean(),
});
export type SessionInfo = z.infer<typeof SessionInfo>;
@@ -83,16 +97,40 @@ export const PasswordChangeResult = z.object({ revokedSessions: z.number().int()
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
/** GET /auth/totp */
export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean() });
export const TotpStatus = z.object({
enabled: z.boolean(),
pending: z.boolean(),
/** 未用过的恢复码个数(未开启时为 0) */
recoveryCodesRemaining: z.number().int().nonnegative(),
});
export type TotpStatus = z.infer<typeof TotpStatus>;
/**
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认;
* disable 需要当前密码 + 验证码
* GET /auth/totp/setup:待确认的 TOTP 密钥(没有就生成一个;已有未确认的原样返回,刷新页面不作废)。
* 前端把 otpauthUri 渲染成二维码;已开启时 409。
*/
export const TotpSetup = z.object({
otpauthUri: z.string(),
/** base32 密钥(无法扫码时手动输入) */
secret: z.string(),
issuer: z.string(),
account: z.string(),
});
export type TotpSetup = z.infer<typeof TotpSetup>;
/**
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认(同时生成恢复码);
* disable 需要当前密码 + 验证码(或恢复码);recovery_codes 用当前密码重新生成恢复码(旧的全部作废)
*/
export const TotpAction = z.discriminatedUnion("action", [
z.object({ action: z.literal("begin") }),
z.object({ action: z.literal("enable"), code: TotpCode }),
z.object({ action: z.literal("disable"), password: z.string().min(1).max(256), code: TotpCode }),
z.object({
action: z.literal("disable"),
password: z.string().min(1).max(256),
code: z.union([TotpCode, RecoveryCode]),
}),
z.object({ action: z.literal("recovery_codes"), password: z.string().min(1).max(256) }),
]);
export type TotpAction = z.infer<typeof TotpAction>;
export const TotpActionResult = z.object({
@@ -100,9 +138,55 @@ export const TotpActionResult = z.object({
/** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */
otpauthUri: z.string().optional(),
secret: z.string().optional(),
/** 仅 enable / recovery_codes 返回:新生成的恢复码明文(只显示这一次,库里只存哈希) */
recoveryCodes: z.array(z.string()).optional(),
});
export type TotpActionResult = z.infer<typeof TotpActionResult>;
// ── Passkey(WebAuthn,替代密码的第一因子;登录成功直接建会话,不再要求 TOTP) ──
export const PasskeyName = z.string().trim().min(1, "输入名称").max(60);
export const PasskeyInfo = z.object({
id: z.string(),
name: z.string(),
/** singleDevice / multiDevice(可同步) */
deviceType: z.string().nullable(),
backedUp: z.boolean(),
createdAt: Timestamp,
lastUsedAt: Timestamp.nullable(),
});
export type PasskeyInfo = z.infer<typeof PasskeyInfo>;
export const PasskeyList = z.object({ items: z.array(PasskeyInfo) });
export type PasskeyList = z.infer<typeof PasskeyList>;
/**
* 注册 / 登录挑战:options 原样交给 @simplewebauthn/browser(startRegistration / startAuthentication),
* challengeId 随校验请求带回(挑战存库、一次性、5 分钟有效)。
*/
export const PasskeyChallenge = z.object({
challengeId: z.string(),
/** WebAuthn 选项 JSON 由 @simplewebauthn 定义,合同里不重复建模 */
options: z.record(z.string(), z.unknown()),
});
export type PasskeyChallenge = z.infer<typeof PasskeyChallenge>;
const WebAuthnResponse = z.record(z.string(), z.unknown());
export const PasskeyRegisterRequest = z.object({
challengeId: z.string().uuid(),
name: PasskeyName,
/** startRegistration 的返回值(RegistrationResponseJSON) */
response: WebAuthnResponse,
});
export type PasskeyRegisterRequest = z.infer<typeof PasskeyRegisterRequest>;
export const PasskeyLoginRequest = z.object({
challengeId: z.string().uuid(),
/** startAuthentication 的返回值(AuthenticationResponseJSON) */
response: WebAuthnResponse,
});
export type PasskeyLoginRequest = z.infer<typeof PasskeyLoginRequest>;
export const PasskeyRenameRequest = z.object({ name: PasskeyName });
export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
/**
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
* 新增动作只在这里加。
@@ -114,6 +198,12 @@ export const AuditAction = z.enum([
"admin.password_change",
"admin.totp_enable",
"admin.totp_disable",
"admin.recovery_codes_regenerate",
"admin.recovery_code_login",
"admin.passkey_register",
"admin.passkey_rename",
"admin.passkey_remove",
"admin.passkey_login",
"api_key.create",
"api_key.update",
"api_key.revoke",
@@ -121,6 +211,7 @@ export const AuditAction = z.enum([
"secret.create",
"secret.rotate",
"secret.delete",
"secret.master_key_rotate",
"library_root.create",
"library_root.update",
"library_root.delete",