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

+70 -1
View File
@@ -1787,10 +1787,79 @@ export const Api = {
path: "/auth/totp",
scope: "admin",
auth: "session",
summary: "TOTP 绑定/启用/停用",
summary: "TOTP 绑定/启用/停用/重新生成恢复码",
body: auth.TotpAction,
response: auth.TotpActionResult,
}),
totpSetup: ep({
method: "get",
path: "/auth/totp/setup",
scope: "admin",
auth: "session",
summary: "待确认的 TOTP 密钥与 otpauth URL(前端渲染二维码)",
response: auth.TotpSetup,
}),
passkeys: ep({
method: "get",
path: "/auth/passkeys",
scope: "admin",
auth: "session",
summary: "已登记的 Passkey",
response: auth.PasskeyList,
}),
passkeyRegisterOptions: ep({
method: "post",
path: "/auth/passkeys/register/options",
scope: "admin",
auth: "session",
summary: "Passkey 注册挑战",
response: auth.PasskeyChallenge,
}),
passkeyRegister: ep({
method: "post",
path: "/auth/passkeys/register/verify",
scope: "admin",
auth: "session",
summary: "校验并登记 Passkey",
body: auth.PasskeyRegisterRequest,
response: auth.PasskeyInfo,
}),
passkeyRename: ep({
method: "patch",
path: "/auth/passkeys/{id}",
scope: "admin",
auth: "session",
summary: "重命名 Passkey",
params: IdParam,
body: auth.PasskeyRenameRequest,
response: auth.PasskeyInfo,
}),
passkeyDelete: ep({
method: "delete",
path: "/auth/passkeys/{id}",
scope: "admin",
auth: "session",
summary: "删除 Passkey",
params: IdParam,
response: OkResult,
}),
passkeyLoginOptions: ep({
method: "post",
path: "/auth/passkeys/login/options",
scope: "read",
auth: "public",
summary: "Passkey 登录挑战(免鉴权)",
response: auth.PasskeyChallenge,
}),
passkeyLogin: ep({
method: "post",
path: "/auth/passkeys/login/verify",
scope: "read",
auth: "public",
summary: "Passkey 登录(免鉴权;成功即下发会话)",
body: auth.PasskeyLoginRequest,
response: auth.LoginResult,
}),
},
// ── 事件 ──