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

@@ -6,20 +6,36 @@ import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import {
adminActor,
assertNotRateLimited,
type CreatedSession,
changePassword,
createSession,
loadAdmin,
recordAttempt,
revokeSession,
sessionInfo,
setupAdmin,
totpBegin,
totpDisable,
totpEnable,
totpRegenerateRecoveryCodes,
totpSetup,
totpStatus,
verifyLogin,
} from "./auth-service.ts";
import { audit, type Def, endpoint } from "./endpoint.ts";
import {
authenticationOptions,
deletePasskey,
listPasskeys,
type RpInfo,
registrationOptions,
renamePasskey,
resolveRp,
toPasskeyInfo,
verifyAuthentication,
verifyRegistration,
} from "./passkey.ts";
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
@@ -36,8 +52,19 @@ const D = {
password: { ...Api.auth.password, tags },
totpGet: { ...Api.auth.totpStatus, tags },
totpPost: { ...Api.auth.totp, tags },
totpSetup: { ...Api.auth.totpSetup, tags },
passkeys: { ...Api.auth.passkeys, tags },
passkeyRegisterOptions: { ...Api.auth.passkeyRegisterOptions, tags },
passkeyRegister: { ...Api.auth.passkeyRegister, tags },
passkeyRename: { ...Api.auth.passkeyRename, tags },
passkeyDelete: { ...Api.auth.passkeyDelete, tags },
passkeyLoginOptions: { ...Api.auth.passkeyLoginOptions, tags },
passkeyLogin: { ...Api.auth.passkeyLogin, tags },
} satisfies Record<string, Def>;
/** Passkey 登录失败 / 限速计数用的账号维度键(免输入用户名) */
const PASSKEY_ACCOUNT = "(passkey)";
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
/**
@@ -57,6 +84,13 @@ export function clientIp(c: Context<AuthEnv>): string {
return xri || xff || remote || "unknown";
}
/** 请求主机(反代后面才信 X-Forwarded-Host),Passkey 未配置 RP ID 时用它 */
function requestHost(c: Context<AuthEnv>): string | null {
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
const trusted = !remote || PRIVATE_IP.test(remote);
return (trusted ? c.req.header("x-forwarded-host") : undefined) ?? c.req.header("host") ?? new URL(c.req.url).host;
}
function isHttps(c: Context<AuthEnv>): boolean {
if (new URL(c.req.url).protocol === "https:") return true;
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
@@ -106,6 +140,10 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
const s = await startSession(c, r.admin.id);
const actor = adminActor(r.admin);
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
if (r.secondFactor === "recovery_code") {
const left = (await totpStatus(ctx.db, r.admin)).recoveryCodesRemaining;
await audit(ctx, actor, "admin.recovery_code_login", { type: "admin_user", id: r.admin.id }, { remaining: left });
}
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
});
@@ -146,22 +184,103 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
return { revokedSessions: revoked };
});
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
endpoint(app, D.totpGet, async () => totpStatus(ctx.db, await loadAdmin(ctx.db)));
endpoint(app, D.totpPost, async ({ body, actor }) => {
const initializedAdmin = async () => {
const admin = await loadAdmin(ctx.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
return admin;
};
endpoint(app, D.totpSetup, async () => {
const r = await totpSetup(deps(), await initializedAdmin());
return { otpauthUri: r.uri, secret: r.secret, issuer: r.issuer, account: r.account };
});
endpoint(app, D.totpPost, async ({ body, actor }) => {
const admin = await initializedAdmin();
const status = async () => totpStatus(ctx.db, await loadAdmin(ctx.db));
if (body.action === "begin") {
const r = await totpBegin(deps(), admin);
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
return { status: await status(), otpauthUri: r.uri, secret: r.secret };
}
if (body.action === "enable") {
await totpEnable(deps(), admin, body.code);
const codes = await totpEnable(deps(), admin, body.code);
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
} else {
await totpDisable(deps(), admin, body);
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
return { status: await status(), recoveryCodes: codes };
}
return { status: totpStatus(await loadAdmin(ctx.db)) };
if (body.action === "recovery_codes") {
const codes = await totpRegenerateRecoveryCodes(deps(), admin, body);
await audit(ctx, actor, "admin.recovery_codes_regenerate", { type: "admin_user", id: admin.id });
return { status: await status(), recoveryCodes: codes };
}
await totpDisable(deps(), admin, body);
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
return { status: await status() };
});
// ── Passkey ──
const rp = (c: Context<AuthEnv>): RpInfo => resolveRp(ctx.config ?? {}, requestHost(c));
endpoint(app, D.passkeys, async ({ c }) => {
const session = c.get("session");
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
return { items: (await listPasskeys(ctx.db, session.adminUserId)).map(toPasskeyInfo) };
});
endpoint(app, D.passkeyRegisterOptions, async ({ c }) =>
registrationOptions(ctx.db, await initializedAdmin(), rp(c)),
);
endpoint(
app,
D.passkeyRegister,
async ({ body, c, actor }) => {
const admin = await initializedAdmin();
const row = await verifyRegistration(ctx.db, admin, rp(c), body);
await audit(ctx, actor, "admin.passkey_register", { type: "admin_passkey", id: row.id }, { name: row.name });
return toPasskeyInfo(row);
},
{ status: 201 },
);
endpoint(app, D.passkeyRename, async ({ params, body, actor }) => {
const admin = await initializedAdmin();
const row = await renamePasskey(ctx.db, admin.id, params.id, body.name);
await audit(ctx, actor, "admin.passkey_rename", { type: "admin_passkey", id: row.id }, { name: row.name });
return toPasskeyInfo(row);
});
endpoint(app, D.passkeyDelete, async ({ params, actor }) => {
const admin = await initializedAdmin();
const row = await deletePasskey(ctx.db, admin.id, params.id);
await audit(ctx, actor, "admin.passkey_remove", { type: "admin_passkey", id: row.id }, { name: row.name });
return { ok: true as const };
});
endpoint(app, D.passkeyLoginOptions, async ({ c }) => {
await assertNotRateLimited(ctx.db, clientIp(c), PASSKEY_ACCOUNT);
return authenticationOptions(ctx.db, await initializedAdmin(), rp(c));
});
// 与密码登录共用限速(IP + "(passkey)" 账号维度)与会话;Passkey 本身是强第一因子,不再要求 TOTP
endpoint(app, D.passkeyLogin, async ({ body, c }) => {
const ip = clientIp(c);
await assertNotRateLimited(ctx.db, ip, PASSKEY_ACCOUNT);
const admin = await initializedAdmin();
let key: Awaited<ReturnType<typeof verifyAuthentication>>;
try {
key = await verifyAuthentication(ctx.db, admin, rp(c), body);
} catch (err) {
if (err instanceof ApiException && (err.code === "unauthorized" || err.code === "bad_request"))
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: false, reason: "bad_passkey" });
throw err;
}
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: true });
const s = await startSession(c, admin.id);
const actor = adminActor(admin);
await audit(ctx, actor, "admin.passkey_login", { type: "admin_session", id: s.id }, { ip, passkeyId: key.id });
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
});
}