feat(security): require fresh verification before destructive account actions

Account deletion and the keep-account data reset now demand a recent
second-factor re-verification for accounts that have a second factor
active; single-factor accounts are unaffected and keep the typed-
confirmation-only contract. The gate runs through requireFreshMfaIfEnrolled,
which is cookie-only by construction — a Bearer transport for an MFA-enrolled
account cannot satisfy step-up and surfaces the auth.stepup.required envelope
so the UI can launch a re-verification flow.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:11 +02:00
1 parent 6d053c3667
commit 01689f77bc
4 files changed
+166 -4

No files matched your search

+11 -2
View File
@@ -3,7 +3,11 @@ import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
@@ -11,9 +15,14 @@ export const dynamic = "force-dynamic";
/**
* Permanently delete the user account and ALL associated data.
* Cascading deletes in the schema handle all related records.
*
* v1.23 — for an account with a second factor active this requires a fresh
* step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed
* confirmation, so a hijacked live session cannot erase the record. Accounts
* without MFA are unaffected and keep the typed-confirmation-only contract.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
let confirm = "";
try {
+10 -2
View File
@@ -2,7 +2,11 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { invalidateUserData } from "@/lib/cache/invalidate";
@@ -10,9 +14,13 @@ export const dynamic = "force-dynamic";
/**
* Delete all user-owned health/integration data while keeping the account.
*
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
* deletion route); single-factor accounts keep the typed-confirmation-only
* contract.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
let confirm = "";
try {