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" />