Kundenportal: Server-Seite mit strikter Mandantentrennung

Masterplan S.12. Anmeldung, Uebersicht, Projektdetail, Aenderungswuensche,
Zusatzangebote annehmen/ablehnen, Nachrichten, Profil.

DIE ZENTRALE ENTSCHEIDUNG: Die Trennung zwischen Kunden sitzt NICHT in der
Oberflaeche, sondern in jeder einzelnen Abfrage. Ueberall steht
"WHERE id = ? AND kunde_id = ?" statt "WHERE id = ?", wobei die kunde_id
IMMER aus der Sitzung kommt, nie aus der Anfrage. Wer eine fremde
Projektkennung errät, bekommt dadurch "nicht gefunden" statt Daten. Die
Oberflaeche liegt im Browser des Kunden und ist beliebig manipulierbar --
sie kann diese Aufgabe grundsaetzlich nicht uebernehmen.

Weitere bewusste Entscheidungen:
* KEINE Selbstregistrierung. Masterplan S.12 verlangt, dass Projektkauf
  oder Betreuung VOR der Freischaltung geprueft werden. Ein Portal, in das
  sich jeder selbst eintraegt, waere das Gegenteil. Kunden werden in der
  Verwaltung angelegt und bekommen einen Einladungslink.
* Passwoerter mit scrypt (in Node eingebaut, kein Zusatzpaket). Ein
  SHA-256 ueber ein Passwort ist milliardenfach pro Sekunde durchprobierbar;
  scrypt ist absichtlich langsam UND speicherhungrig.
* Mindestanforderung ist LAENGE, nicht Zeichenklassen. "Hund1234!" erfuellt
  jede Klassenregel und ist trotzdem schlecht; "mein blauer stuhl steht
  krumm" erfuellt keine und ist ausgezeichnet.
* Gleiche Fehlermeldung bei unbekannter Adresse und falschem Passwort --
  sonst lassen sich Kundenadressen durchprobieren.
* Die Anmeldesperre haengt an der E-Mail, nicht an der IP. Eine IP-Sperre
  wuerde mehrere Kunden hinter demselben Firmenanschluss gemeinsam
  aussperren -- genau der Fehler, der im Universe am 19.08.2026 auftrat.
* Sperre und Passwortwechsel beenden laufende Sitzungen SOFORT, nicht erst
  nach zwoelf Stunden.
* Ein Zusatzangebot laesst sich nur annehmen, wenn es beziffert ist --
  sonst entstuende eine Zahlungspflicht ohne Preis.

