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
+13
-1
@@ -8,6 +8,8 @@ import type { MappingExport } from "./ops.ts";
|
||||
// migrate | preflight | probe
|
||||
// export-mappings --out <file.json> [--compare <基准清单.json>] 映射清单导出(可与基准比行数)
|
||||
// read-only-on [--reason <文本>] 打开 automation.readOnlyReconcile
|
||||
// rotate-master-key 用 env SECRET_MASTER_KEY_NEW 重新加密 secret 表
|
||||
// (逐行事务、幂等可重跑;步骤与回滚见 docs/runbook/auth.md)
|
||||
// fsworker fsproxy 执行子进程(编译二进制里由 fsproxy 以
|
||||
// `<自身> --role fsworker` 拉起,stdin 一条请求)
|
||||
const Roles = [
|
||||
@@ -20,10 +22,11 @@ const Roles = [
|
||||
"probe",
|
||||
"export-mappings",
|
||||
"read-only-on",
|
||||
"rotate-master-key",
|
||||
"fsworker",
|
||||
] as const;
|
||||
type Role = (typeof Roles)[number];
|
||||
const OneShot = ["migrate", "preflight", "export-mappings", "read-only-on"] as const;
|
||||
const OneShot = ["migrate", "preflight", "export-mappings", "read-only-on", "rotate-master-key"] as const;
|
||||
type OneShotRole = (typeof OneShot)[number];
|
||||
const isOneShot = (r: Role): r is OneShotRole => (OneShot as readonly Role[]).includes(r);
|
||||
|
||||
@@ -108,6 +111,15 @@ if (isOneShot(role)) {
|
||||
|
||||
async function runOneShot(r: OneShotRole): Promise<number> {
|
||||
switch (r) {
|
||||
case "rotate-master-key": {
|
||||
if (!config.SECRET_MASTER_KEY_NEW) {
|
||||
logger.error("缺少环境变量 SECRET_MASTER_KEY_NEW(新主密钥);SECRET_MASTER_KEY 保持旧密钥");
|
||||
return 2;
|
||||
}
|
||||
const { rotateMasterKey } = await import("./modules/auth-settings/provided.ts");
|
||||
const res = await rotateMasterKey({ db, logger }, config.SECRET_MASTER_KEY, config.SECRET_MASTER_KEY_NEW);
|
||||
return res.failed.length ? 1 : 0;
|
||||
}
|
||||
case "migrate": {
|
||||
const { results } = await migrateToLatest(db);
|
||||
for (const m of results ?? []) logger.info({ migration: m.migrationName, status: m.status }, "migration");
|
||||
|
||||
Reference in new issue
Block a user