Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting

Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 07:28:01 +00:00
parent 7f37f111e8
commit df05ea5358
22 changed files with 822 additions and 20 deletions
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { deleteCustomerAccount, getSessionCustomer, loginCustomer, clearSessionCookie } from "../../../lib/customerAuth";
// GDPR self-service deletion. Password re-verified here (not just trusting
// the active session) before anything is deleted — same reasoning as
// changeCustomerPassword. See customerAuth.ts's deleteCustomerAccount for
// what actually survives (past orders, anonymized-by-omission — their own
// snapshot fields aren't touched, only the account/login disappears).
export async function POST(request: Request) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const password = typeof body?.password === "string" ? body.password : "";
if (!password) return NextResponse.json({ ok: false, reason: "Bitte dein Passwort zur Bestätigung eingeben." }, { status: 400 });
const verify = await loginCustomer({ email: session.customer.email, password });
if (!verify.ok) return NextResponse.json({ ok: false, reason: "Passwort ist falsch." }, { status: 400 });
const deleted = await deleteCustomerAccount(verify.token, session.customer.id);
if (!deleted) return NextResponse.json({ ok: false, reason: "Konto konnte nicht gelöscht werden." }, { status: 500 });
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerProfile, getCustomerOrders, getCustomerOrderDetail } from "../../../lib/customerAuth";
// GDPR data portability (Art. 20) — a full, structured, machine-readable
// export of everything tied to the account: profile + every order's full
// detail (not just the summary list, so this is a genuinely complete
// export, not a teaser).
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const profile = await getCustomerProfile(session.token);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
const orders = await Promise.all(
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
);
const payload = {
exportedAt: new Date().toISOString(),
profile,
orders: orders.filter(Boolean),
};
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": 'attachment; filename="meine-daten.json"',
},
});
}
+9
View File
@@ -1,7 +1,16 @@
import { NextResponse } from "next/server";
import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
// Complements Payload's own per-account lockout (5 attempts / 10min,
// see Customers.ts in the Payload repo) with a per-IP layer — that one
// alone doesn't stop someone spraying single attempts across many
// different email addresses from the same IP.
if (!checkRateLimit(`login:${getClientIp(request)}`, { limit: 10, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email : "";
const password = typeof body?.password === "string" ? body.password : "";
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import {
getSessionCustomer,
getCustomerOrderDetail,
requestOrderStatusChange,
customerOrderAction,
} from "../../../../lib/customerAuth";
// The real security boundary is Orders.ts's beforeChange hook in Payload
// (only `status` can change, only via an allowed transition) — the check
// against customerOrderAction() here is just for a friendlier error
// message than a bare 403 when the button's already stale (e.g. two tabs
// open, order shipped in the meantime).
export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const { orderNumber } = await params;
const body = await request.json().catch(() => null);
const action = body?.action;
if (action !== "cancel" && action !== "request-return") {
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
}
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
if (customerOrderAction(order.status) !== action) {
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
}
const result = await requestOrderStatusChange(session.token, order.id, action);
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+5
View File
@@ -1,7 +1,12 @@
import { NextResponse } from "next/server";
import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
+5
View File
@@ -1,7 +1,12 @@
import { NextResponse } from "next/server";
import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`register:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const body = await request.json().catch(() => null);
const { firstName, lastName, email, password } = body ?? {};
if (
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, resendVerificationEmail } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
export async function POST(request: Request) {
if (!checkRateLimit(`resend-verification:${getClientIp(request)}`, { limit: 3, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
}
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
if (session.customer.emailVerified) return NextResponse.json({ ok: true });
const ok = await resendVerificationEmail(session);
return NextResponse.json({ ok }, { status: ok ? 200 : 500 });
}
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyEmailByToken } from "../../../lib/customerAuth";
// Entered from the link in the verification email — no session exists
// yet at this point. See Customers.ts's own comment on why this is a
// non-blocking flag (login already works before this is ever clicked).
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get("token");
if (!token) return new Response("Ungültiger Link.", { status: 400 });
const ok = await verifyEmailByToken(token);
const url = new URL("/konto/profil", request.url);
url.searchParams.set("verified", ok ? "1" : "0");
return NextResponse.redirect(url);
}
+16 -1
View File
@@ -5,6 +5,7 @@ import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServ
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
import { fetchProductsBySlug } from "../../lib/productsServer";
import { sendCriticalAlert } from "../../lib/alertAdmin";
type CheckoutBody = {
cart: CartItem[];
@@ -137,7 +138,21 @@ export async function POST(request: Request) {
discountAmount,
total,
});
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
if (!order) {
// The worst-case failure in this whole flow: the customer went
// through checkout believing they bought something, and nothing was
// persisted. Kuma's uptime checks can't see this (the site is up,
// this route just returned a 500) — this is the one alert path that
// can.
sendCriticalAlert("Bestellung konnte nicht gespeichert werden", {
customerId: customer.id,
customerEmail: body.email,
cart: body.cart,
total,
timestamp: new Date().toISOString(),
});
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
}
return NextResponse.json({
ok: true,
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
// Deliberately checks Payload connectivity, not just "did this route
// handler run" — the site can return 200s from every static/ISR page
// while Payload itself is unreachable (stale cached content masks it for
// a while). Meant for a Kuma HTTP monitor, added to the existing "Content
// & API" group alongside the direct Payload monitors (see ~/dev/README.md).
export async function GET() {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${PAYLOAD_URL}/api/posts?limit=1`, { signal: controller.signal, cache: "no-store" });
clearTimeout(timeout);
if (!res.ok) return NextResponse.json({ ok: false, payload: false }, { status: 503 });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false, payload: false }, { status: 503 });
}
}