Dazu der wichtigste Test des Bereichs (test-webdesign-portal.mjs): zwei
echte Kunden, und Kunde B versucht systematisch mit Kunde As echten
Kennungen an dessen Projekt, Nachrichten, Dateien und Zusatzangebote zu
kommen. Prueft ausserdem, dass interne Notizen und interne Arbeitsdateien
auch dem BERECHTIGTEN Kunden verborgen bleiben.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 19:12:56 +02:00
co-authored by Claude Opus 5
parent ae9878cb4c
commit 04a1af1253
3 changed files with 809 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
/* =====================================================================
test-webdesign-portal.mjs — Mandantentrennung im Kundenportal
Das ist der wichtigste Test des ganzen Bereichs.
Masterplan S.12: "Jeder Kunde sieht ausschließlich die eigenen
Projekte, Dateien, Nachrichten, Kosten, Freigaben und Betreuungsdaten."
Geprüft wird das mit ZWEI echten Kunden, die beide ein Projekt haben.
Kunde B versucht anschließend systematisch, an die Daten von Kunde A zu
kommen — mit dessen echten Kennungen, die er (etwa aus einer weiter-
geleiteten E-Mail) kennen könnte. Jeder dieser Versuche MUSS mit
"nicht gefunden" enden, nicht mit Daten.
Aufruf (aus server-internal/, braucht better-sqlite3):
node test-webdesign-portal.mjs
===================================================================== */
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "wd-portal-")), "test.db");
const { initDb, db } = await import("./db.js");
initDb();
const P = await import("./routes/webdesign-portal.js");
const { jetzt, neueId } = await import("./lib/webdesign-helfer.js");
let ok = 0, schlecht = 0;
function pruefe(name, b, zusatz = "") {
if (b) { ok++; console.log(" ok " + name); }
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
}
function res() {
const a = { code: 200, daten: null };
a.status = (c) => { a.code = c; return a; };
a.json = (d) => { a.daten = d; return a; };
return a;
}
function req(koerper, { token = null, params = {}, query = {} } = {}) {
return {
body: koerper, params, query,
headers: token ? { authorization: "Bearer " + token } : {},
socket: { remoteAddress: "203.0.113.1" },
};
}
/* ---------- Zwei Kunden mit je einem Projekt anlegen ---------- */
async function legeKundeAn(email, name, passwort) {
const id = neueId();
db.prepare(
`INSERT INTO wd_kunden (id, email, name, sprache, passwort_hash, email_bestaetigt,
freigeschaltet, gesperrt, erstellt_am) VALUES (?,?,?,'de',?,1,1,0,?)`
).run(id, email, name, await P.passwortHashen(passwort), jetzt());
return id;
}
function legeProjektAn(kundeId, nummer, titel) {
const id = neueId();
db.prepare(
`INSERT INTO wd_projekte (id, nummer, kunde_id, titel, paket, status, preis_cent,
anzahlung_cent, waehrung, erstellt_am) VALUES (?,?,?,?,'website','design',150000,45000,'EUR',?)`
).run(id, nummer, kundeId, titel, jetzt());
return id;
}
console.log("\n=== Kundenportal: Mandantentrennung ===\n");
const kundeA = await legeKundeAn("[email protected]", "Anna Alpha", "einlangespasswort");
const kundeB = await legeKundeAn("[email protected]", "Bert Beta", "nochlaengerespasswort");
const projektA = legeProjektAn(kundeA, "P-2608-0001", "Annas Website");
const projektB = legeProjektAn(kundeB, "P-2608-0002", "Berts Website");
// Vertrauliche Inhalte an Annas Projekt
db.prepare(`INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, erstellt_am)
VALUES (?,?,?,'dogfather','GEHEIM-FUER-ANNA',0,?)`).run(neueId(), projektA, kundeA, jetzt());
db.prepare(`INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, erstellt_am)
VALUES (?,?,?,'dogfather','INTERNE-NOTIZ-NICHT-FUER-KUNDEN',1,?)`).run(neueId(), projektA, kundeA, jetzt());
db.prepare(`INSERT INTO wd_dateien (id, projekt_id, kunde_id, anzeigename, speichername, typ, groesse,
kategorie, hochgeladen_von, fuer_kunde, erstellt_am)
VALUES (?,?,?,'Annas Angebot.pdf','x1.pdf','application/pdf',1000,'angebot','dogfather',1,?)`)
.run(neueId(), projektA, kundeA, jetzt());
db.prepare(`INSERT INTO wd_dateien (id, projekt_id, kunde_id, anzeigename, speichername, typ, groesse,
kategorie, hochgeladen_von, fuer_kunde, erstellt_am)
VALUES (?,?,?,'INTERNE-ARBEITSDATEI.psd','x2.psd','image/vnd.adobe.photoshop',9000,'sonstiges','dogfather',0,?)`)
.run(neueId(), projektA, kundeA, jetzt());
const aenderungA = neueId();
db.prepare(`INSERT INTO wd_aenderungen (id, projekt_id, kunde_id, titel, beschreibung, status,
zusatzpreis_cent, erstellt_am) VALUES (?,?,?,'Annas Wunsch','Text','angeboten',20000,?)`)
.run(aenderungA, projektA, kundeA, jetzt());
/* ---------- 1. Anmeldung ---------- */
console.log("1. Anmeldung");
let tokenA = null, tokenB = null;
{
let r = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "falschesPasswort" }), r);
pruefe("falsches Passwort wird abgewiesen", r.code === 401);
pruefe("Meldung verraet NICHT, ob die Adresse existiert",
/E-Mail oder Passwort/.test(r.daten?.error || ""),
"-> sonst kann man Kundenadressen durchprobieren");
r = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "irgendwas" }), r);
pruefe("unbekannte Adresse ergibt DIESELBE Meldung",
r.code === 401 && /E-Mail oder Passwort/.test(r.daten?.error || ""));
r = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "einlangespasswort" }), r);
pruefe("richtiges Passwort meldet an", r.daten?.ok === true, JSON.stringify(r.daten));
tokenA = r.daten?.token;
pruefe("Sitzungstoken kommt zurueck", !!tokenA);
pruefe("Passwort steht NICHT in der Antwort", !JSON.stringify(r.daten).includes("passwort"));
r = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "nochlaengerespasswort" }), r);
tokenB = r.daten?.token;
pruefe("zweiter Kunde meldet sich an", !!tokenB);
const gespeichert = db.prepare(`SELECT passwort_hash FROM wd_kunden WHERE email = ?`).get("[email protected]");
pruefe("Passwort liegt nur als scrypt-Hash in der Datenbank",
gespeichert.passwort_hash.startsWith("scrypt$") && !gespeichert.passwort_hash.includes("einlangespasswort"));
}
/* ---------- 2. Ohne Anmeldung geht nichts ---------- */
console.log("\n2. Ohne Anmeldung");
{
for (const [name, fn, r0] of [
["Uebersicht", P.portalUebersicht, req(null)],
["Projekt", P.portalProjekt, req(null, { params: { id: projektA } })],
["Aenderung einreichen", P.portalAenderungEinreichen, req({ projektId: projektA, titel: "x", beschreibung: "y" })],
["Nachricht", P.portalNachricht, req({ projektId: projektA, text: "hallo" })],
["Profil", P.portalProfilSpeichern, req({ name: "Hacker" })],
]) {
const r = res();
await fn(r0, r);
pruefe(name + " -> 401", r.code === 401);
pruefe(name + " -> keine Daten", !r.daten?.projekte && !r.daten?.projekt);
}
const r = res();
await P.portalUebersicht(req(null, { token: "voellig-erfundenes-token" }), r);
pruefe("erfundenes Token -> 401", r.code === 401);
}
/* ---------- 3. DER KERNTEST: B kommt nicht an A ---------- */
console.log("\n3. Kunde B versucht an Kunde A zu kommen");
{
let r = res();
await P.portalUebersicht(req(null, { token: tokenB }), r);
const eigene = JSON.stringify(r.daten);
pruefe("B sieht sein eigenes Projekt", eigene.includes("P-2608-0002"));
pruefe("B sieht NICHT Annas Projekt", !eigene.includes("P-2608-0001"),
"-> Uebersicht filtert auf die eigene kunde_id");
pruefe("B sieht NICHT Annas Namen", !eigene.includes("Annas Website"));
// Direkter Zugriff mit Annas echter Projektkennung
r = res();
await P.portalProjekt(req(null, { token: tokenB, params: { id: projektA } }), r);
pruefe("B kann Annas Projekt NICHT oeffnen", r.code === 404,
"-> mit der echten Kennung! Das ist der Kern der Trennung");
pruefe("keine Daten im Fehlerfall", !r.daten?.projekt);
pruefe("nichts Vertrauliches in der Antwort",
!JSON.stringify(r.daten).includes("GEHEIM-FUER-ANNA"));
// Nachricht in Annas Projekt schreiben
r = res();
await P.portalNachricht(req({ projektId: projektA, text: "eingeschmuggelt" }, { token: tokenB }), r);
pruefe("B kann in Annas Projekt NICHTS schreiben", r.code === 404);
pruefe("es wurde auch wirklich nichts gespeichert",
db.prepare(`SELECT COUNT(*) AS n FROM wd_nachrichten WHERE text = 'eingeschmuggelt'`).get().n === 0);
// Änderungswunsch an Annas Projekt hängen
r = res();
await P.portalAenderungEinreichen(req({ projektId: projektA, titel: "fremd", beschreibung: "fremd" }, { token: tokenB }), r);
pruefe("B kann an Annas Projekt keine Idee haengen", r.code === 404);
// Annas Zusatzangebot annehmen
r = res();
await P.portalAenderungEntscheiden(req({ annehmen: true }, { token: tokenB, params: { id: aenderungA } }), r);
pruefe("B kann Annas Zusatzangebot NICHT annehmen", r.code === 404,
"-> sonst koennte man Fremden Kosten aufhalsen");
pruefe("Annas Angebot ist unveraendert",
db.prepare(`SELECT status FROM wd_aenderungen WHERE id = ?`).get(aenderungA).status === "angeboten");
}
/* ---------- 4. A sieht das Richtige, aber nicht das Interne ---------- */
console.log("\n4. Was Anna sehen darf");
{
const r = res();
await P.portalProjekt(req(null, { token: tokenA, params: { id: projektA } }), r);
const t = JSON.stringify(r.daten);
pruefe("Anna oeffnet ihr Projekt", r.daten?.ok === true);
pruefe("sieht die Nachricht an sie", t.includes("GEHEIM-FUER-ANNA"));
pruefe("sieht INTERNE Notizen NICHT", !t.includes("INTERNE-NOTIZ-NICHT-FUER-KUNDEN"),
"-> intern = 1 bleibt intern");
pruefe("sieht ihre Angebotsdatei", t.includes("Annas Angebot.pdf"));
pruefe("sieht INTERNE Arbeitsdateien NICHT", !t.includes("INTERNE-ARBEITSDATEI"),
"-> fuer_kunde = 0 bleibt unsichtbar");
pruefe("Betrag wird lesbar mitgeliefert", /1500,00/.test(t));
pruefe("Speichername der Datei wird NICHT verraten", !t.includes("x1.pdf"),
"-> interner Dateiname geht niemanden etwas an");
}
/* ---------- 5. Zusatzangebot nur entscheidbar, wenn beziffert ---------- */
console.log("\n5. Zusatzangebote");
{
const offen = neueId();
db.prepare(`INSERT INTO wd_aenderungen (id, projekt_id, kunde_id, titel, beschreibung, status, erstellt_am)
VALUES (?,?,?,'Noch nicht beziffert','Text','eingereicht',?)`)
.run(offen, projektA, kundeA, jetzt());
let r = res();
await P.portalAenderungEntscheiden(req({ annehmen: true }, { token: tokenA, params: { id: offen } }), r);
pruefe("nicht beziffertes Anliegen kann nicht angenommen werden", r.code === 409,
"-> sonst entstuende eine Zahlungspflicht ohne Preis");
r = res();
await P.portalAenderungEntscheiden(req({ annehmen: true }, { token: tokenA, params: { id: aenderungA } }), r);
pruefe("beziffertes Angebot kann angenommen werden", r.daten?.ok === true);
pruefe("Status steht auf angenommen",
db.prepare(`SELECT status FROM wd_aenderungen WHERE id = ?`).get(aenderungA).status === "angenommen");
}
/* ---------- 6. Gesperrter Zugang ---------- */
console.log("\n6. Sperre wirkt sofort");
{
db.prepare(`UPDATE wd_kunden SET gesperrt = 1 WHERE id = ?`).run(kundeA);
const r = res();
await P.portalUebersicht(req(null, { token: tokenA }), r);
pruefe("laufende Sitzung endet bei Sperre sofort", r.code === 401,
"-> nicht erst nach zwoelf Stunden");
db.prepare(`UPDATE wd_kunden SET gesperrt = 0 WHERE id = ?`).run(kundeA);
}
/* ---------- 7. Einladung und Passwort ---------- */
console.log("\n7. Einladung");
{
const rohToken = "einladung-test-token-1234567890";
const { tokenHash } = await import("./lib/webdesign-helfer.js");
db.prepare(`INSERT INTO wd_kunden_token (token_hash, kunde_id, zweck, laeuft_ab, erstellt_am)
VALUES (?,?,'einladung',?,?)`)
.run(tokenHash(rohToken), kundeB, new Date(Date.now() + 86400000).toISOString(), jetzt());
let r = res();
await P.portalPasswortSetzen(req({ token: rohToken, passwort: "kurz" }), r);
pruefe("zu kurzes Passwort wird abgelehnt", r.code === 400);
pruefe("Begruendung nennt die Mindestlaenge", /\d+ Zeichen/.test(r.daten?.error || ""));
r = res();
await P.portalPasswortSetzen(req({ token: rohToken, passwort: "mein blauer stuhl steht krumm" }), r);
pruefe("langes Passwort wird gesetzt", r.daten?.ok === true);
r = res();
await P.portalPasswortSetzen(req({ token: rohToken, passwort: "noch ein anderes passwort" }), r);
pruefe("derselbe Link ein zweites Mal wird abgelehnt", r.code === 400,
"-> sonst bliebe ein dauerhaft gueltiger Generalschluessel liegen");
r = res();
await P.portalUebersicht(req(null, { token: tokenB }), r);
pruefe("alte Sitzungen enden beim Passwortwechsel", r.code === 401,
"-> wer das Passwort aendert, will oft genau jemanden aussperren");
r = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "mein blauer stuhl steht krumm" }), r);
pruefe("Anmeldung mit dem neuen Passwort klappt", r.daten?.ok === true);
}
/* ---------- 8. Sperre nach zu vielen Fehlversuchen ---------- */
console.log("\n8. Fehlversuche");
{
let letzte = null;
for (let i = 0; i < 6; i++) {
letzte = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "falsch" + i }), letzte);
}
pruefe("nach fuenf Fehlversuchen wird gesperrt", letzte.code === 429, "war " + letzte.code);
const andere = res();
await P.portalAnmelden(req({ email: "[email protected]", passwort: "mein blauer stuhl steht krumm" }), andere);
pruefe("ein ANDERER Kunde ist davon nicht betroffen", andere.daten?.ok === true,
"-> die Sperre haengt an der Adresse, nicht an der IP");
}
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
process.exit(schlecht === 0 ? 0 : 1);