feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe (PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin dynamisch aus dem Dashboard. - Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt alle Eingaben, Turnstile wird neu geladen - Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe Sitzung erneut eingebettet - Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden Browser gebunden, die session_id in der URL allein reicht nicht - Alte /checkout/cancel?ref= Links aendern per GET nichts mehr - CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert - STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_ Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6ac7cc5d1f
commit
4ee5bafe20
28 files changed
+1272
-179
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
||||
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
||||
|
||||
const releaseCheckout = vi.fn<(token: string) => Promise<void>>(async () => undefined);
|
||||
vi.mock("@/lib/checkout-release", () => ({
|
||||
CANCEL_TOKEN_PATTERN: /^[0-9a-f]{64}$/,
|
||||
releaseCheckout: (token: string) => releaseCheckout(token),
|
||||
}));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
const TOKEN = "c".repeat(64);
|
||||
|
||||
function post(body: unknown): Promise<Response> {
|
||||
return POST(
|
||||
new Request("https://wabenhain.test/api/checkout/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isTrustedOrigin.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/checkout/cancel", () => {
|
||||
test("gibt die Bestellung zum Token frei", async () => {
|
||||
const response = await post({ cancelToken: TOKEN });
|
||||
expect(response.status).toBe(200);
|
||||
expect(releaseCheckout).toHaveBeenCalledWith(TOKEN);
|
||||
});
|
||||
|
||||
test("weist einen fremden Origin ab, ohne etwas freizugeben", async () => {
|
||||
isTrustedOrigin.mockReturnValue(false);
|
||||
const response = await post({ cancelToken: TOKEN });
|
||||
expect(response.status).toBe(403);
|
||||
expect(releaseCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("weist die Bestellnummer als Ersatz fuer den Token ab", async () => {
|
||||
const response = await post({ cancelToken: "WH-2026-AABBCCDD" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(releaseCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("weist einen Body ohne Token ab", async () => {
|
||||
const response = await post(null);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { releaseCheckout, CANCEL_TOKEN_PATTERN } from "@/lib/checkout-release";
|
||||
|
||||
/**
|
||||
* Bricht eine offene, eingebettete Stripe-Zahlung ab und gibt den dafuer
|
||||
* reservierten Bestand sofort frei ("Zurueck zu deinen Angaben" im
|
||||
* Zahlungsschritt, "Zahlung abbrechen" auf der Rueckkehrseite).
|
||||
*
|
||||
* POST statt eines Links: der Aufruf aendert Daten und darf weder von einem
|
||||
* Prefetch noch von einem fremden <img src> ausgeloest werden. Der
|
||||
* Origin-Check schliesst gefaelschte Cross-Site-POSTs aus (siehe lib/csrf.ts).
|
||||
*
|
||||
* Die Antwort ist bei jedem wohlgeformten Token dieselbe — ob es ihn gab und
|
||||
* ob tatsaechlich etwas freigegeben wurde, verraet sie nicht.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const cancelToken =
|
||||
body && typeof body === "object" && "cancelToken" in body
|
||||
? (body as { cancelToken?: unknown }).cancelToken
|
||||
: undefined;
|
||||
|
||||
if (typeof cancelToken !== "string" || !CANCEL_TOKEN_PATTERN.test(cancelToken)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
await releaseCheckout(cancelToken);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -121,7 +121,7 @@ beforeEach(() => {
|
||||
);
|
||||
sessionsCreate.mockResolvedValue({
|
||||
id: "cs_test_1",
|
||||
url: "https://checkout.stripe.com/c/pay/cs_test_1",
|
||||
client_secret: "cs_test_1_secret_abc",
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
@@ -243,15 +243,45 @@ describe("Checkout-Route", () => {
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
url: "https://checkout.stripe.com/c/pay/cs_test_1",
|
||||
});
|
||||
const body = await response.json();
|
||||
expect(body.clientSecret).toBe("cs_test_1_secret_abc");
|
||||
// Der Abbruch-Token ist das, was "Zurueck zu deinen Angaben" braucht —
|
||||
// und derselbe, der an der Bestellung gespeichert wird.
|
||||
expect(body.cancelToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(db.order.create.mock.calls[0][0].data.cancelToken).toBe(body.cancelToken);
|
||||
|
||||
// Derselbe Token als HttpOnly-Cookie: bindet den erneuten Versuch nach
|
||||
// PayPal an genau diesen Browser (siehe lib/checkout-return.ts).
|
||||
const cookie = response.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toContain(`wh_checkout_ref=${body.cancelToken}`);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/SameSite=Lax/i);
|
||||
expect(cookie).toMatch(/Secure/i);
|
||||
expect(cookie).toMatch(/Path=\/checkout/i);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(db.order.update).toHaveBeenCalledWith({
|
||||
where: { id: ORDER.id },
|
||||
data: { stripeCheckoutSessionId: "cs_test_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("bettet die Zahlung ein und schickt jede Zahlungsart auf die serverseitig pruefende Rueckkehrseite", async () => {
|
||||
await post();
|
||||
|
||||
const params = sessionsCreate.mock.calls[0][0];
|
||||
expect(params.ui_mode).toBe("embedded_page");
|
||||
expect(params.redirect_on_completion).toBe("always");
|
||||
// Feste Basis-URL statt Origin-Header, Sitzungs-ID statt Bestellnummer.
|
||||
expect(params.return_url).toBe(
|
||||
"https://wabenhain.test/checkout/success?session_id={CHECKOUT_SESSION_ID}"
|
||||
);
|
||||
// Eingebettete Sitzungen kennen diese beiden nicht — Stripe wiese sie ab.
|
||||
expect(params).not.toHaveProperty("success_url");
|
||||
expect(params).not.toHaveProperty("cancel_url");
|
||||
// Dynamische Zahlungsarten: PayPal & Co. kommen aus dem Dashboard.
|
||||
expect(params).not.toHaveProperty("payment_method_types");
|
||||
});
|
||||
|
||||
test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => {
|
||||
await post();
|
||||
|
||||
@@ -352,7 +382,7 @@ describe("Checkout-Route", () => {
|
||||
test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => {
|
||||
// Sonst entstuende genau der Zustand, den es nicht geben darf: eine
|
||||
// Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei
|
||||
// gefahrlos verfallen — ihre URL steht nur in der Antwort, die wir
|
||||
// gefahrlos verfallen — ihr client_secret steht nur in der Antwort, die wir
|
||||
// daraufhin nicht ausliefern.
|
||||
db.order.update.mockRejectedValueOnce(new Error("connection reset"));
|
||||
releaseUnpaidOrder.mockResolvedValue({
|
||||
@@ -368,5 +398,20 @@ describe("Checkout-Route", () => {
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
|
||||
test("behandelt eine Sitzung ohne client_secret wie eine gescheiterte", async () => {
|
||||
// Ohne client_secret laesst sich das eingebettete Formular nicht
|
||||
// zeigen. Ein 200er ohne Zahlungsweg haette die Ware bis zum Ablauf
|
||||
// der Sitzung festgehalten.
|
||||
sessionsCreate.mockResolvedValue({ id: "cs_test_1", client_secret: null });
|
||||
sessionsExpire.mockResolvedValue({});
|
||||
releaseUnpaidOrder.mockResolvedValue(null);
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@ import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region
|
||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import {
|
||||
CHECKOUT_COOKIE,
|
||||
CHECKOUT_COOKIE_MAX_AGE_SECONDS,
|
||||
CHECKOUT_COOKIE_PATH,
|
||||
} from "@/lib/checkout-release";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { vatRateForProduct } from "@/lib/vat";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -360,11 +365,12 @@ export async function POST(request: Request) {
|
||||
|
||||
const locale = await getLocale();
|
||||
|
||||
// Steht gleich ausschliesslich in der cancel_url der Stripe-Sitzung und
|
||||
// gibt den reservierten Bestand frei, sobald die Kundschaft dort abbricht
|
||||
// (siehe checkout/cancel). 32 Byte, damit er nicht durchprobiert werden
|
||||
// kann — mit einem ratbaren Wert liesse sich die offene Zahlung fremder
|
||||
// Leute abbrechen.
|
||||
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
|
||||
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
|
||||
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
|
||||
// 32 Byte, damit er nicht durchprobiert werden kann — mit einem ratbaren
|
||||
// Wert liesse sich die offene Zahlung fremder Leute abbrechen. Die
|
||||
// Bestellnummer taugt dafuer nicht: sie steht in jeder Bestaetigungsmail.
|
||||
const cancelToken = randomBytes(32).toString("hex");
|
||||
|
||||
// Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
|
||||
@@ -493,7 +499,7 @@ export async function POST(request: Request) {
|
||||
const stripe = getStripeClient();
|
||||
// Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei
|
||||
// waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde
|
||||
// sich sonst in success_url/cancel_url der Stripe-Session einschleusen —
|
||||
// sich sonst in die return_url der Stripe-Session einschleusen —
|
||||
// ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
|
||||
// eigener Checkout-Seite waere hochgradig phishing-tauglich.
|
||||
const origin = getBaseUrl();
|
||||
@@ -514,13 +520,15 @@ export async function POST(request: Request) {
|
||||
|
||||
// Kein payment_method_types (s. README "Zahlungsmethoden"): Stripe zeigt
|
||||
// dadurch dynamisch alle im Dashboard aktivierten, berechtigten
|
||||
// Zahlungsmethoden. Apple Pay/Google Pay sind bei dieser vollstaendig
|
||||
// gehosteten Checkout-Seite (kein ui_mode: "embedded") laut Stripe-Doku
|
||||
// automatisch enthalten, sobald Kartenzahlung aktiv ist — keine
|
||||
// Codeaenderung und (anders als bei Elements/eingebetteter Checkout-Seite)
|
||||
// keine Domain-Registrierung noetig, da checkout.stripe.com bereits
|
||||
// Stripes eigene, verifizierte Domain ist.
|
||||
// Zahlungsmethoden — PayPal eingeschlossen, sobald es dort aktiv ist.
|
||||
//
|
||||
// "embedded_page": das Zahlungsformular erscheint als Stripe-iframe
|
||||
// direkt auf /checkout statt auf checkout.stripe.com. Kartendaten
|
||||
// beruehren diesen Server trotzdem nie (PCI SAQ A bleibt). Apple Pay und
|
||||
// Google Pay braeuchten dafuer eine Domain-Registrierung im Dashboard;
|
||||
// ohne sie erscheinen sie schlicht nicht.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
ui_mode: "embedded_page",
|
||||
mode: "payment",
|
||||
customer_email: input.email,
|
||||
line_items: [
|
||||
@@ -549,14 +557,27 @@ export async function POST(request: Request) {
|
||||
// Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und
|
||||
// haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES).
|
||||
expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60,
|
||||
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
|
||||
// Der Token statt der Bestellnummer: die Bestellnummer steht in jeder
|
||||
// Bestaetigungsmail und auf jeder Rechnung, mit ihr liesse sich also
|
||||
// die noch offene Zahlung einer fremden Person abbrechen.
|
||||
cancel_url: `${origin}/checkout/cancel?ref=${cancelToken}`,
|
||||
// Jede Zahlungsart endet hier — Karte ebenso wie PayPal, das die
|
||||
// Kundschaft zwangslaeufig zu PayPal und zurueck schickt. Ein einziger
|
||||
// Rueckweg statt zweier (onComplete im Browser vs. Weiterleitung) heisst
|
||||
// auch: nur eine Stelle entscheidet ueber "bezahlt", und die fragt
|
||||
// serverseitig bei Stripe nach (siehe checkout/success). Den Platzhalter
|
||||
// ersetzt Stripe selbst; die Bestellnummer steht bewusst nicht in der
|
||||
// URL, sie wird erst nach der Pruefung aus der Datenbank gelesen.
|
||||
return_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
redirect_on_completion: "always",
|
||||
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||
});
|
||||
|
||||
// Ohne client_secret laesst sich das eingebettete Formular nicht
|
||||
// anzeigen — dieselbe Lage wie eine gescheiterte Sitzungserstellung.
|
||||
if (!session.client_secret) {
|
||||
await stripe.checkout.sessions.expire(session.id).catch((expireError: unknown) => {
|
||||
console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError);
|
||||
});
|
||||
throw new Error(`Stripe-Sitzung ${session.id} ohne client_secret.`);
|
||||
}
|
||||
|
||||
// KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
|
||||
// Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
|
||||
// (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der
|
||||
@@ -569,8 +590,9 @@ export async function POST(request: Request) {
|
||||
// Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
|
||||
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
|
||||
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
|
||||
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT
|
||||
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen.
|
||||
// ihr client_secret steht ausschliesslich in der Antwort, die wir
|
||||
// daraufhin NICHT ausliefern — niemand ausser diesem Prozess hat ihn je
|
||||
// gesehen.
|
||||
try {
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
@@ -592,7 +614,24 @@ export async function POST(request: Request) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
// client_secret: oeffnet ausschliesslich das Zahlungsformular dieser einen
|
||||
// Sitzung, sonst nichts. cancelToken: erlaubt "Zurueck zu deinen
|
||||
// Angaben", ohne dass die Ware bis zum Sitzungsablauf blockiert bleibt.
|
||||
// Beides geht nur an die Person, die gerade diese Bestellung angelegt hat.
|
||||
const response = NextResponse.json(
|
||||
{ clientSecret: session.client_secret, cancelToken },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
// Siehe CHECKOUT_COOKIE: nur dieser Browser darf nach einem Umweg ueber
|
||||
// PayPal dieselbe Zahlung erneut oeffnen oder abbrechen.
|
||||
response.cookies.set(CHECKOUT_COOKIE, cancelToken, {
|
||||
httpOnly: true,
|
||||
secure: origin.startsWith("https://"),
|
||||
sameSite: "lax",
|
||||
path: CHECKOUT_COOKIE_PATH,
|
||||
maxAge: CHECKOUT_COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
|
||||
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
|
||||
|
||||
Reference in new issue
Block a user