From b5f926bcf71bd4db299e6c25881e8adfa1c52537 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 12:20:46 +0100 Subject: [PATCH] fix(auth): new passwords need at least 8 characters (FLAW-09) auth.ValidatePassword checks every way a password is set: setup, registration, profile, admin create and reset, reset by mail. Same rule in the browser before sending (lib/password.ts), with our own German message instead of the browser popup. Existing shorter passwords still log in. Profile and admin reset no longer trim the password, which made passwords with surrounding spaces unusable for login. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 2 +- Doku/architecture.md | 2 +- Doku/flaws.md | 3 +- .../src/features/auth/SetupAndAuthView.tsx | 29 ++++++++++++++----- .../features/settings/AdminSettingsView.tsx | 19 +++++++++--- .../features/settings/UserSettingsView.tsx | 11 +++++-- frontend/src/lib/password.ts | 8 +++++ internal/api/handlers_admin.go | 14 +++++++-- internal/api/handlers_auth.go | 20 ++++++++++--- internal/api/password_reset.go | 4 +-- internal/api/server_test.go | 11 ++++++- internal/auth/auth.go | 23 +++++++++++++++ internal/auth/auth_test.go | 22 ++++++++++++++ 13 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/password.ts create mode 100644 internal/auth/auth_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6c3ac43..374827a 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -13,7 +13,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | | POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen | -| POST | `/auth/reset-password` | `{token, password}` (mind. 4 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | +| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig | diff --git a/Doku/architecture.md b/Doku/architecture.md index fcb0d1d..7c66ea5 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -74,7 +74,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in ## Authentifizierung - **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`). -- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. +- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Logout** schreibt den Token-Hash in `revoked_tokens`. - **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)). - **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile. diff --git a/Doku/flaws.md b/Doku/flaws.md index effe319..a29c2b9 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -18,7 +18,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | | FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig | | FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** | -| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel | +| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** | @@ -90,6 +90,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`). ### FLAW-09: Kein Schutz gegen Brute-Force +- **Teilweise behoben:** Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. Offen: Rate-Limit und Sperre. - **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`. - **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar. - **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort. diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index fb36735..12eecfd 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -15,6 +15,7 @@ import { AuthStatus, SharingMode, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { LegalLinks } from '../../components/LegalLinks'; +import { passwordProblem } from '../../lib/password'; interface SetupAndAuthViewProps { authStatus: AuthStatus; @@ -74,6 +75,12 @@ export const SetupAndAuthView: React.FC = ({ const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + const resetProblem = passwordProblem(newPassword); + if (resetProblem) { + setError(resetProblem); + triggerHaptic(25); + return; + } if (newPassword !== newPasswordConfirm) { setError('Die eingegebenen Passwörter stimmen nicht überein.'); triggerHaptic(25); @@ -99,6 +106,12 @@ export const SetupAndAuthView: React.FC = ({ e.preventDefault(); setError(null); + const setupProblem = passwordProblem(setupPassword); + if (setupProblem) { + setError(setupProblem); + triggerHaptic(25); + return; + } if (setupPassword !== setupPasswordConfirm) { setError('Die eingegebenen Passwörter stimmen nicht überein.'); triggerHaptic(25); @@ -143,6 +156,12 @@ export const SetupAndAuthView: React.FC = ({ const handleRegisterOrInvite = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + const registerProblem = passwordProblem(password); + if (registerProblem) { + setError(registerProblem); + triggerHaptic(25); + return; + } setSubmitting(true); triggerHaptic(15); try { @@ -235,8 +254,7 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={setupPassword} onChange={(e) => setSetupPassword(e.target.value)} - placeholder="Mindestens 4 Zeichen" - minLength={4} + placeholder="Mindestens 8 Zeichen" required className="w-full h-8 px-2.5 input-field" /> @@ -248,7 +266,6 @@ export const SetupAndAuthView: React.FC = ({ value={setupPasswordConfirm} onChange={(e) => setSetupPasswordConfirm(e.target.value)} placeholder="Passwort erneut eingeben" - minLength={4} required className={`w-full h-8 px-2.5 input-field ${ setupPasswordConfirm && setupPassword !== setupPasswordConfirm @@ -529,7 +546,7 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} - minLength={4} + placeholder="Mindestens 8 Zeichen" required autoFocus autoComplete="new-password" @@ -542,7 +559,6 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} - minLength={4} required autoComplete="new-password" className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs" @@ -716,8 +732,7 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="Mindestens 4 Zeichen" - minLength={4} + placeholder="Mindestens 8 Zeichen" required className="w-full h-8 px-2.5 input-field" /> diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index bdc30af..85cedc0 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -25,6 +25,7 @@ import { UserRole, } from '../../lib/types'; import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api'; +import { passwordProblem } from '../../lib/password'; import { UserAvatar } from '../../components/UserAvatar'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { CountBadge } from '../../components/CountBadge'; @@ -151,6 +152,11 @@ export const AdminSettingsView: React.FC = ({ const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); setStatusMsg(null); + const problem = passwordProblem(newUserPassword); + if (problem) { + setStatusMsg({ type: 'err', text: problem }); + return; + } triggerHaptic(15); try { const created = await api.adminCreateUser({ @@ -235,8 +241,14 @@ export const AdminSettingsView: React.FC = ({ const handleConfirmResetPassword = async (userId: string) => { if (!resetPwdVal.trim()) return; setStatusMsg(null); + const problem = passwordProblem(resetPwdVal); + if (problem) { + setStatusMsg({ type: 'err', text: problem }); + return; + } try { - await api.adminResetPassword(userId, resetPwdVal.trim()); + // Not trimmed: login compares the password exactly as typed + await api.adminResetPassword(userId, resetPwdVal); setResetPwdUserId(null); setResetPwdVal(''); setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' }); @@ -555,8 +567,7 @@ export const AdminSettingsView: React.FC = ({ type="password" value={newUserPassword} onChange={(e) => setNewUserPassword(e.target.value)} - placeholder="Mind. 4 Zeichen" - minLength={4} + placeholder="Mind. 8 Zeichen" required className="w-full min-w-0 h-8 px-2.5 input-field" /> @@ -843,7 +854,7 @@ export const AdminSettingsView: React.FC = ({ type="password" value={resetPwdVal} onChange={(e) => setResetPwdVal(e.target.value)} - placeholder="Neues Passwort eingeben…" + placeholder="Neues Passwort (mind. 8 Zeichen)…" className="flex-1 h-7 px-2 input-field" />