From b47d3d0a3e1b8c62a3c26dea83858c0648b4978f Mon Sep 17 00:00:00 2001 From: kelle Date: Fri, 24 Jul 2026 20:45:31 +0200 Subject: [PATCH] Modul-Review 1: Auth-Nachbesserungen (Sicherheit & UX) Ergebnis der ersten Test- und Review-Runde zum Modul "Zugang & App-Rahmen": - Login-Timing-Ausgleich: unbekannter Benutzer wird gegen Dummy-bcrypt-Hash geprueft -> Antwortzeit verraet nicht mehr, ob ein Name existiert - Zurueck-Knopf nach Logout: pageshow-Waechter prueft die Session erneut und leitet die aus dem bfcache zurueckgeholte Ansicht auf /login - Rate-Limiting (neues lib/rate-limit.ts): Login 10/15min, Registrierung 5/h je IP, Passwortaenderung 10/15min je Benutzer; 429 + Retry-After - Passwort-Dialog laeuft neu ueber Modal -> schliesst auf Esc (Fokus-Falle, aria-modal inklusive) - Registrierungs-Fehler getrennt: nur belegter Name = 409 mit freundlicher Meldung, sonst 500 statt roher Prisma-Meldung SPEZIFIKATION 0.27 (3.1.2/3/4, neues 3.1.6). 261 -> 267 Tests. Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 46 ++++++++++++++--- src/app/api/auth/change-password/route.ts | 6 +++ src/app/api/auth/login/route.ts | 5 ++ src/app/api/auth/register/route.ts | 18 +++++-- src/app/page.tsx | 15 ++++++ src/components/ProfileMenu.tsx | 22 +++----- src/lib/rate-limit.test.ts | 47 +++++++++++++++++ src/lib/rate-limit.ts | 62 +++++++++++++++++++++++ src/lib/users.ts | 41 +++++++++++++-- 9 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 src/lib/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 73801a1..6c72409 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.26 | -| **Datum** | 2026-07-21 | +| **Version** | 0.27 | +| **Datum** | 2026-07-24 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `c429624` inkl. anpassbarem Pensionsalter (Branch `main`) | +| **Codestand** | Arbeitsstand nach `d6855ef` inkl. Sicherheits-Nachbesserungen Auth (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. | | 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet – ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert – der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter – wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 – bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert – sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen – bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet – effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios//retirement`; 40 Tests ergänzt (221 → 261). | | 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein – eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** – die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert – ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen – genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans//reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). | | 0.24 | 2026-07-20 | Claude (Opus 4.8) | **Navigation auf Plan-Ebene und gespeicherte Analysen** (neues Kapitel 3.10). Die Seitenleiste ist neu zweistufig: Unter jedem Plan liegen die Unterpunkte **Szenarien**, **Effektive Werte** und **Analysen**; ein Klick auf den Plan-Namen öffnet ein **Plan-Dashboard** (Kennzahlen – Haushaltsdaten direkt, gerechnete Werte ausdrücklich «laut Basisszenario», dazu die Ist-Abweichung, falls erfasst). Die **Szenario-Liste** zeigt je Szenario Version, Elementzahl, Endvermögen und Ruinalter mit den Aktionen Historie und Matrix; das Basisszenario ist hervorgehoben, der Szenario-Baum in der Seitenleiste bleibt daneben erhalten. Die **Analysen-Ansicht** bietet vier umklappende Kacheln (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren – Umklappen auch per Antippen für Touch). **Grafiken** öffnen neu nicht mehr alle drei Diagramme, sondern lassen zuerst **eines** wählen; der Szenario-Vergleich zieht mit zu den Grafiken, der CSV-Export auf die Matrix. **Gespeicherte Analysen:** Jede Grafik, MC-Simulation und Einflussfaktoren-Berechnung kann festgehalten werden – als **Zahlen, nicht als Bild** (read-only, es wird nichts neu gerechnet). Das hält den Datensatz klein und macht ihn druckfähig: Der spätere PDF-Bericht (Roadmap Nr. 11) zeichnet daraus vektoriell in Druckauflösung, was ein Screenshot nicht könnte. Bewusst **nicht** gespeichert wird `finalWealthSorted` (megabyteweise). Jedes Werkzeug schreibt sein Ergebnis in dieselbe generische Form, sodass eine Nur-Lese-Ansicht genügt. Neue Tabelle `SavedAnalysis`, neue Endpunkte unter `/api/plans//analyses` und `/dashboard`; neue Komponenten `PlanViews`, `SavedAnalysisView`, `SaveAnalysisButton`, neues Modul `analyses.ts`. Kein Eingriff in den Rechenkern; Testbestand 212 (Migration um `SavedAnalysis` erweitert). | @@ -222,7 +223,9 @@ Referenz: `src/lib/users.ts` Zeilen 6–22, `src/app/api/auth/register/route.ts` - Benutzername + Passwort; Prüfung via `bcrypt.compare`. - Fehlermeldung ist bewusst unspezifisch: „Benutzername oder Passwort falsch." (HTTP 401) – - verrät nicht, ob der Benutzer existiert. + verrät nicht, ob der Benutzer existiert. Auch die **Antwortzeit** verrät es nicht: Ein + unbekannter Benutzer wird gegen einen Dummy-bcrypt-Hash geprüft, damit die Dauer dieselbe + ist wie bei einem bekannten mit falschem Passwort (`TIMING_DUMMY_HASH` in `users.ts`). - Bei Erfolg: JWT (HS256, Payload `{ userId }`, Gültigkeit 30 Tage) im HttpOnly-Cookie `fpt_session` (`sameSite=lax`, `secure` nur in Produktion, `maxAge` 30 Tage). @@ -234,11 +237,21 @@ Referenz: `src/app/api/auth/login/route.ts`, `src/lib/auth.ts`. bereits kopiertes Token bis zum Ablauf technisch gültig – es gibt keine serverseitige Token-Sperrliste. +Nach dem Abmelden führt der **Zurück-Knopf** des Browsers zur zuletzt gezeigten Ansicht aus +dem *bfcache* zurück -- ohne neue Anfrage, also ohne dass Middleware oder Session-Prüfung +greifen. Damit die eingefrorene, scheinbar noch angemeldete Ansicht nicht stehen bleibt, +prüft die Hauptseite beim `pageshow`-Ereignis (nur bei einer aus dem bfcache zurückgeholten +Seite) erneut `/api/auth/me` und leitet ohne gültige Session sofort auf `/login`. Es sind +dabei nie echte Daten freigegeben -- das Cookie ist gelöscht --, aber die alten Zahlen sollen +auf einem geteilten Rechner gar nicht erst wieder sichtbar werden. Referenz: `src/app/page.tsx`. + ### 3.1.4 Passwortänderung Im Profilmenü. Erfordert das aktuelle Passwort; das neue Passwort muss ≥ 6 Zeichen haben und wird im Dialog gegen eine Wiederholung geprüft (Client-seitig). Nach Erfolg erscheint 1.2 s lang -„Passwort geändert.", dann schliesst der Dialog. +„Passwort geändert.", dann schliesst der Dialog. Der Dialog läuft über die zentrale +`Modal`-Komponente und schliesst damit auch auf **Esc** (Fokus-Falle und `aria-modal` +inklusive, siehe 3.7.6). Referenz: `src/app/api/auth/change-password/route.ts`, `src/components/ProfileMenu.tsx` Zeilen 117–140. @@ -255,6 +268,26 @@ Zweistufig: `getOwnedElement` in `src/lib/queries.ts`). Ein fremder Datensatz führt zu HTTP 404 (nicht 403) – die Existenz wird nicht preisgegeben. +### 3.1.6 Missbrauchsschutz (Rate-Limiting) + +Login, Registrierung und Passwortänderung sind gegen wiederholtes Durchprobieren gebremst +(`src/lib/rate-limit.ts`): + +| Endpunkt | Grenze | Schlüssel | +|---|---|---| +| `POST /api/auth/login` | 10 / 15 min | Client-IP | +| `POST /api/auth/register` | 5 / 60 min | Client-IP | +| `POST /api/auth/change-password` | 10 / 15 min | Benutzer-ID | + +Bei Überschreitung: **HTTP 429** mit lesbarer Meldung und `Retry-After`-Header. Der Zähler ist +ein **In-Memory-Fixed-Window** – bewusst einfach, weil das Tool in einem einzigen Container +läuft; nach einem Deploy ist er leer. Die Client-IP kommt aus `X-Forwarded-For` (Traefik); +ohne Header fallen alle auf denselben Eimer, was im Zweifel eher zu stark als zu schwach +bremst. Für einen Mehrinstanz-Betrieb müsste der Zähler nach Redis o. Ä. wandern (Roadmap #34). + +**Bewusst offen (Roadmap #34, Pentest):** kein Passwort-Längen-Maximum (bcrypt prüft still nur +die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palette. + ## 3.2 Plan-Verwaltung ### 3.2.1 Plan erstellen @@ -3765,7 +3798,8 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `server-boundary.test.ts` | 1 | statischer Wächter: kein Modul unter `src/lib` importiert aus `src/components` | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | -| **Total** | **261** | | +| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | +| **Total** | **267** | | ## 8.2 Testfälle diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index 81651e0..a5ad708 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCurrentUserId } from "@/lib/session"; import { changeUserPassword } from "@/lib/users"; +import { rateLimit, tooManyRequests } from "@/lib/rate-limit"; const changePasswordSchema = z.object({ currentPassword: z.string().min(1), @@ -14,6 +15,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); } + // Bremse gegen das Durchprobieren des aktuellen Passworts: 10 Versuche je Benutzer und + // 15 Minuten (nach dem Auth-Check, damit der Schlüssel die echte Benutzer-ID ist). + const limited = rateLimit(`change-pw:${userId}`, 10, 15 * 60 * 1000); + if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds); + const body = await request.json(); const parsed = changePasswordSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 405c1f8..ab8ee9f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; import { verifyUserCredentials } from "@/lib/users"; +import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit"; const loginSchema = z.object({ username: z.string().min(1), @@ -9,6 +10,10 @@ const loginSchema = z.object({ }); export async function POST(request: NextRequest) { + // Bremse gegen Brute-Force: 10 Versuche je IP und 15 Minuten. + const limited = rateLimit(`login:${clientIp(request)}`, 10, 15 * 60 * 1000); + if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds); + const body = await request.json(); const parsed = loginSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 928168f..3c249e7 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth"; -import { registerUser, validateUsername } from "@/lib/users"; +import { registerUser, UsernameTakenError, validateUsername } from "@/lib/users"; +import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit"; const registerSchema = z.object({ username: z.string().min(1), @@ -9,6 +10,10 @@ const registerSchema = z.object({ }); export async function POST(request: NextRequest) { + // Bremse gegen Massen-Registrierung: 5 Konten je IP und Stunde. + const limited = rateLimit(`register:${clientIp(request)}`, 5, 60 * 60 * 1000); + if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds); + const body = await request.json(); const parsed = registerSchema.safeParse(body); if (!parsed.success) { @@ -25,10 +30,13 @@ export async function POST(request: NextRequest) { try { user = await registerUser(parsed.data.username, parsed.data.password); } catch (e) { - return NextResponse.json( - { error: e instanceof Error ? e.message : "Registrierung fehlgeschlagen." }, - { status: 409 } - ); + // Nur der "Name vergeben"-Fall ist ein 409 mit freundlicher Meldung; alles andere + // (z. B. DB kurzzeitig weg) ist ein echter Serverfehler und darf nicht als Konflikt + // mit roher Prisma-Meldung durchgereicht werden. + if (e instanceof UsernameTakenError) { + return NextResponse.json({ error: e.message }, { status: 409 }); + } + return NextResponse.json({ error: "Registrierung fehlgeschlagen." }, { status: 500 }); } const token = await createSessionToken(user.id); diff --git a/src/app/page.tsx b/src/app/page.tsx index a1c211a..3f860a3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,6 +17,21 @@ export default function Home() { }); }, []); + // Nach dem Abmelden führt der Zurück-Knopf zu dieser Seite aus dem Browser-Cache (bfcache) + // zurück -- OHNE neue Anfrage, also ohne dass Middleware oder Session-Prüfung greifen. Die + // eingefrorene Ansicht darf nicht stehen bleiben: Bei einer aus dem bfcache zurückgeholten + // Seite die Session erneut prüfen und bei fehlender Anmeldung sofort auf /login gehen. + useEffect(() => { + function onPageShow(e: PageTransitionEvent) { + if (!e.persisted) return; + api.get("/api/auth/me").catch(() => { + window.location.href = "/login"; + }); + } + window.addEventListener("pageshow", onPageShow); + return () => window.removeEventListener("pageshow", onPageShow); + }, []); + if (username === null) { return (
diff --git a/src/components/ProfileMenu.tsx b/src/components/ProfileMenu.tsx index eb4c284..06ec75e 100644 --- a/src/components/ProfileMenu.tsx +++ b/src/components/ProfileMenu.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react"; import { api } from "@/lib/api-client"; +import { Button, Modal } from "@/components/ui"; import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme"; export function ProfileMenu({ username }: { username: string }) { @@ -140,28 +141,21 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) { const inputClass = "w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"; + // Über die zentrale Modal-Komponente: bringt Esc, Fokus-Falle und aria-modal mit + // (SPEZIFIKATION 3.7.6) -- der Dialog war zuvor von Hand gebaut und ignorierte Esc. return ( -
-
e.stopPropagation()} - className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl" - > -

Passwort ändern

+ + setCurrentPassword(e.target.value)} className={inputClass} /> setNewPassword(e.target.value)} className={inputClass} /> setNewPasswordConfirm(e.target.value)} className={inputClass} /> {error &&

{error}

} {done &&

Passwort geändert.

}
- - + +
-
+ ); } diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..6ec7d89 --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { rateLimit, clientIp } from "@/lib/rate-limit"; + +describe("rateLimit", () => { + afterEach(() => vi.useRealTimers()); + + it("erlaubt bis zum Limit und blockt danach", () => { + const key = `k-${Math.random()}`; + for (let i = 0; i < 3; i++) expect(rateLimit(key, 3, 1000).ok).toBe(true); + const blocked = rateLimit(key, 3, 1000); + expect(blocked.ok).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + }); + + it("startet nach Ablauf des Fensters neu", () => { + vi.useFakeTimers(); + const key = `k-${Math.random()}`; + expect(rateLimit(key, 1, 1000).ok).toBe(true); + expect(rateLimit(key, 1, 1000).ok).toBe(false); + vi.advanceTimersByTime(1001); + expect(rateLimit(key, 1, 1000).ok).toBe(true); + }); + + it("zaehlt je Schluessel getrennt", () => { + const a = `a-${Math.random()}`; + const b = `b-${Math.random()}`; + expect(rateLimit(a, 1, 1000).ok).toBe(true); + expect(rateLimit(a, 1, 1000).ok).toBe(false); + expect(rateLimit(b, 1, 1000).ok).toBe(true); // b unberuehrt + }); +}); + +describe("clientIp", () => { + const req = (headers: Record) => new Request("http://x", { headers }); + + it("nimmt den ersten Eintrag aus X-Forwarded-For", () => { + expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" }))).toBe("1.2.3.4"); + }); + + it("faellt auf X-Real-IP zurueck", () => { + expect(clientIp(req({ "x-real-ip": "9.9.9.9" }))).toBe("9.9.9.9"); + }); + + it("liefert 'unknown' ohne Header", () => { + expect(clientIp(req({}))).toBe("unknown"); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..752c34c --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,62 @@ +// Einfache In-Memory-Bremse gegen wiederholtes Durchprobieren (Login/Registrierung). +// +// Bewusst schlicht: Das Tool läuft in EINEM Node-Container (kein Cluster), ein Speicher- +// Zähler je Schlüssel genügt. Nach einem Deploy ist der Zähler leer -- das ist akzeptabel, +// ein Angreifer gewinnt dadurch nichts Nennenswertes. Für einen echten Mehrinstanz-Betrieb +// (Roadmap #34/Skalierung) müsste das nach Redis o. Ä. wandern. + +interface Entry { + count: number; + resetAt: number; // Zeitpunkt (ms), an dem das Fenster neu beginnt +} + +const store = new Map(); + +// Verhindert unbegrenztes Wachsen: abgelaufene Einträge werden entfernt, sobald die Map +// eine harmlose Grösse überschreitet. +function pruneIfNeeded(now: number) { + if (store.size < 1000) return; + for (const [key, entry] of store) { + if (entry.resetAt <= now) store.delete(key); + } +} + +export interface RateLimitResult { + ok: boolean; + retryAfterSeconds: number; +} + +// Fixed-Window-Zähler: erlaubt `limit` Aufrufe je `windowMs` und Schlüssel. +export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { + const now = Date.now(); + pruneIfNeeded(now); + + const entry = store.get(key); + if (!entry || entry.resetAt <= now) { + store.set(key, { count: 1, resetAt: now + windowMs }); + return { ok: true, retryAfterSeconds: 0 }; + } + if (entry.count >= limit) { + return { ok: false, retryAfterSeconds: Math.ceil((entry.resetAt - now) / 1000) }; + } + entry.count += 1; + return { ok: true, retryAfterSeconds: 0 }; +} + +// Client-IP hinter dem Reverse Proxy (Traefik setzt X-Forwarded-For). Erster Eintrag ist +// die ursprüngliche Client-Adresse; ohne Header fallen alle auf denselben Eimer zurück -- +// im Zweifel bremst das etwas zu stark, nie zu schwach. +export function clientIp(request: Request): string { + const xff = request.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0]!.trim(); + return request.headers.get("x-real-ip")?.trim() || "unknown"; +} + +// Baut die 429-Antwort samt lesbarer Meldung und Retry-After-Header. +export function tooManyRequests(retryAfterSeconds: number) { + const minutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); + return Response.json( + { error: `Zu viele Versuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.` }, + { status: 429, headers: { "Retry-After": String(retryAfterSeconds) } } + ); +} diff --git a/src/lib/users.ts b/src/lib/users.ts index 564dbf5..a9621f1 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -1,4 +1,5 @@ import bcrypt from "bcryptjs"; +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; // Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts @@ -6,6 +7,20 @@ import { prisma } from "@/lib/db"; const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/; +// Vergleichs-Hash für den Timing-Ausgleich (siehe verifyUserCredentials). Wird beim ersten +// Auth-Aufruf einmalig berechnet -- ein bewusst gültiger bcrypt-Hash mit Cost 12, damit der +// Vergleich exakt so lange dauert wie gegen einen echten Benutzer. +const TIMING_DUMMY_HASH = bcrypt.hashSync("timing-equalizer-not-a-real-password", 12); + +// Eigene Fehlerklasse, damit die Route den "Name vergeben"-Fall (409, freundliche Meldung) +// sauber von echten Server-Fehlern (500) trennen kann. +export class UsernameTakenError extends Error { + constructor() { + super("Dieser Benutzername ist bereits vergeben."); + this.name = "UsernameTakenError"; + } +} + export function validateUsername(username: string): string | null { if (!USERNAME_PATTERN.test(username)) { return "Benutzername: 3-32 Zeichen, nur Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich."; @@ -14,17 +29,33 @@ export function validateUsername(username: string): string | null { } export async function registerUser(username: string, password: string) { + // Vorprüfung für den Normalfall (freundliche Meldung ohne Ausnahme). Der eigentliche + // Schutz gegen ein Rennen ist die DB-Eindeutigkeitsregel unten -- ohne sie könnten zwei + // gleichzeitige Anfragen beide durch diese Prüfung rutschen. const existing = await prisma.user.findUnique({ where: { username } }); - if (existing) { - throw new Error("Dieser Benutzername ist bereits vergeben."); - } + if (existing) throw new UsernameTakenError(); + const passwordHash = await bcrypt.hash(password, 12); - return prisma.user.create({ data: { username, passwordHash } }); + try { + return await prisma.user.create({ data: { username, passwordHash } }); + } catch (e) { + // P2002 = Verletzung einer Eindeutigkeits-Constraint (hier: username @unique). + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + throw new UsernameTakenError(); + } + throw e; + } } export async function verifyUserCredentials(username: string, password: string) { const user = await prisma.user.findUnique({ where: { username } }); - if (!user) return null; + if (!user) { + // Gegen einen Dummy-Hash vergleichen, damit ein unbekannter Benutzer GENAU so lange + // dauert wie ein bekannter mit falschem Passwort. Sonst liesse sich aus der Antwortzeit + // ablesen, ob ein Benutzername existiert (siehe SPEZIFIKATION 3.1.2). + await bcrypt.compare(password, TIMING_DUMMY_HASH); + return null; + } const valid = await bcrypt.compare(password, user.passwordHash); return valid ? user : null; }