Verwaltung: nur noch EINMAL den Code eingeben

Rueckmeldung 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 muessen." Berechtigt.

Die Zugangswand hat serverseitig laengst geprueft, WER da ist -- Dogfather
oder VanVan, laut Masterplan S.4 beide gleichberechtigte
Volladministratoren. Ein zweites Passwort danach bringt keinen
zusaetzlichen Schutz, es kostet nur jedes Mal Zeit.

Warum es nicht einfach "Cookie mitschicken" ist: Die Verwaltungsdaten
liegen hinter postfach.dogfather-universe.com, einem ANDEREN Rechnernamen.
Das Sitzungs-Cookie gilt dort nicht -- so sind Cookies gebaut, und das ist
gut so.

Loesung: Die Zugangswand stellt unter /webdesign/api-ausweis einen
kurzlebigen, signierten Ausweis aus, den die interne API anerkennt.
  - 15 Minuten gueltig, die Seite holt bei Bedarf still einen neuen
  - traegt bereich="wd-admin", damit ein Sitzungs-Token der Zugangswand
    hier NICHT durchgeht und umgekehrt
  - nur mit gueltiger Zugangssitzung zu bekommen
  - gilt AUSSCHLIESSLICH fuer die /webdesign-Endpunkte. Postfach,
    Bewerbungen, Supporter und Teamverwaltung bleiben unberuehrt

Fehlt WEBDESIGN_API_SECRET auf einer der beiden Seiten, gilt kein Ausweis
und die Verwaltung fragt wie bisher nach dem Team-Code. Ein fehlender
Konfigurationswert darf niemals eine Tuer oeffnen -- nur eine schliessen.
Genau das prueft der letzte Testfall.

Bei einem 401 wird EINMAL still ein neuer Ausweis geholt und die Anfrage
wiederholt, statt jemanden mitten im Arbeiten rauszuwerfen.

10/10 Tests, darunter: gefaelschte Signatur, fremdes Geheimnis,
abgelaufen, falscher Bereich, erfundene Rolle.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 21:36:11 +02:00
co-authored by Claude Opus 5
parent 2542a2598e
commit 74091590b8
8 changed files with 298 additions and 9 deletions
+8
View File
@@ -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=
+83
View File
@@ -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();
}
@@ -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 };
@@ -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 };
@@ -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);
+10
View File
@@ -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=
+53
View File
@@ -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();
+58 -7
View File
@@ -160,8 +160,10 @@
<div id="vw-anmeldung" class="wd-karte vw-login" hidden>
<h1 style="font-size:1.4rem">Verwaltung</h1>
<p class="wd-klein wd-leise">
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).
</p>
<form id="vw-login-form" novalidate>
<div class="wd-feld" id="vw-feld-code">
@@ -385,8 +387,20 @@
window.WD.meldungAus(meldung);
} catch (fehler) {
if (fehler.status === 401) {
/* Sitzung abgelaufen — zurück zur Anmeldung statt einer
Fehlermeldung, mit der niemand etwas anfangen kann. */
/* Der Ausweis der Zugangswand laeuft nach 15 Minuten ab. Statt
jemanden mitten im Arbeiten rauszuwerfen, EINMAL still einen
neuen holen und es nochmal versuchen. Klappt auch das nicht,
ist die Zugangssitzung selbst weg -- dann zurueck zur
Anmeldung, nicht zu einer leeren Liste. */
if (!ausweisVersucht) {
ausweisVersucht = true;
var frisch = await ausweisHolen();
if (frisch) {
token = frisch; tokenSchreiben(token);
ausweisVersucht = false;
return ladeAnfragen();
}
}
token = null; tokenSchreiben(null); zeigeAnmeldung();
return;
}
@@ -814,12 +828,49 @@
}
}
/* ---------------- Start ---------------- */
function start() {
/* ---------------- Start ----------------
Reihenfolge bewusst so:
1. Ein bereits geholter Ausweis im Sitzungsspeicher.
2. Sonst: einen neuen bei der Zugangswand holen. Die hat serverseitig
laengst geprueft, wer hier ist -- ein zweites Passwort danach
bringt keinen zusaetzlichen Schutz und kostet nur Zeit
(Rueckmeldung 22.08.2026).
3. Erst wenn das nicht klappt (Ausweis nicht eingerichtet, Sitzung
abgelaufen), die eigene Anmeldemaske.
Der Ausweis laeuft nach 15 Minuten ab. Deshalb wird bei jedem 401
einmal stillschweigend ein neuer geholt und die Anfrage wiederholt --
sonst fliegt man mitten im Arbeiten raus. */
var ausweisVersucht = false;
async function ausweisHolen() {
try {
var antwort = await fetch("/webdesign/api-ausweis", { cache: "no-store" });
if (!antwort.ok) return null;
var daten = await antwort.json();
return daten && daten.ausweis ? daten.ausweis : null;
} catch (e) {
return null;
}
}
async function start() {
token = tokenLesen();
if (!token) { zeigeAnmeldung(); return; }
if (token) { zeigeBereich(); ladeAnfragen(); return; }
var ausweis = await ausweisHolen();
if (ausweis) {
token = ausweis;
tokenSchreiben(token);
document.getElementById("vw-name").textContent =
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
zeigeBereich();
ladeAnfragen();
return;
}
zeigeAnmeldung();
}
if (window.WD) start();