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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user