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]>
252 lines
12 KiB
JavaScript
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);
|
|
}
|