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

+40
View File
@@ -0,0 +1,40 @@
/**
* GET /api/auth/me/mfa
*
* Second-factor status for the security settings hub: whether TOTP is active,
* how many recovery codes remain, and the registered WebAuthn security keys.
* Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface.
*
* Returns only metadata: no secret, no recovery-code plaintext, no public key.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireCookieAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
countRemainingRecoveryCodes(user.id),
prisma.webauthnMfaCredential.findMany({
where: { userId: user.id },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
orderBy: { createdAt: "desc" },
}),
]);
annotate({ action: { name: "auth.mfa.status" } });
return apiSuccess({
totp: { enabled: Boolean(user.totpConfirmedAt) },
recoveryCodesRemaining,
webauthn,
// The security hub also drives the passkey-upgrade nudge from this one
// status read, so it knows whether the user has already dismissed it.
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
});
});
@@ -0,0 +1,95 @@
/**
* PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key.
* DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated).
*
* Rename is cookie-authenticated (non-destructive). Removal is a sensitive
* action: it is gated on a fresh second-factor step-up (`requireFreshMfa`,
* cookie-only — a Bearer token can never satisfy it).
*/
import { NextRequest } from "next/server";
import {
apiHandler,
requireCookieAuth,
requireFreshMfa,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { mfaWebauthnRenameSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const PATCH = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireCookieAuth();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const existing = await prisma.webauthnMfaCredential.findUnique({
where: { id },
select: { userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Security key not found", 404);
}
const updated = await prisma.webauthnMfaCredential.update({
where: { id },
data: { name: parsed.data.name },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
});
annotate({ action: { name: "auth.mfa.webauthn.rename" } });
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
// session is not freshly second-factor-verified.
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
const { id } = await params;
const existing = await prisma.webauthnMfaCredential.findUnique({
where: { id },
select: { userId: true, name: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Security key not found", 404);
}
await prisma.webauthnMfaCredential.delete({ where: { id } });
await auditLog("auth.mfa.webauthn.remove", {
userId: user.id,
ipAddress: getClientIp(request),
details: { credentialId: id, name: existing.name },
});
annotate({ action: { name: "auth.mfa.webauthn.remove" } });
return apiSuccess({ success: true });
},
);
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { webauthnMfaCredential: { create: vi.fn() } },
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/mfa/webauthn", () => ({
createMfaRegistrationOptions: vi.fn(),
verifyMfaRegistration: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { POST as REGISTER_OPTIONS } from "../options/route";
import { POST as REGISTER_VERIFY } from "../verify/route";
import { requireCookieAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import {
createMfaRegistrationOptions,
verifyMfaRegistration,
} from "@/lib/auth/mfa/webauthn";
const USER = { id: "user-1", username: "u", email: "u@example.com" };
function verifyRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest(
"http://localhost/api/auth/me/mfa/webauthn/register/verify",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
it("returns ceremony options + challenge id", async () => {
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
options: { challenge: "abc" },
challengeId: "ch-1",
} as never);
const res = await REGISTER_OPTIONS();
const body = (await res.json()) as {
data: { options: unknown; challengeId: string };
};
expect(res.status).toBe(200);
expect(body.data.challengeId).toBe("ch-1");
expect(createMfaRegistrationOptions).toHaveBeenCalledWith(
"user-1",
"u@example.com",
);
});
});
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
const credential = {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: { transports: ["usb"] },
};
it("stores the credential when verification succeeds", async () => {
vi.mocked(verifyMfaRegistration).mockResolvedValue({
verified: true,
registrationInfo: {
credential: {
id: "cred-1",
publicKey: new Uint8Array([1, 2, 3]),
counter: 0,
},
},
} as never);
vi.mocked(prisma.webauthnMfaCredential.create).mockResolvedValue({
id: "row-1",
name: "My key",
createdAt: new Date(),
lastUsedAt: null,
} as never);
const res = await REGISTER_VERIFY(
verifyRequest({ challengeId: "ch-1", credential, name: "My key" }),
);
const body = (await res.json()) as { data: { id: string; name: string } };
expect(res.status).toBe(200);
expect(body.data.id).toBe("row-1");
expect(verifyMfaRegistration).toHaveBeenCalledWith(
"ch-1",
"user-1",
credential,
);
const createArgs = vi.mocked(prisma.webauthnMfaCredential.create).mock
.calls[0][0];
expect(createArgs.data.userId).toBe("user-1");
expect(createArgs.data.credentialId).toBe("cred-1");
expect(createArgs.data.name).toBe("My key");
});
it("returns 400 when verification fails", async () => {
vi.mocked(verifyMfaRegistration).mockResolvedValue({
verified: false,
} as never);
const res = await REGISTER_VERIFY(
verifyRequest({ challengeId: "ch-1", credential }),
);
expect(res.status).toBe(400);
expect(prisma.webauthnMfaCredential.create).not.toHaveBeenCalled();
});
it("returns 422 for a malformed body", async () => {
const res = await REGISTER_VERIFY(verifyRequest({ challengeId: "ch-1" }));
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,40 @@
/**
* POST /api/auth/me/mfa/webauthn/register/options
*
* Begin registering a WebAuthn security key as a second factor. Cookie-only —
* an API token can never enrol MFA. Returns the SimpleWebAuthn creation
* options + the server-issued challenge id to present back at /register/verify.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireCookieAuth();
const rl = await checkRateLimit(
`mfa:webauthn:register:${user.id}`,
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
const { options, challengeId } = await createMfaRegistrationOptions(
user.id,
user.email ?? user.username,
);
annotate({ action: { name: "auth.mfa.webauthn.register-options" } });
return apiSuccess({ options, challengeId });
});
@@ -0,0 +1,69 @@
/**
* POST /api/auth/me/mfa/webauthn/register/verify
*
* Finish registering a WebAuthn security key as a second factor. Cookie-only.
* Verifies the attestation against the user-bound challenge and stores the
* credential in `WebauthnMfaCredential` (kept separate from primary passkeys).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireCookieAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const { challengeId, credential, name } = parsed.data;
const verification = await verifyMfaRegistration(
challengeId,
user.id,
credential,
);
if (!verification.verified || !verification.registrationInfo) {
return apiError("Security key verification failed", 400);
}
const { registrationInfo } = verification;
const created = await prisma.webauthnMfaCredential.create({
data: {
userId: user.id,
name: name ?? "Security key",
credentialId: registrationInfo.credential.id,
credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey),
counter: BigInt(registrationInfo.credential.counter),
transports:
((credential.response as Record<string, unknown> | undefined)
?.transports as string[]) ?? [],
},
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
});
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.mfa.webauthn.register" } });
return apiSuccess(created);
});