Files
HealthLog/src/app/api/settings/account/route.ts
T
Marc-André Bombeck 01689f77bc 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.
2026-06-27 20:34:11 +02:00

87 lines
3.0 KiB
TypeScript

import { prisma } from "@/lib/db";
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,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
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 requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
let confirm = "";
try {
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE_ACCOUNT") {
return apiError(
"Confirmation missing. Send { confirm: 'DELETE_ACCOUNT' }",
422,
);
}
// Prevent last admin from deleting themselves
if (user.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin account cannot be deleted", 400);
}
}
const userId = user.id;
const username = user.username;
// Log BEFORE deletion. The audit row's userId is SetNull post-cascade
// (per schema), but we then immediately purge it below for GDPR Art. 17
// erasure completeness — see comment further down.
await auditLog("user.account.delete", {
userId,
ipAddress: getClientIp(request),
details: { username },
});
// Destroy all sessions first
await destroyAllSessions(userId);
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
// descriptions, IP addresses, login city geo) attached to the record after
// the user is deleted. We explicitly purge them inside the same logical
// operation so account deletion is genuinely complete erasure.
await prisma.feedback.deleteMany({ where: { userId } });
await prisma.auditLog.deleteMany({ where: { userId } });
// Delete user — all other related data is removed via onDelete: Cascade
await prisma.user.delete({ where: { id: userId } });
annotate({
action: { name: "settings.account.delete" },
meta: { username },
});
return apiSuccess({ deleted: true });
});