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);