feat(security): add a TOTP and security-key setup hub

Introduce a dedicated /settings/security section that gathers every
account-protection control in one place: TOTP enrolment (QR plus manual
secret, confirm, recovery codes shown once with copy and download),
WebAuthn security keys as a second factor (register, list with last-used,
rename, step-up-gated removal), recovery-code count and regeneration, and
primary passkey management.

The second-factor WebAuthn path keeps its own credential store, separate
from primary passkeys, and uses non-resident keys so an account can hold
many. Relying-party resolution is shared with the passkey path. A status
endpoint backs the hub; the step-up gate now recognises a registered
security key as an active second factor so a WebAuthn-only account can
still reach the gated actions.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:40:36 +02:00
1 parent 6d053c3667
commit 402ea90d45
32 files changed
+3246 -401

No files matched your search

+8 -1
View File
@@ -539,8 +539,15 @@ export async function requireFreshMfa(
// The user must actually have a second factor active. A single-factor
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
// unreachable for it by design (the management UI gates enrolment first).
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
if (!sessionData.user.totpConfirmedAt) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: sessionData.user.id },
});
if (webauthnKeyCount === 0) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
}
// Read the freshness stamp off the live session row — `getSession`'s