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:
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user