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]>
This commit is contained in:
qcigano
2026-08-04 11:45:31 +02:00
co-authored by Claude Sonnet 5
parent 7e02879f66
commit aff961816b
5 changed files with 333 additions and 3 deletions
+75
View File
@@ -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,9 +186,46 @@ export async function onRequest(context) {
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.
+70
View File
@@ -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;
}
+7 -1
View File
@@ -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." });
+6 -1
View File
@@ -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." });
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tages-Code — Bestellverwaltung</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<style>
:root {
--c-black: #0a0910;
--c-anthracite: #1b1a22;
--c-anthracite-light: #252333;
--c-text: #f4f1f7;
--c-text-muted: #b7aec4;
--c-accent: #e3b23c;
--c-accent-hover: #c99527;
--c-purple-glow: #9b7fd6;
--radius-l: 22px;
--radius-m: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
position: relative;
overflow: hidden;
background: var(--c-black);
background-image: radial-gradient(60% 50% at 15% 10%, color-mix(in srgb, var(--c-purple-glow) 22%, transparent), transparent 70%),
radial-gradient(50% 45% at 85% 90%, color-mix(in srgb, var(--c-accent) 16%, transparent), transparent 70%);
color: var(--c-text);
font-family: "Inter", "Manrope", system-ui, sans-serif;
line-height: 1.6;
}
.gate-box {
position: relative;
max-width: 430px;
width: 100%;
text-align: center;
padding: 2.4rem 2rem;
border-radius: var(--radius-l);
border: 1.5px solid transparent;
background:
linear-gradient(165deg, var(--c-anthracite-light), var(--c-anthracite)) padding-box,
linear-gradient(120deg, #e3b23c, #9b7fd6, #5fd3ec, #e3b23c) border-box;
background-size: 100% 100%, 300% 100%;
animation: gate-border-shift 9s ease-in-out infinite;
box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.7);
}
@keyframes gate-border-shift {
0%, 100% { background-position: 0 0, 0% 50%; }
50% { background-position: 0 0, 100% 50%; }
}
@media (prefers-reduced-motion: reduce) { .gate-box { animation: none; } }
.gate-box > * { position: relative; z-index: 1; }
.gate-eyebrow {
display: inline-block;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--c-accent);
background: color-mix(in srgb, var(--c-accent) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--c-accent) 40%, transparent);
border-radius: 999px;
padding: 0.3rem 0.9rem;
margin-bottom: 1rem;
}
.gate-box h1 {
font-family: "Fraunces", "Playfair Display", Georgia, serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.6rem;
line-height: 1.2;
}
.gate-box p {
margin: 0 0 1.4rem;
color: var(--c-text-muted);
font-size: 0.94rem;
}
.gate-box input {
width: 100%;
padding: 0.9rem 1rem;
border-radius: 12px;
text-align: center;
letter-spacing: 0.08em;
font-size: 1.05rem;
margin-bottom: 0.9rem;
background: rgba(244, 241, 247, 0.05);
border: 1.5px solid rgba(244, 241, 247, 0.16);
color: var(--c-text);
font-family: inherit;
}
.gate-box input:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 25%, transparent);
}
.gate-box button {
width: 100%;
padding: 0.85rem 1rem;
border-radius: 12px;
border: none;
background: linear-gradient(120deg, var(--c-accent), var(--c-accent-hover));
color: var(--c-black);
font-weight: 800;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.gate-box button:hover { transform: translateY(-2px); box-shadow: 0 10px 24px -10px color-mix(in srgb, var(--c-accent) 60%, transparent); }
.gate-fehler {
color: #ff9a9a;
font-size: 0.88rem;
margin: 0.9rem 0 0;
display: none;
}
.gate-fehler.zeige { display: block; }
.gate-zurueck { display: inline-block; margin-top: 1.4rem; font-size: 0.85rem; color: var(--c-text-muted); }
.gate-zurueck a { color: inherit; }
</style>
</head>
<body>
<div class="gate-box">
<span class="gate-eyebrow">🔒 Tages-Code</span>
<h1>Bestellverwaltung</h1>
<p>Diese Seite ist zusätzlich mit einem eigenen Code geschützt, den nur ihr beide kennt —
er muss einmal pro Tag neu eingegeben werden.</p>
<form id="vw-form">
<input type="text" id="vw-code" placeholder="Tages-Code" autocomplete="off" autocapitalize="off" spellcheck="false" autofocus />
<button type="submit">Weiter</button>
</form>
<p class="gate-fehler" id="vw-fehler"></p>
<p class="gate-zurueck"><a href="/">← Zurück zur Startseite</a></p>
</div>
<script>
(function () {
const params = new URLSearchParams(location.search);
const next = params.get("next") || "/verwaltung/";
const form = document.getElementById("vw-form");
const fehlerEl = document.getElementById("vw-fehler");
form.addEventListener("submit", async function (e) {
e.preventDefault();
fehlerEl.classList.remove("zeige");
const code = document.getElementById("vw-code").value.trim();
if (!code) return;
try {
const res = await fetch("/verwaltung-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, next }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
fehlerEl.textContent = (data && data.error) || "Falscher Tages-Code.";
fehlerEl.classList.add("zeige");
return;
}
location.href = data.next || "/verwaltung/";
} catch {
fehlerEl.textContent = "Verbindung fehlgeschlagen. Bitte nochmal versuchen.";
fehlerEl.classList.add("zeige");
}
});
})();
</script>
</body>
</html>