diff --git a/server-internal/index.js b/server-internal/index.js index 118aeac8..9d1a72d0 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -86,6 +86,15 @@ import { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren, anfrageNotiz, } from "./routes/webdesign-anfragen.js"; +/* Kundenportal (Masterplan S.12). Eigene Anmeldung mit eigener Sitzung -- + Kunden sind KEINE Teammitglieder und duerfen mit deren Rechtesystem + nichts zu tun haben. Die Mandantentrennung sitzt in den Handlern: + jede Abfrage filtert auf die kunde_id aus der Sitzung. */ +import { + portalAnmelden, portalAbmelden, portalEinladungPruefen, portalPasswortSetzen, + portalUebersicht, portalProjekt, portalAenderungEinreichen, + portalAenderungEntscheiden, portalNachricht, portalProfilSpeichern, +} from "./routes/webdesign-portal.js"; initDb(); @@ -227,6 +236,20 @@ app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus)); app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren)); app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz)); +/* ---------- Kundenportal ---------- + Oeffentlich sind nur Anmeldung und das Einloesen eines Einladungslinks. + Alles Weitere prueft die Kundensitzung im Handler selbst. */ +app.post("/webdesign/portal/anmelden", wrap(portalAnmelden)); +app.post("/webdesign/portal/abmelden", wrap(portalAbmelden)); +app.get("/webdesign/portal/einladung", wrap(portalEinladungPruefen)); +app.post("/webdesign/portal/passwort", wrap(portalPasswortSetzen)); +app.get("/webdesign/portal/uebersicht", wrap(portalUebersicht)); +app.get("/webdesign/portal/projekt/:id", wrap(portalProjekt)); +app.post("/webdesign/portal/aenderung", wrap(portalAenderungEinreichen)); +app.post("/webdesign/portal/aenderung/:id/entscheiden", wrap(portalAenderungEntscheiden)); +app.post("/webdesign/portal/nachricht", wrap(portalNachricht)); +app.post("/webdesign/portal/profil", wrap(portalProfilSpeichern)); + app.post("/supporter/register", wrap(registerSupporter)); app.post("/supporter/verify-email", wrap(verifySupporterEmail)); // Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode. diff --git a/server-internal/routes/webdesign-portal.js b/server-internal/routes/webdesign-portal.js new file mode 100644 index 00000000..bacc9ace --- /dev/null +++ b/server-internal/routes/webdesign-portal.js @@ -0,0 +1,497 @@ +/* ===================================================================== + routes/webdesign-portal.js — Kundenportal (Masterplan S.12) + + ⚠️ DIE WICHTIGSTE REGEL DIESER DATEI + + "Jeder Kunde sieht ausschließlich die eigenen Projekte, Dateien, + Nachrichten, Kosten, Freigaben und Betreuungsdaten." (Masterplan S.12) + + Umgesetzt wird das NICHT dadurch, dass die Oberfläche nur die eigenen + Sachen anzeigt — die Oberfläche liegt im Browser des Kunden und ist + damit beliebig manipulierbar. Umgesetzt wird es dadurch, dass JEDE + Abfrage hier die kunde_id aus der SITZUNG in die WHERE-Bedingung + nimmt, nie aus der Anfrage. + + Konkret: `WHERE id = ? AND kunde_id = ?` statt `WHERE id = ?`. + Wer eine fremde Projekt-Kennung errät, bekommt dadurch "nicht + gefunden" — nicht die Daten. Das ist der Unterschied zwischen einer + Zugriffsbeschränkung und einem Datenleck. + + REGISTRIERUNG: bewusst KEINE Selbstregistrierung. + Masterplan S.12: "Projektkauf oder laufende Betreuung wird vor + Freischaltung geprüft". Ein Portal, in das sich jeder selbst eintragen + kann, wäre genau das Gegenteil. Kunden werden in der Verwaltung + angelegt und bekommen einen Einladungslink; erst darüber setzen sie + ihr Passwort. + ===================================================================== */ + +import { randomBytes, scrypt as scryptRoh, timingSafeEqual } from "node:crypto"; +import { promisify } from "node:util"; +import { db } from "../db.js"; +import { json } from "../lib/http.js"; +import { + jetzt, neueId, text, istEmail, emailNormal, tokenHash, + besucherIp, zuVieleVersuche, versuchZaehlen, versucheZuruecksetzen, + verlauf, spracheOderDe, centZuText, +} from "../lib/webdesign-helfer.js"; + +const scrypt = promisify(scryptRoh); + +/* --------------------------------------------------------------------- + Passwörter + + scrypt statt eines einfachen Hashes: Ein SHA-256 über ein Passwort ist + auf heutiger Hardware milliardenfach pro Sekunde durchprobierbar. + scrypt ist absichtlich langsam UND speicherhungrig — das macht auch + Grafikkarten-Angriffe teuer. Node bringt es eingebaut mit, es braucht + also kein zusätzliches Paket. + + Der Zufallswert (salt) steht mit im gespeicherten Text: zwei Kunden mit + demselben Passwort bekommen dadurch verschiedene Hashes, und eine + fertige Regenbogentabelle nützt nichts. + --------------------------------------------------------------------- */ +const SCRYPT_LAENGE = 64; +const SCRYPT_KOSTEN = { N: 16384, r: 8, p: 1 }; + +async function passwortHashen(passwort) { + const salz = randomBytes(16).toString("hex"); + const abgeleitet = await scrypt(passwort, salz, SCRYPT_LAENGE, SCRYPT_KOSTEN); + return `scrypt$${salz}$${abgeleitet.toString("hex")}`; +} + +async function passwortPruefen(passwort, gespeichert) { + if (!gespeichert || !gespeichert.startsWith("scrypt$")) return false; + const [, salz, hex] = gespeichert.split("$"); + if (!salz || !hex) return false; + const abgeleitet = await scrypt(passwort, salz, SCRYPT_LAENGE, SCRYPT_KOSTEN); + const erwartet = Buffer.from(hex, "hex"); + if (erwartet.length !== abgeleitet.length) return false; + return timingSafeEqual(erwartet, abgeleitet); +} + +/* Mindestanforderung bewusst LÄNGE statt Zeichenklassen. + "Hund1234!" erfüllt jede Zeichenklassen-Regel und ist trotzdem schlecht; + "mein blauer stuhl steht krumm" erfüllt keine und ist ausgezeichnet. + Länge ist der einzige Faktor, der zuverlässig hilft. */ +const PASSWORT_MIN = 10; + +/* --------------------------------------------------------------------- + Sitzungen + Serverseitig gespeichert, damit sie sich in der Verwaltung gezielt + beenden lassen (Masterplan S.13 "Aktive Sitzungen abmelden"). + --------------------------------------------------------------------- */ +const SITZUNG_STUNDEN = 12; + +function sitzungAnlegen(kundeId) { + const token = randomBytes(32).toString("base64url"); + db.prepare( + `INSERT INTO wd_kunden_sitzungen (token_hash, kunde_id, erstellt_am, laeuft_ab, letzte_aktion) + VALUES (?, ?, ?, ?, ?)` + ).run( + tokenHash(token), + kundeId, + jetzt(), + new Date(Date.now() + SITZUNG_STUNDEN * 3600 * 1000).toISOString(), + jetzt() + ); + return token; +} + +function sitzungLesen(req) { + const kopf = req.headers["authorization"] || ""; + const token = kopf.startsWith("Bearer ") ? kopf.slice(7) : null; + if (!token) return null; + + const zeile = db + .prepare(`SELECT * FROM wd_kunden_sitzungen WHERE token_hash = ?`) + .get(tokenHash(token)); + if (!zeile) return null; + + if (zeile.laeuft_ab < jetzt()) { + db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE token_hash = ?`).run(zeile.token_hash); + return null; + } + + const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE id = ?`).get(zeile.kunde_id); + /* Auch eine gültige Sitzung endet sofort, wenn der Zugang inzwischen + gesperrt oder entzogen wurde — sonst wäre eine Sperre erst nach + zwölf Stunden wirksam. */ + if (!kunde || kunde.gesperrt || !kunde.freigeschaltet || kunde.archiviert) return null; + + db.prepare(`UPDATE wd_kunden_sitzungen SET letzte_aktion = ? WHERE token_hash = ?`) + .run(jetzt(), zeile.token_hash); + return kunde; +} + +function brauchtKunde(req, res) { + const kunde = sitzungLesen(req); + if (!kunde) { + json(res, { ok: false, error: "Bitte melde dich erneut an." }, 401); + return null; + } + return kunde; +} + +/* --------------------------------------------------------------------- + ANMELDUNG + --------------------------------------------------------------------- */ +export async function portalAnmelden(req, res) { + const email = emailNormal(req.body?.email); + const passwort = String(req.body?.passwort || ""); + + /* Die Sperre hängt an der E-Mail, nicht an der IP. Grund: Ein Angreifer + wechselt IPs mühelos, aber nicht das Zielkonto. Eine IP-Sperre würde + zudem mehrere Kunden hinter demselben Firmenanschluss gemeinsam + aussperren — genau der Fehler, der im Universe am 19.08.2026 auftrat. */ + const schluessel = "portal:" + email; + if (email && zuVieleVersuche(schluessel)) { + return json(res, { + ok: false, + error: "Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen oder das Passwort zurücksetzen.", + }, 429); + } + + if (!email || !istEmail(email) || !passwort) { + return json(res, { ok: false, error: "Bitte E-Mail und Passwort eingeben." }, 400); + } + + const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE email = ?`).get(email); + + /* Immer dieselbe Meldung, egal ob die Adresse unbekannt ist oder nur + das Passwort falsch war. Unterschiedliche Meldungen verraten sonst, + welche E-Mail-Adressen Kunden sind. */ + const abweisen = () => { + versuchZaehlen(schluessel); + return json(res, { ok: false, error: "E-Mail oder Passwort stimmt nicht." }, 401); + }; + + if (!kunde || !kunde.passwort_hash) return abweisen(); + if (!(await passwortPruefen(passwort, kunde.passwort_hash))) return abweisen(); + + if (kunde.gesperrt) { + return json(res, { + ok: false, + error: "Dieser Zugang ist gesperrt. Bitte melde dich direkt bei Dogfather.", + }, 403); + } + if (!kunde.freigeschaltet) { + return json(res, { + ok: false, + error: "Dein Zugang ist noch nicht freigeschaltet. Sobald dein Projekt startet, bekommst du Bescheid.", + }, 403); + } + + versucheZuruecksetzen(schluessel); + const token = sitzungAnlegen(kunde.id); + db.prepare(`UPDATE wd_kunden SET letzter_login = ? WHERE id = ?`).run(jetzt(), kunde.id); + verlauf("kunde", kunde.id, "angemeldet", "kunde:" + kunde.id, { ip: besucherIp(req) ? "erfasst" : null }); + + return json(res, { + ok: true, + token, + kunde: { name: kunde.name, firma: kunde.firma, email: kunde.email, sprache: kunde.sprache }, + }); +} + +export function portalAbmelden(req, res) { + const kopf = req.headers["authorization"] || ""; + const token = kopf.startsWith("Bearer ") ? kopf.slice(7) : null; + if (token) db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE token_hash = ?`).run(tokenHash(token)); + return json(res, { ok: true }); +} + +/* --------------------------------------------------------------------- + EINLADUNG EINLÖSEN — hier setzt der Kunde sein Passwort + --------------------------------------------------------------------- */ +export async function portalEinladungPruefen(req, res) { + const token = text(req.query.token, 200); + const zeile = db + .prepare(`SELECT * FROM wd_kunden_token WHERE token_hash = ? AND zweck IN ('einladung','passwort')`) + .get(tokenHash(token)); + + if (!zeile || zeile.benutzt_am || zeile.laeuft_ab < jetzt()) { + return json(res, { ok: false, error: "Dieser Link ist abgelaufen oder wurde schon benutzt." }, 400); + } + const kunde = db.prepare(`SELECT name, email FROM wd_kunden WHERE id = ?`).get(zeile.kunde_id); + if (!kunde) return json(res, { ok: false, error: "Zugang nicht gefunden." }, 404); + + return json(res, { ok: true, name: kunde.name, email: kunde.email, zweck: zeile.zweck }); +} + +export async function portalPasswortSetzen(req, res) { + const token = text(req.body?.token, 200); + const passwort = String(req.body?.passwort || ""); + + if (passwort.length < PASSWORT_MIN) { + return json(res, { + ok: false, + error: `Bitte mindestens ${PASSWORT_MIN} Zeichen. Ein kurzer Satz ist sicherer und leichter zu merken als ein kryptisches Wort.`, + }, 400); + } + + const zeile = db + .prepare(`SELECT * FROM wd_kunden_token WHERE token_hash = ? AND zweck IN ('einladung','passwort')`) + .get(tokenHash(token)); + if (!zeile || zeile.benutzt_am || zeile.laeuft_ab < jetzt()) { + return json(res, { ok: false, error: "Dieser Link ist abgelaufen oder wurde schon benutzt." }, 400); + } + + const hash = await passwortHashen(passwort); + + /* Alles in EINER Transaktion. Bräche der Vorgang zwischen "Passwort + gesetzt" und "Einladung verbraucht" ab, bliebe ein gültiger + Einladungslink zurück, mit dem sich das Passwort erneut ändern ließe. */ + db.transaction(() => { + db.prepare( + `UPDATE wd_kunden SET passwort_hash = ?, email_bestaetigt = 1, aktualisiert_am = ? WHERE id = ?` + ).run(hash, jetzt(), zeile.kunde_id); + db.prepare(`UPDATE wd_kunden_token SET benutzt_am = ? WHERE token_hash = ?`) + .run(jetzt(), zeile.token_hash); + /* Alle bestehenden Sitzungen beenden: Wer das Passwort ändert, will + damit oft genau erreichen, dass jemand anders rausfliegt. */ + db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE kunde_id = ?`).run(zeile.kunde_id); + })(); + + verlauf("kunde", zeile.kunde_id, "passwort_gesetzt", "kunde:" + zeile.kunde_id, null); + return json(res, { ok: true }); +} + +/* --------------------------------------------------------------------- + DASHBOARD — alles, was der Kunde auf einen Blick braucht + --------------------------------------------------------------------- */ +export function portalUebersicht(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + + const projekte = db + .prepare( + `SELECT id, nummer, titel, paket, status, naechster_schritt, wartet_auf, + preis_cent, anzahlung_cent, anzahlung_bezahlt, rest_bezahlt, waehrung, + start_am, richttermin, uebergabe_am, portfolio_freigabe + FROM wd_projekte WHERE kunde_id = ? AND archiviert = 0 + ORDER BY erstellt_am DESC` + ) + .all(kunde.id); + + /* Beträge zusätzlich als fertigen Text mitgeben. Sonst muss die + Oberfläche das Formatieren nachbauen — und zwei Formatierungen + driften garantiert irgendwann auseinander. */ + for (const p of projekte) { + p.preis_text = centZuText(p.preis_cent, p.waehrung); + p.anzahlung_text = centZuText(p.anzahlung_cent, p.waehrung); + p.offene_aenderungen = db + .prepare(`SELECT COUNT(*) AS n FROM wd_aenderungen WHERE projekt_id = ? AND status IN ('eingereicht','geprueft','angeboten')`) + .get(p.id).n; + p.ungelesene_nachrichten = db + .prepare(`SELECT COUNT(*) AS n FROM wd_nachrichten WHERE projekt_id = ? AND intern = 0 AND autor != 'kunde' AND gelesen_kunde = 0`) + .get(p.id).n; + } + + const abos = db + .prepare(`SELECT id, paket, preis_cent, status, start_am, naechste_zahlung, laeuft_bis + FROM wd_betreuung_abos WHERE kunde_id = ? AND status != 'abgelaufen'`) + .all(kunde.id); + for (const a of abos) a.preis_text = centZuText(a.preis_cent); + + const offeneZahlungen = db + .prepare( + `SELECT id, nummer, art, zweck_text, betrag_cent, waehrung, status, erstellt_am + FROM wd_zahlungen WHERE kunde_id = ? AND status IN ('offen','freigegeben') + ORDER BY erstellt_am ASC` + ) + .all(kunde.id); + for (const z of offeneZahlungen) z.betrag_text = centZuText(z.betrag_cent, z.waehrung); + + return json(res, { + ok: true, + kunde: { + name: kunde.name, firma: kunde.firma, email: kunde.email, + sprache: kunde.sprache, betreuung_aktiv: !!kunde.betreuung_aktiv, + }, + projekte, + abos, + offeneZahlungen, + }); +} + +/* --------------------------------------------------------------------- + EIN PROJEKT IM DETAIL + --------------------------------------------------------------------- */ +export function portalProjekt(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + + const id = text(req.params.id, 64); + + /* HIER steht die eigentliche Absicherung: "AND kunde_id = ?". + Ohne diesen Zusatz könnte jeder angemeldete Kunde durch Raten oder + Ausprobieren einer fremden Kennung ein fremdes Projekt öffnen. */ + const projekt = db + .prepare(`SELECT * FROM wd_projekte WHERE id = ? AND kunde_id = ? AND archiviert = 0`) + .get(id, kunde.id); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + projekt.preis_text = centZuText(projekt.preis_cent, projekt.waehrung); + projekt.anzahlung_text = centZuText(projekt.anzahlung_cent, projekt.waehrung); + + /* fuer_kunde = 1: interne Arbeitsdateien bleiben unsichtbar. */ + const dateien = db + .prepare( + `SELECT id, anzeigename, typ, groesse, kategorie, version, hochgeladen_von, erstellt_am + FROM wd_dateien WHERE projekt_id = ? AND kunde_id = ? AND geloescht = 0 AND fuer_kunde = 1 + ORDER BY erstellt_am DESC` + ) + .all(id, kunde.id); + + // intern = 0: interne Notizen des Teams sieht der Kunde nicht. + const nachrichten = db + .prepare( + `SELECT id, autor, text, erstellt_am FROM wd_nachrichten + WHERE projekt_id = ? AND kunde_id = ? AND intern = 0 ORDER BY erstellt_am ASC` + ) + .all(id, kunde.id); + + const aenderungen = db + .prepare( + `SELECT id, titel, beschreibung, status, aufwand_text, zusatzpreis_cent, + zusatzdauer_text, antwort, erstellt_am, entschieden_am + FROM wd_aenderungen WHERE projekt_id = ? AND kunde_id = ? ORDER BY erstellt_am DESC` + ) + .all(id, kunde.id); + for (const a of aenderungen) a.zusatzpreis_text = centZuText(a.zusatzpreis_cent); + + const zahlungen = db + .prepare( + `SELECT id, nummer, art, zweck_text, betrag_cent, waehrung, status, erstellt_am, bezahlt_am + FROM wd_zahlungen WHERE projekt_id = ? AND kunde_id = ? ORDER BY erstellt_am DESC` + ) + .all(id, kunde.id); + for (const z of zahlungen) z.betrag_text = centZuText(z.betrag_cent, z.waehrung); + + // Als gelesen markieren — der Kunde hat sie ja gerade geöffnet. + db.prepare( + `UPDATE wd_nachrichten SET gelesen_kunde = 1 WHERE projekt_id = ? AND kunde_id = ? AND intern = 0` + ).run(id, kunde.id); + + return json(res, { ok: true, projekt, dateien, nachrichten, aenderungen, zahlungen }); +} + +/* --------------------------------------------------------------------- + ÄNDERUNGSWUNSCH EINREICHEN (Masterplan S.12) + --------------------------------------------------------------------- */ +export function portalAenderungEinreichen(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + + const projektId = text(req.body?.projektId, 64); + const titel = text(req.body?.titel, 160); + const beschreibung = text(req.body?.beschreibung, 4000); + + if (!titel || !beschreibung) { + return json(res, { ok: false, error: "Bitte gib der Idee einen Titel und beschreib sie kurz." }, 400); + } + + const projekt = db + .prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ? AND archiviert = 0`) + .get(projektId, kunde.id); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + const id = neueId(); + db.prepare( + `INSERT INTO wd_aenderungen (id, projekt_id, kunde_id, titel, beschreibung, status, erstellt_am) + VALUES (?,?,?,?,?,'eingereicht',?)` + ).run(id, projektId, kunde.id, titel, beschreibung, jetzt()); + + verlauf("projekt", projektId, "aenderung_eingereicht", "kunde:" + kunde.id, { titel }); + return json(res, { ok: true, id }); +} + +/* --------------------------------------------------------------------- + ZUSATZANGEBOT ANNEHMEN ODER ABLEHNEN + --------------------------------------------------------------------- */ +export function portalAenderungEntscheiden(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + + const id = text(req.params.id, 64); + const annehmen = req.body?.annehmen === true; + + const aenderung = db + .prepare(`SELECT * FROM wd_aenderungen WHERE id = ? AND kunde_id = ?`) + .get(id, kunde.id); + if (!aenderung) return json(res, { ok: false, error: "Nicht gefunden." }, 404); + + /* Nur ein Vorgang, zu dem tatsächlich ein Angebot vorliegt, lässt sich + entscheiden. Ohne diese Prüfung könnte jemand eine gerade erst + eingereichte Idee als "angenommen" markieren — und damit eine + Zahlungspflicht behaupten, die nie beziffert wurde. */ + if (aenderung.status !== "angeboten") { + return json(res, { + ok: false, + error: "Zu dieser Idee liegt noch kein Angebot vor. Sobald der Aufwand feststeht, kannst du entscheiden.", + }, 409); + } + + db.prepare( + `UPDATE wd_aenderungen SET status = ?, entschieden_am = ?, aktualisiert_am = ? WHERE id = ?` + ).run(annehmen ? "angenommen" : "abgelehnt", jetzt(), jetzt(), id); + + verlauf("projekt", aenderung.projekt_id, annehmen ? "zusatz_angenommen" : "zusatz_abgelehnt", + "kunde:" + kunde.id, { titel: aenderung.titel, preisCent: aenderung.zusatzpreis_cent }); + + return json(res, { ok: true, status: annehmen ? "angenommen" : "abgelehnt" }); +} + +/* --------------------------------------------------------------------- + NACHRICHT ZUM PROJEKT + --------------------------------------------------------------------- */ +export function portalNachricht(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + + const projektId = text(req.body?.projektId, 64); + const inhalt = text(req.body?.text, 4000); + if (!inhalt) return json(res, { ok: false, error: "Die Nachricht ist leer." }, 400); + + const projekt = db + .prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ? AND archiviert = 0`) + .get(projektId, kunde.id); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + const id = neueId(); + db.prepare( + `INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, gelesen_kunde, erstellt_am) + VALUES (?,?,?,'kunde',?,0,1,?)` + ).run(id, projektId, kunde.id, inhalt, jetzt()); + + return json(res, { ok: true, id }); +} + +/* --------------------------------------------------------------------- + EIGENES PROFIL + --------------------------------------------------------------------- */ +export function portalProfilSpeichern(req, res) { + const kunde = brauchtKunde(req, res); + if (!kunde) return; + + const name = text(req.body?.name, 120) || kunde.name; + const firma = text(req.body?.firma, 160); + const telefon = text(req.body?.telefon, 60); + const sprache = spracheOderDe(req.body?.sprache || kunde.sprache); + const benachrichtigungen = req.body?.benachrichtigungen === false ? 0 : 1; + + /* Die E-Mail-Adresse ist bewusst NICHT änderbar: sie ist gleichzeitig + der Anmeldename. Eine Änderung braucht eine Bestätigung über beide + Adressen, sonst sperrt ein Tippfehler den eigenen Zugang aus. Das + läuft über die Verwaltung. */ + db.prepare( + `UPDATE wd_kunden SET name = ?, firma = ?, telefon = ?, sprache = ?, + benachrichtigungen = ?, aktualisiert_am = ? WHERE id = ?` + ).run(name, firma || null, telefon || null, sprache, benachrichtigungen, jetzt(), kunde.id); + + verlauf("kunde", kunde.id, "profil_geaendert", "kunde:" + kunde.id, null); + return json(res, { ok: true }); +} + +/* Für Tests und die Verwaltung nutzbar. */ +export { passwortHashen, passwortPruefen, PASSWORT_MIN }; diff --git a/server-internal/test-webdesign-portal.mjs b/server-internal/test-webdesign-portal.mjs new file mode 100644 index 00000000..63759aac --- /dev/null +++ b/server-internal/test-webdesign-portal.mjs @@ -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("anna@example.com", "Anna Alpha", "einlangespasswort"); +const kundeB = await legeKundeAn("bert@example.com", "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: "anna@example.com", 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: "gibtesnicht@example.com", 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: "anna@example.com", 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: "bert@example.com", 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("anna@example.com"); + 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: "bert@example.com", 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: "anna@example.com", passwort: "falsch" + i }), letzte); + } + pruefe("nach fuenf Fehlversuchen wird gesperrt", letzte.code === 429, "war " + letzte.code); + + const andere = res(); + await P.portalAnmelden(req({ email: "bert@example.com", 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);