Harden authentication and put filter toggle and fields in one row

Security: per-username login throttling, constant-time unknown-user check,
Sec-Fetch-Site CSRF guard for writes, session rotation on login and
password change, absolute session lifetime, sessions ended on reset and
deactivation, last-admin protection, input length caps, ASCII usernames,
clickjacking headers. Covered by tests/authentication.test.ts.

Filter panel: toggle, fields and count share one row to save space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-28 21:30:30 +02:00
parent 8d2948cdb1
commit 5073d9342a
18 changed files with 683 additions and 81 deletions

View file

@ -33,7 +33,8 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
`role`, `active`, `mustChangePassword`.
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit
absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
@ -44,6 +45,28 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
## Schutzmechanismen
| Angriff | Schutz |
|---|---|
| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). |
| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash |
| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung |
| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten |
| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel |
| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) |
| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen |
| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; eigener Zugang ist in der Liste gesperrt |
| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` |
| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt |
| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben |
| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) |
| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt |
**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im
Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen
Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen.
## Bootstrap-Admin
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
@ -56,6 +79,7 @@ in `compose.yaml` selbst.
## Bewusst nicht umgesetzt (Stand jetzt)
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
- Konten-Sperre nach Fehlversuchen
- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich
begrenzte Drosselung, siehe oben)
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle