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:
@@ -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 });
|
||||
}
|
||||
@@ -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"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user