Files
vans-diy-bastelbedarf/functions/_middleware.js
T
qciganoandClaude Sonnet 5 aff961816b Zweite, tägliche Zugangsschranke nur für die Bestellverwaltung
Auf ausdrücklichen Wunsch: /verwaltung/ + /api/verwaltung/* verlangen jetzt ZUSÄTZLICH zum
normalen 90-Tage-Seitenzugang einen zweiten, eigenen Code, den nur qciga und VanVan kennen —
und der nach 24 Stunden automatisch abläuft (muss also einmal täglich neu eingegeben werden).
Der Rest der Seite ist davon unberührt, nur die Bestellübersicht ist betroffen.

Bewusst als eigenständiges System (eigenes Cookie vandiy_verwaltung_session, eigenes Geheimnis
VERWALTUNG_SESSION_SECRET, eigener HMAC-Signaturcode statt geteiltem mit dem normalen
Seitenzugang) — gleiches Trennungsprinzip wie bei den Kundenkonten: ein Leck bei einer
Sitzungsart darf nie die andere gefährden.

Neue Dateien: functions/_shared/verwaltung-auth.js (Session-Logik), public/verwaltung-code.html
(Eingabemaske im selben Stil wie die Haupt-Zugangsseite). functions/_middleware.js prüft nach
erfolgreicher Haupt-Sitzung zusätzlich diese zweite Sitzung für /verwaltung*-Pfade. Beide
API-Routen (orders.js, orders/[id].js) prüfen die zweite Schranke zusätzlich eigenständig,
nach demselben Verteidigungs-in-der-Tiefe-Prinzip wie die bestehende Haupt-Prüfung.

Lokal vollständig durchgetestet (wrangler pages dev): kompletter Ablauf ohne jeden Zugang ->
Haupt-Gate -> Redirect zum Tages-Code -> falscher Code abgelehnt -> richtiger Code setzt
24-Stunden-Cookie -> Seite und API danach erreichbar. Bestätigt: der Rest der Seite bleibt
unberührt (kein Tages-Code nötig), UND dass VanVan mit nur dem Haupt-Zugang (ohne Tages-Code)
ebenfalls noch ausgesperrt bleibt, wie gewünscht.

Zwei neue Cloudflare-Secrets in Produktion gesetzt: VERWALTUNG_SESSION_SECRET (zufällig
erzeugt) und VERWALTUNG_ACCESS_CODE (der eigentliche Tages-Code, an qciga und VanVan zu
verteilen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 11:45:31 +02:00

252 lines
12 KiB
JavaScript

/* =====================================================================
functions/_middleware.js — Zugangs-Schranke vor der ganzen Website.
Van's DIY & Bastelbedarf ist technisch schon live (Cloudflare Pages),
soll aber noch nicht öffentlich einsehbar sein — nur qcigano und VanVan
sollen jederzeit von überall reinkommen können, ohne dass es eine
Enterprise-Lösung wie Cloudflare Access/Zero Trust braucht (die kostet ab
einer gewissen Nutzerzahl und bräuchte einen separaten Login-Flow, der
VanVan genau die Probleme gemacht hat, die zu diesem Umbau geführt haben).
Gleiches Prinzip wie beim Dogfather-Universe-Projekt, nur als Cloudflare
PAGES FUNCTIONS Middleware statt Workers-Static-Assets (Pages hat kein
env.ASSETS.fetch() — hier übernimmt context.next() dieselbe Rolle: die
eigentliche Seite normal weiter ausliefern).
Ablauf:
- Kein/ungültiges Session-Cookie -> Zugangscode-Seite (/gate.html) statt
der eigentlichen Seite, Zielseite wird als ?next= mitgegeben.
- POST /gate-auth mit korrektem Code -> signiertes, HttpOnly-Cookie
"vandiy_gate_session" (90 Tage) wird gesetzt + ein zweites, NICHT-
HttpOnly Cookie "vandiy_gate_role" (qciga/vanvan) — rein zur Anzeige des
Vorschau-Umschalters im Frontend (siehe Layout.astro), kein
Sicherheits-Token.
- Gültiges Session-Cookie -> normale Auslieferung über context.next().
- /admin/* läuft IMMER unangetastet durch diese Schranke durch — der
Adminbereich hat sein eigenes, echtes Login (GitHub-OAuth über den
cms-auth-Worker) und funktioniert für VanVan bereits einwandfrei; eine
zusätzliche Schranke davor würde nur doppelt verwirren.
- /verwaltung/* (interne Bestellübersicht) und /api/verwaltung/* (die dazugehörigen
Bestell-Daten, siehe functions/api/verwaltung/) bleiben IMMER geschützt — auch wenn der
Rest der Seite später per SITE_PUBLIC=true freigeschaltet wird (siehe unten). Diese Seite
ist nie für Kund:innen gedacht. /api/orders (Bestellung ANLEGEN beim Checkout) ist bewusst
NICHT dauerhaft geschützt — das muss auch für echte Kund:innen erreichbar sein, sobald der
Shop öffentlich ist, genau wie der Checkout selbst.
- /verwaltung/* + /api/verwaltung/* verlangen ZUSÄTZLICH zum normalen 90-Tage-Seitenzugang
noch einen zweiten, eigenen Tages-Code (siehe functions/_shared/verwaltung-auth.js,
/verwaltung-code.html) — auf ausdrücklichen Wunsch, damit die Bestellübersicht auch bei
einer kompromittierten/geteilten normalen Sitzung nicht dauerhaft offen bleibt.
- API-Routen (/api/*) bekommen bei fehlender Sitzung eine 401-JSON-Antwort statt der
302-Weiterleitung zur Zugangscode-Seite — ein fetch() im Checkout/Verwaltung-Skript soll
einen klaren Fehler sehen, nicht versehentlich HTML als Antwort bekommen.
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers/Pages-Runtime
eingebaut) verhindert, dass jemand sich einfach selbst ein
"vandiy_gate_session=irgendwas"-Cookie setzt.
===================================================================== */
import { COOKIE_NAME, ROLE_COOKIE_NAME, SESSION_TAGE, signSession, verifySession, getCookie } from "./_shared/auth.js";
import { VERWALTUNG_COOKIE_NAME, VERWALTUNG_SESSION_STUNDEN, signVerwaltungSession, verifyVerwaltungSession } from "./_shared/verwaltung-auth.js";
/* Seiten, die auch ohne Zugangscode ausgeliefert werden (siehe Begründung unten in onRequest).
Bewusst als feste, vollständige Liste statt "alles was mit X anfängt" — so kann hier nicht
versehentlich mehr freigegeben werden als gewollt. */
const OEFFENTLICHE_SEITEN = new Set([
"/impressum",
"/datenschutz",
"/agb",
"/widerruf",
"/muster-widerrufsformular",
]);
/* Damit diese Seiten nicht nackt/ohne Gestaltung beim Prüfteam ankommen, sind zusätzlich die
rein gestalterischen Dateien freigegeben: Stylesheets sowie Logo/Favicons. Bewusst NUR
.css aus /_astro/ — die JavaScript-Bündel dort enthalten u.a. Produktdaten und bleiben
deshalb weiterhin hinter der Schranke. */
function istOeffentlichErreichbar(pfad) {
const ohneSlash = pfad.endsWith("/") && pfad.length > 1 ? pfad.slice(0, -1) : pfad;
if (OEFFENTLICHE_SEITEN.has(ohneSlash)) return true;
if (pfad.startsWith("/_astro/") && pfad.endsWith(".css")) return true;
if (/^\/(logo\.png|favicon\.ico|favicon-\d+x\d+\.png|apple-touch-icon\.png|icon-\d+(-maskable)?\.png)$/.test(pfad)) return true;
return false;
}
async function handleAuth(request, env) {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "Ungültige Anfrage." }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const code = (body.code || "").trim();
const next = typeof body.next === "string" && body.next.startsWith("/") ? body.next : "/";
let role = null;
if (env.SITE_ACCESS_CODE_QCIGA && code === env.SITE_ACCESS_CODE_QCIGA) role = "qciga";
else if (env.SITE_ACCESS_CODE_VANVAN && code === env.SITE_ACCESS_CODE_VANVAN) role = "vanvan";
if (!role) {
return new Response(JSON.stringify({ ok: false, error: "Falscher Zugangscode." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const session = await signSession(role, env.SITE_ACCESS_SECRET);
const maxAge = SESSION_TAGE * 24 * 60 * 60;
const headers = new Headers({ "Content-Type": "application/json" });
headers.append(
"Set-Cookie",
`${COOKIE_NAME}=${encodeURIComponent(session)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`
);
headers.append(
"Set-Cookie",
`${ROLE_COOKIE_NAME}=${role}; Path=/; Max-Age=${maxAge}; Secure; SameSite=Lax`
);
return new Response(JSON.stringify({ ok: true, next }), { status: 200, headers });
}
/* Gegenstück zu handleAuth() oben, aber für den zweiten, verwaltungseigenen Tages-Code —
bewusst EIN gemeinsamer Code (kein Unterschied qciga/VanVan), da es hier nur um die
zusätzliche Hürde geht, nicht um eine rollenabhängige Anzeige. */
async function handleVerwaltungAuth(request, env) {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "Ungültige Anfrage." }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const code = (body.code || "").trim();
const next = typeof body.next === "string" && body.next.startsWith("/") ? body.next : "/verwaltung/";
if (!env.VERWALTUNG_ACCESS_CODE || code !== env.VERWALTUNG_ACCESS_CODE) {
return new Response(JSON.stringify({ ok: false, error: "Falscher Tages-Code." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const session = await signVerwaltungSession(env.VERWALTUNG_SESSION_SECRET);
const maxAge = VERWALTUNG_SESSION_STUNDEN * 60 * 60;
const headers = new Headers({ "Content-Type": "application/json" });
headers.append(
"Set-Cookie",
`${VERWALTUNG_COOKIE_NAME}=${encodeURIComponent(session)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`
);
return new Response(JSON.stringify({ ok: true, next }), { status: 200, headers });
}
export async function onRequest(context) {
const { request, next, env } = context;
const url = new URL(request.url);
// /admin/* NIE anfassen — eigenes, echtes GitHub-OAuth-Login, funktioniert für VanVan bereits.
if (url.pathname.startsWith("/admin")) {
return next();
}
// Rechtstexte (Impressum, Datenschutz, AGB, Widerruf) bleiben IMMER ohne Zugangscode
// erreichbar. Grund: PayPal prüft vor der Freischaltung von "Log in with PayPal" die
// angegebene Datenschutz- und AGB-Adresse tatsächlich nach — hinter der Zugangsschranke
// sähe deren Prüfteam nur die Code-Eingabe, und die Freischaltung würde scheitern. Gleiches
// gilt für Google (verlangt beim Veröffentlichen eine erreichbare Datenschutzerklärung).
// Rechtlich ist das ohnehin die saubere Seite: Impressum und Datenschutzerklärung sollen
// "ständig verfügbar" sein. Diese Seiten enthalten keine Shop-/Kundendaten.
if (istOeffentlichErreichbar(url.pathname)) {
return next();
}
// Fail-open-Absicherung: fehlen die Zugangs-Secrets komplett (z.B. noch nicht gesetzt), lieber
// GAR KEINE Schranke scharf schalten, als sich selbst aus der eigenen Seite auszusperren.
const codesKonfiguriert = !!(env.SITE_ACCESS_SECRET && (env.SITE_ACCESS_CODE_QCIGA || env.SITE_ACCESS_CODE_VANVAN));
if (!codesKonfiguriert) {
return next();
}
// /verwaltung/* (Bestellübersicht — nur für qciga/vanvan) bleibt IMMER geschützt, unabhängig
// vom SITE_PUBLIC-Schalter unten. Sobald der Shop live geht, setzt man einfach das Secret
// SITE_PUBLIC=true (wrangler pages secret put) — dann öffnet sich der Rest der Seite für alle,
// aber /verwaltung/ bleibt weiterhin komplett hinter dem Zugangscode.
const immerGeschuetzterPfad = url.pathname.startsWith("/verwaltung") || url.pathname.startsWith("/api/verwaltung");
const sitePublic = env.SITE_PUBLIC === "true";
if (sitePublic && !immerGeschuetzterPfad) {
return next();
}
if (url.pathname === "/gate-auth" && request.method === "POST") {
return handleAuth(request, env);
}
const sessionToken = getCookie(request, COOKIE_NAME);
const payload = sessionToken ? await verifySession(sessionToken, env.SITE_ACCESS_SECRET) : null;
if (payload) {
// Normaler Seitenzugang ist gültig — für alles außerhalb von /verwaltung/ reicht das.
if (!immerGeschuetzterPfad) {
return next();
}
// Ab hier: /verwaltung/* oder /api/verwaltung/* MIT gültigem normalem Seitenzugang.
// Die Eingabemaske für den zweiten Code + ihr eigener Auswertungs-Endpunkt müssen erreichbar
// sein, BEVOR der zweite Code selbst geprüft wird — sonst käme hier niemand überhaupt zur
// Eingabe. Gleiches "/verwaltung-code.html" <-> "/verwaltung-code"-Doppel wie bei gate.html
// oben, aus demselben Grund (Cloudflare Pages html_handling).
if (url.pathname === "/verwaltung-code.html" || url.pathname === "/verwaltung-code") {
return next();
}
if (url.pathname === "/verwaltung-auth" && request.method === "POST") {
return handleVerwaltungAuth(request, env);
}
// Fail-open auch hier: fehlt das Secret komplett, lieber die zweite Schranke nicht scharf
// schalten, als sich selbst auszusperren — die erste Schranke (normaler Seitenzugang)
// schützt /verwaltung/ in diesem Fall ja immer noch.
if (!env.VERWALTUNG_SESSION_SECRET || !env.VERWALTUNG_ACCESS_CODE) {
return next();
}
const vwToken = getCookie(request, VERWALTUNG_COOKIE_NAME);
const vwPayload = vwToken ? await verifyVerwaltungSession(vwToken, env.VERWALTUNG_SESSION_SECRET) : null;
if (vwPayload) {
return next();
}
if (url.pathname.startsWith("/api/")) {
return new Response(JSON.stringify({ ok: false, error: "Tages-Code für die Verwaltung erforderlich." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const vwNext = url.pathname + url.search;
return Response.redirect(`${url.origin}/verwaltung-code.html?next=${encodeURIComponent(vwNext)}`, 302);
}
// gate.html selbst muss ohne Session ladbar sein, sonst kommt niemand überhaupt bis zur
// Eingabemaske. Ist komplett selbst-gestylt (inline CSS, keine externen Assets) — deshalb
// reicht diese eine Ausnahme, es müssen keine CSS-/Bild-Pfade extra freigegeben werden.
// WICHTIG: Cloudflare Pages leitet "/gate.html" standardmäßig automatisch auf die
// "saubere" URL "/gate" um (html_handling) — beide Schreibweisen müssen hier durchgelassen
// werden, sonst entsteht eine Endlosschleife (gate.html -> Redirect auf /gate -> Middleware
// kennt /gate nicht -> Redirect zurück auf gate.html -> ...).
if (url.pathname === "/gate.html" || url.pathname === "/gate") {
return next();
}
// API-Aufrufe (fetch() aus Checkout/Verwaltung) sollen bei fehlender Sitzung eine saubere
// 401-JSON-Antwort bekommen statt eines 302 auf die HTML-Zugangscode-Seite (siehe Kopfkommentar).
if (url.pathname.startsWith("/api/")) {
return new Response(JSON.stringify({ ok: false, error: "Nicht angemeldet." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const nextParam = url.pathname + url.search;
return Response.redirect(`${url.origin}/gate?next=${encodeURIComponent(nextParam)}`, 302);
}