diff --git a/server-internal/.env.example b/server-internal/.env.example index 8eddf152..5391f90a 100644 --- a/server-internal/.env.example +++ b/server-internal/.env.example @@ -85,3 +85,11 @@ PAYPAL_WD_PLAN_PREMIUM= # Zufallszeichenkette. Wird sie geaendert, passen alte Hashes nicht mehr -- # das ist unkritisch, sie dienen nur der Missbrauchserkennung. WD_IP_SALT= + +# Gegenstueck zu WEBDESIGN_API_SECRET in server/.env (22.08.2026). +# MUSS exakt derselbe Wert sein. Damit erkennt die interne API den Ausweis +# der Zugangswand an und Dogfather/VanVan muessen sich in der +# Webdesign-Verwaltung nicht ein zweites Mal anmelden. +# Der Ausweis gilt AUSSCHLIESSLICH fuer die /webdesign-Endpunkte -- Postfach, +# Bewerbungen, Supporter und Teamverwaltung bleiben unberuehrt. +WEBDESIGN_API_SECRET= diff --git a/server-internal/lib/webdesign-ausweis.js b/server-internal/lib/webdesign-ausweis.js new file mode 100644 index 00000000..1711d538 --- /dev/null +++ b/server-internal/lib/webdesign-ausweis.js @@ -0,0 +1,83 @@ +/* ===================================================================== + webdesign-ausweis.js — Ausweis der Zugangswand anerkennen + + Hintergrund (Rückmeldung 22.08.2026: "ich will es nur einmal eingeben + müssen"): Die Zugangswand vor /webdesign hat serverseitig bereits + geprüft, wer da ist. Dogfather und VanVan sind laut Masterplan S.4 + gleichberechtigte Volladministratoren. Ein zweites Passwort für die + Verwaltung bringt danach keinen zusätzlichen Schutz — es kostet nur + jedes Mal Zeit. + + Der Ausweis wird von server/webdesign-gate.js ausgestellt und hier + geprüft. Beide Seiten teilen sich dafür WEBDESIGN_API_SECRET. + + ⚠️ WAS DIESER AUSWEIS NICHT IST + Er ist KEIN Team-Login. Er berechtigt ausschließlich zu den + Webdesign-Endpunkten und verschafft keinerlei Zugriff auf Postfach, + Bewerbungen, Supporter oder Teamverwaltung. Wer den Webdesign-Bereich + betreten darf, darf damit nicht automatisch alles andere auch. + + ⚠️ FEHLT DAS GEHEIMNIS, GILT KEIN AUSWEIS + Dann bleibt es bei der Team-Anmeldung wie bisher. Ein fehlender + Konfigurationswert darf niemals eine Tür öffnen — nur eine schließen. + ===================================================================== */ + +import { createHmac, timingSafeEqual } from "node:crypto"; + +function b64urlZurueck(str) { + return Buffer.from(String(str).replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +/** + * Prüft den Ausweis aus dem Authorization-Kopf. + * Liefert { rolle } bei Gültigkeit, sonst null. + */ +export function ausweisPruefen(req) { + const geheim = process.env.WEBDESIGN_API_SECRET; + if (!geheim) return null; // nicht eingerichtet -> kein Ausweis + + const kopf = req.headers["authorization"] || ""; + const token = kopf.startsWith("Bearer ") ? kopf.slice(7) : null; + if (!token || !token.includes(".")) return null; + + const [nutzlast, sigB64] = token.split("."); + try { + const erwartet = createHmac("sha256", geheim).update(nutzlast).digest(); + const geliefert = b64urlZurueck(sigB64); + /* Länge zuerst vergleichen: timingSafeEqual wirft bei + unterschiedlich langen Puffern, statt false zu liefern. */ + if (geliefert.length !== erwartet.length) return null; + if (!timingSafeEqual(geliefert, erwartet)) return null; + + const daten = JSON.parse(b64urlZurueck(nutzlast).toString("utf8")); + /* bereich prüfen: Ohne diesen Vergleich würde ein Sitzungs-Token der + öffentlichen Zugangswand hier ebenfalls durchgehen, obwohl es für + einen ganz anderen Zweck ausgestellt wurde. */ + if (daten.bereich !== "wd-admin") return null; + if (!daten.exp || daten.exp < Date.now()) return null; + if (daten.rolle !== "dogfather" && daten.rolle !== "vanvan") return null; + + return { rolle: daten.rolle }; + } catch { + return null; + } +} + +/** + * Einheitliche Rechteprüfung für die Webdesign-Endpunkte. + * + * Zwei Wege führen hinein: + * 1. Der Ausweis der Zugangswand (Dogfather oder VanVan) — beide sind + * Volladministratoren und dürfen im Webdesign-Bereich alles. + * 2. Eine normale Team-Anmeldung mit dem passenden Einzelrecht — für + * den Fall, dass später jemand aus dem Team nur Anfragen ansehen, + * aber keine Kundendaten bearbeiten soll. + * + * `pruefeTeam` wird als Funktion hereingereicht, damit diese Datei nichts + * von auth.js und permissions.js wissen muss. + */ +export async function darfWebdesign(req, recht, pruefeTeam) { + const ausweis = ausweisPruefen(req); + if (ausweis) return { erlaubt: true, actor: ausweis.rolle, ueberAusweis: true }; + return pruefeTeam(); +} diff --git a/server-internal/routes/webdesign-anfragen.js b/server-internal/routes/webdesign-anfragen.js index 21b188ea..5007f506 100644 --- a/server-internal/routes/webdesign-anfragen.js +++ b/server-internal/routes/webdesign-anfragen.js @@ -16,6 +16,7 @@ import { db } from "../db.js"; import { json } from "../lib/http.js"; import { requireSession } from "../lib/auth.js"; import { hasPermission } from "../lib/permissions.js"; +import { ausweisPruefen } from "../lib/webdesign-ausweis.js"; import { jetzt, neueId, naechsteNummer, text, istEmail, emailNormal, ipHash, besucherIp, spracheOderDe, verlauf, @@ -27,6 +28,23 @@ import { restlichen internen Bereich. --------------------------------------------------------------------- */ async function darf(req, recht) { + /* Zwei Wege fuehren hinein. + + 1. Der Ausweis der Zugangswand. Wer dort mit dem persoenlichen Code + durchgekommen ist, ist Dogfather oder VanVan -- laut Masterplan S.4 + beide gleichberechtigte Volladministratoren. Ein zweites Passwort + danach bringt keinen zusaetzlichen Schutz und kostet nur Zeit + (Rueckmeldung 22.08.2026: "ich will es nur einmal eingeben muessen"). + + 2. Eine normale Team-Anmeldung mit dem passenden Einzelrecht -- fuer + den Fall, dass spaeter jemand aus dem Team nur Anfragen ansehen, + aber keine Kundendaten bearbeiten koennen soll. + + Der Ausweis gilt AUSSCHLIESSLICH hier. Postfach, Bewerbungen, + Supporter und Teamverwaltung bleiben davon unberuehrt. */ + const ausweis = ausweisPruefen(req); + if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } }; + const sitzung = await requireSession(req); if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null }; if (sitzung.isOwner) return { erlaubt: true, sitzung }; diff --git a/server-internal/routes/webdesign-kunden.js b/server-internal/routes/webdesign-kunden.js index bc74c80b..15b4898c 100644 --- a/server-internal/routes/webdesign-kunden.js +++ b/server-internal/routes/webdesign-kunden.js @@ -17,12 +17,30 @@ import { db } from "../db.js"; import { json } from "../lib/http.js"; import { requireSession } from "../lib/auth.js"; import { hasPermission } from "../lib/permissions.js"; +import { ausweisPruefen } from "../lib/webdesign-ausweis.js"; import { jetzt, neueId, naechsteNummer, text, istEmail, emailNormal, tokenHash, verlauf, spracheOderDe, centZuText, PAKETE, PROJEKT_STATUS, } from "../lib/webdesign-helfer.js"; async function darf(req, recht) { + /* Zwei Wege fuehren hinein. + + 1. Der Ausweis der Zugangswand. Wer dort mit dem persoenlichen Code + durchgekommen ist, ist Dogfather oder VanVan -- laut Masterplan S.4 + beide gleichberechtigte Volladministratoren. Ein zweites Passwort + danach bringt keinen zusaetzlichen Schutz und kostet nur Zeit + (Rueckmeldung 22.08.2026: "ich will es nur einmal eingeben muessen"). + + 2. Eine normale Team-Anmeldung mit dem passenden Einzelrecht -- fuer + den Fall, dass spaeter jemand aus dem Team nur Anfragen ansehen, + aber keine Kundendaten bearbeiten koennen soll. + + Der Ausweis gilt AUSSCHLIESSLICH hier. Postfach, Bewerbungen, + Supporter und Teamverwaltung bleiben davon unberuehrt. */ + const ausweis = ausweisPruefen(req); + if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } }; + const sitzung = await requireSession(req); if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null }; if (sitzung.isOwner) return { erlaubt: true, sitzung }; diff --git a/server-internal/test-webdesign-ausweis.mjs b/server-internal/test-webdesign-ausweis.mjs new file mode 100644 index 00000000..2780ef3b --- /dev/null +++ b/server-internal/test-webdesign-ausweis.mjs @@ -0,0 +1,48 @@ +/* ===================================================================== + test-webdesign-ausweis.mjs — Ausweis der Zugangswand + + Der Ausweis erlaubt Dogfather und VanVan, die Webdesign-Verwaltung ohne + zweite Anmeldung zu benutzen (Rückmeldung 22.08.2026: "ich will es nur + einmal eingeben müssen"). Genau deshalb muss hier besonders sauber + geprüft werden, was NICHT durchgeht. + + Aufruf: node test-webdesign-ausweis.mjs + ===================================================================== */ +import { createHmac } from "node:crypto"; +process.env.WEBDESIGN_API_SECRET = "test-geheimnis"; +const { ausweisPruefen } = await import("./lib/webdesign-ausweis.js"); + +const b64 = (b) => Buffer.from(b).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +function bau(inhalt, geheim = "test-geheimnis") { + const n = b64(Buffer.from(JSON.stringify(inhalt), "utf8")); + return n + "." + b64(createHmac("sha256", geheim).update(n).digest()); +} +const req = (t) => ({ headers: t ? { authorization: "Bearer " + t } : {} }); + +let ok = 0, bad = 0; +const p = (n, b, z = "") => { if (b) { ok++; console.log(" ok " + n); } else { bad++; console.log(" FEHLT " + n + " " + z); } }; + +console.log("\n=== Ausweis der Zugangswand ===\n"); +const gut = bau({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + 900000 }); +p("gueltiger Ausweis wird anerkannt", ausweisPruefen(req(gut))?.rolle === "dogfather"); +p("VanVan ebenso", ausweisPruefen(req(bau({ bereich: "wd-admin", rolle: "vanvan", exp: Date.now() + 900000 })))?.rolle === "vanvan"); +p("ohne Ausweis -> null", ausweisPruefen(req(null)) === null); +p("Unsinn -> null", ausweisPruefen(req("kein.ausweis")) === null); +p("mit fremdem Geheimnis signiert -> null", + ausweisPruefen(req(bau({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + 900000 }, "anderes"))) === null); +p("abgelaufen -> null", + ausweisPruefen(req(bau({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() - 1000 }))) === null); +p("Sitzungs-Token der Zugangswand gilt hier NICHT", + ausweisPruefen(req(bau({ bereich: "webdesign", rolle: "dogfather", exp: Date.now() + 900000 }))) === null, + "-> anderer Zweck, anderer Bereich"); +p("erfundene Rolle -> null", + ausweisPruefen(req(bau({ bereich: "wd-admin", rolle: "hacker", exp: Date.now() + 900000 }))) === null); +p("verfaelschte Signatur -> null", ausweisPruefen(req(gut.split(".")[0] + ".AAAA")) === null); + +delete process.env.WEBDESIGN_API_SECRET; +const { ausweisPruefen: ohne } = await import("./lib/webdesign-ausweis.js?neu=1"); +p("ohne eingerichtetes Geheimnis gilt KEIN Ausweis", ohne(req(gut)) === null, + "-> ein fehlender Wert schliesst, er oeffnet nicht"); + +console.log(`\n=== ${ok} bestanden, ${bad} gescheitert ===\n`); +process.exit(bad === 0 ? 0 : 1); diff --git a/server/.env.example b/server/.env.example index f6f88bb8..5380d16b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -38,3 +38,13 @@ WEBDESIGN_CODE_VANVAN= # wird SITE_ACCESS_SECRET benutzt. Ein Universe-Cookie funktioniert hier # trotzdem nicht (die Sitzung traegt zusaetzlich bereich="webdesign"). WEBDESIGN_ACCESS_SECRET= + +# Gemeinsames Geheimnis mit dem internen Dienst (22.08.2026). +# Damit stellt die Zugangswand einen kurzlebigen Ausweis aus, mit dem die +# Verwaltung OHNE zweite Anmeldung auf die interne API zugreifen darf -- +# wer durch die Zugangswand kam, ist bereits als Dogfather oder VanVan +# geprueft. Muss in server-internal/.env EXAKT gleich stehen. +# Leer lassen = kein Ausweis, die Verwaltung fragt wie bisher nach dem +# Team-Code. Ein fehlender Wert darf nie eine Tuer oeffnen, nur eine +# schliessen. +WEBDESIGN_API_SECRET= diff --git a/server/webdesign-gate.js b/server/webdesign-gate.js index 0542bdaa..3f7a8ff4 100644 --- a/server/webdesign-gate.js +++ b/server/webdesign-gate.js @@ -318,6 +318,59 @@ export function webdesignGate(req, res, next) { return wdAbmeldung(req, res); } + /* --------------------------------------------------------------------- + AUSWEIS FÜR DIE INTERNE API + + Rückmeldung 22.08.2026: "ich hab auf der verwaltungs seite 2 mal dass + ich den zugangscode eingeben muss und vanvan auch, ich will es nur + einmal eingeben müssen." + + Berechtigt. Die Zugangswand hat serverseitig bereits geprüft, WER da + ist — Dogfather oder VanVan, beide laut Masterplan S.4 + gleichberechtigte Volladministratoren. Ein zweites Mal danach zu + fragen bringt keinen zusätzlichen Schutz, es nervt nur. + + Warum es trotzdem nicht einfach "das Cookie mitschicken" ist: + Die Verwaltungsdaten liegen hinter postfach.dogfather-universe.com, + einem ANDEREN Rechnernamen. Das Sitzungs-Cookie dieser Seite gilt + dort nicht — so sind Cookies gebaut, und das ist auch gut so. + + Deshalb stellt diese Stelle einen kurzlebigen, signierten Ausweis + aus, den die interne API prüfen kann: + - nur 15 Minuten gültig (die Seite holt sich bei Bedarf einen neuen) + - trägt bereich="wd-admin", damit er NICHT als Sitzung dieser Seite + durchgeht und umgekehrt + - nur mit gültiger Zugangssitzung zu bekommen + + Fehlt WEBDESIGN_API_SECRET, wird kein Ausweis ausgestellt und die + Verwaltung fragt wie bisher nach dem Team-Code. Kein Ausfall, nur + kein Komfort — ein fehlender Wert darf nie eine Tür öffnen. + --------------------------------------------------------------------- */ + if (pfad === "/webdesign/api-ausweis" && req.method === "GET") { + const secret = geheimnis(); + const apiSecret = process.env.WEBDESIGN_API_SECRET; + const sitzung = secret ? wdPruefe(req.cookies?.[WD_COOKIE], secret) : null; + + if (!sitzung) { + return res.status(401).json({ ok: false, error: "Keine gültige Zugangssitzung." }); + } + if (!apiSecret) { + /* Bewusst 200 mit ausweis:null statt eines Fehlers: die Verwaltung + soll daraufhin ruhig ihre eigene Anmeldung zeigen, nicht eine + Störungsmeldung. */ + return res.json({ ok: true, ausweis: null, grund: "nicht eingerichtet" }); + } + + const inhalt = JSON.stringify({ + bereich: "wd-admin", + rolle: sitzung.rolle, + exp: Date.now() + 15 * 60 * 1000, + }); + const nutzlast = b64url(Buffer.from(inhalt, "utf8")); + const sig = createHmac("sha256", apiSecret).update(nutzlast).digest(); + return res.json({ ok: true, ausweis: `${nutzlast}.${b64url(sig)}`, rolle: sitzung.rolle }); + } + // Wer bin ich? (für die persönliche Begrüßung) if (pfad === "/webdesign/zugang-status" && req.method === "GET") { const secret = geheimnis(); diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index 12098296..d9278ec4 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -160,8 +160,10 @@
- Dein persönlicher Zugangscode aus dem internen Bereich — derselbe - wie für das Postfach. Der Code der Zugangswand gilt hier nicht. + Normalerweise landest du hier gar nicht: Wer durch die Zugangswand + gekommen ist, wird direkt hereingelassen. Diese Maske erscheint nur, + wenn das nicht möglich war — dann brauchst du deinen persönlichen + Zugangscode aus dem internen Bereich (derselbe wie fürs Postfach).