diff --git a/functions/_middleware.js b/functions/_middleware.js index 1ca61ad..a7f3be6 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -31,6 +31,10 @@ 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. @@ -41,6 +45,7 @@ ===================================================================== */ 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 @@ -103,6 +108,39 @@ async function handleAuth(request, env) { 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); @@ -148,7 +186,44 @@ export async function onRequest(context) { const payload = sessionToken ? await verifySession(sessionToken, env.SITE_ACCESS_SECRET) : null; if (payload) { - return next(); + // 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 diff --git a/functions/_shared/verwaltung-auth.js b/functions/_shared/verwaltung-auth.js new file mode 100644 index 0000000..51ffb86 --- /dev/null +++ b/functions/_shared/verwaltung-auth.js @@ -0,0 +1,70 @@ +/* ===================================================================== + functions/_shared/verwaltung-auth.js — ZWEITE, ZUSÄTZLICHE Zugangsschranke NUR für + /verwaltung/ + /api/verwaltung/* — auf ausdrücklichen Wunsch: ein einziger, gemeinsamer + Tages-Code, den nur qciga und VanVan kennen, unabhängig vom normalen 90-Tage-Seitenzugang + (siehe functions/_shared/auth.js). Wer die Bestellübersicht öffnen will, braucht also BEIDE + Codes — den normalen Seitenzugang UND diesen hier, und dieser hier läuft nach 24 Stunden ab. + + Bewusst als eigene, kleine Kopie der HMAC-Signaturlogik statt geteiltem Code mit auth.js + (gleiches Prinzip wie bei customer-auth.js) — ein Leck bei einer Sitzungsart darf nie die + andere gefährden. Eigenes Geheimnis (VERWALTUNG_SESSION_SECRET), eigenes Cookie, eigener + einzelner Code (VERWALTUNG_ACCESS_CODE) statt getrennter Codes pro Person — hier reicht ein + gemeinsamer Code, weil es nur um diese eine zusätzliche Hürde geht, nicht um die Anzeige + einer rollenabhängigen Vorschau wie beim normalen Seitenzugang. ===================================================================== */ + +export const VERWALTUNG_COOKIE_NAME = "vandiy_verwaltung_session"; +export const VERWALTUNG_SESSION_STUNDEN = 24; + +function b64urlEncode(bytes) { + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function b64urlDecodeToBytes(str) { + str = str.replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4) str += "="; + const bin = atob(str); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} +async function hmacKey(secret) { + return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); +} + +export async function signVerwaltungSession(secret) { + const payload = JSON.stringify({ exp: Date.now() + VERWALTUNG_SESSION_STUNDEN * 60 * 60 * 1000 }); + const payloadB64 = b64urlEncode(new TextEncoder().encode(payload)); + const key = await hmacKey(secret); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64)); + return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`; +} + +export async function verifyVerwaltungSession(token, secret) { + if (!token || !token.includes(".")) return null; + const [payloadB64, sigB64] = token.split("."); + try { + const key = await hmacKey(secret); + const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64)); + if (!valid) return null; + const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64))); + if (!payload.exp || payload.exp < Date.now()) return null; + return payload; + } catch { + return null; + } +} + +export function getCookie(request, name) { + const header = request.headers.get("Cookie") || ""; + const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +/** true, sofern eine gültige, noch nicht abgelaufene Tages-Sitzung vorliegt. Für API-Routen + * gedacht, die unabhängig vom Seiten-Routing (Middleware) selbst nochmal prüfen wollen — siehe + * functions/api/verwaltung/*, genau wie getGateRole() in auth.js für den normalen Zugang. */ +export async function hatGueltigeVerwaltungSitzung(request, env) { + if (!env.VERWALTUNG_SESSION_SECRET) return false; + const token = getCookie(request, VERWALTUNG_COOKIE_NAME); + const payload = token ? await verifyVerwaltungSession(token, env.VERWALTUNG_SESSION_SECRET) : null; + return !!payload; +} diff --git a/functions/api/verwaltung/orders.js b/functions/api/verwaltung/orders.js index b69b32f..97184ce 100644 --- a/functions/api/verwaltung/orders.js +++ b/functions/api/verwaltung/orders.js @@ -4,9 +4,12 @@ Zusätzlich zur Middleware (functions/_middleware.js, schützt /api/verwaltung/* IMMER, unabhängig von SITE_PUBLIC) prüft diese Route die Sitzung noch einmal selbst — Verteidigung - in der Tiefe, falls diese Datei mal unabhängig von der Middleware aufgerufen wird. ===================================================================== */ + in der Tiefe, falls diese Datei mal unabhängig von der Middleware aufgerufen wird. Das + umfasst BEIDE Schranken: den normalen Seitenzugang UND den zusätzlichen Verwaltungs- + Tages-Code (siehe functions/_shared/verwaltung-auth.js). ===================================================================== */ import { getGateRole, unauthorizedJson } from "../../_shared/auth.js"; +import { hatGueltigeVerwaltungSitzung } from "../../_shared/verwaltung-auth.js"; import { json } from "../../_shared/http.js"; export async function onRequestGet(context) { @@ -14,6 +17,9 @@ export async function onRequestGet(context) { const role = await getGateRole(request, env); if (!role) return unauthorizedJson(); + if (!(await hatGueltigeVerwaltungSitzung(request, env))) { + return json(401, { ok: false, error: "Tages-Code für die Verwaltung erforderlich." }); + } if (!env.DB) { return json(500, { ok: false, error: "Datenbank nicht verbunden." }); diff --git a/functions/api/verwaltung/orders/[id].js b/functions/api/verwaltung/orders/[id].js index 5bdf50c..9800cbf 100644 --- a/functions/api/verwaltung/orders/[id].js +++ b/functions/api/verwaltung/orders/[id].js @@ -3,9 +3,11 @@ ändern, direkt aus der Verwaltungsseite heraus (siehe src/pages/verwaltung/index.astro). Genau wie bei GET /api/verwaltung/orders: zusätzlich zur Middleware-Sperre wird die - Sitzung hier noch einmal eigenständig geprüft. ===================================================================== */ + Sitzung hier noch einmal eigenständig geprüft — beide Schranken (normaler Seitenzugang UND + Verwaltungs-Tages-Code). ===================================================================== */ import { getGateRole, unauthorizedJson } from "../../../_shared/auth.js"; +import { hatGueltigeVerwaltungSitzung } from "../../../_shared/verwaltung-auth.js"; import { json } from "../../../_shared/http.js"; const ERLAUBTE_STATUS = ["zahlungOffen", "bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"]; @@ -15,6 +17,9 @@ export async function onRequestPatch(context) { const role = await getGateRole(request, env); if (!role) return unauthorizedJson(); + if (!(await hatGueltigeVerwaltungSitzung(request, env))) { + return json(401, { ok: false, error: "Tages-Code für die Verwaltung erforderlich." }); + } if (!env.DB) { return json(500, { ok: false, error: "Datenbank nicht verbunden." }); diff --git a/public/verwaltung-code.html b/public/verwaltung-code.html new file mode 100644 index 0000000..40e396f --- /dev/null +++ b/public/verwaltung-code.html @@ -0,0 +1,174 @@ + + +
+ + +Diese Seite ist zusätzlich mit einem eigenen Code geschützt, den nur ihr beide kennt — + er muss einmal pro Tag neu eingegeben werden.
+ + + +