From e56f75a5e11926e692f86f02fc063b3283233d8d Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Mon, 2 Nov 2026 20:10:07 +0100 Subject: [PATCH] docs(auth): add spec for KTO-01 registration, login and password reset Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/1-auth-registrierung-anmeldung.md | 463 +++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 docs/specs/1-auth-registrierung-anmeldung.md diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md new file mode 100644 index 0000000..2cafcaf --- /dev/null +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -0,0 +1,463 @@ +# Spec #1 – KTO-01 Registrieren, anmelden und Passwort zurücksetzen + +| | | +|---|---| +| Issue | #1 (KTO-01) | +| Status | Entwurf (offene Fragen F1–F10 von Chris entschieden am 2026-11-02, Freigabe der Spec steht aus) | +| Autor | architect | +| Stand | 2026-11-02 | +| Abhängigkeit | Fundament-Issue #31 (blockiert #1) | +| Quellen | `requirements/20-r1-akzeptanzkriterien.md` § KTO-01 und § Querschnitt, `requirements/r1-klaerungen.md` (18, 21/47, 22, 45, N1–N5, N3a, N13), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100100_profiles.sql`, ADR-0001, ADR-0002 | + +## 1. Ziel + +Brettspieler können sich mit E-Mail, Passwort und Anzeigenamen registrieren (mit Bestätigung „mindestens 16“ und Zustimmung zu Datenschutzerklärung und Nutzungsbedingungen), ihre E-Mail-Adresse bestätigen, sich anmelden und angemeldet bleiben sowie ihr Passwort über einen einmaligen, zeitlich begrenzten Link zurücksetzen. Grundlage ist Supabase Auth (GoTrue) im Self-Hosting (ADR-0002); alle Auth-Einstellungen werden explizit im Repo festgelegt und dokumentiert (Klärung 18). + +Gleichzeitig ist #1 die **erste Story mit App-Code**. Das Flutter-Gerüst existiert noch nicht (`app/` fehlt) und entsteht im vorgelagerten **Fundament-Issue #31** (siehe 8.1). + +### Kernentscheidungen (Kurzfassung) + +| # | Entscheidung | Begründung | +|---|---|---| +| E1 | **App-Fundament als eigenes Chore-Issue #31** vor #1, nicht als Teil von #1 (entschieden, F10) | KAT-01/KAT-02 (R1.1) brauchen das Gerüst auch ohne Anmeldung; kleinere, prüfbare PRs; CLAUDE.md verlangt ein Issue je Änderung. `datenmodell-r1.md` Schritt 4 bündelt Gerüst und Anmeldung. Wir weichen davon nur im Zuschnitt ab, die Reihenfolge bleibt gleich. | +| E2 | **Ein Issue #1, drei PRs** (01a Registrierung, 01b Anmelden, 01c Passwort zurücksetzen), davor ein Backend-PR (Migration + Auth-Konfiguration) | Die Kriterien teilen Backend-Konfiguration und Domänenschicht. Getrennte Issues würden Abhängigkeiten nur verschieben. Die PRs referenzieren `Refs #1`, der letzte `Closes #1`. **Abweichung vom Issue-Vorschlag:** Krit. 6 liegt in 01b statt 01a, weil der Hinweis auf die ausstehende Bestätigung beim *Anmelden* erscheint und den LoginScreen braucht. Die Aktion „E-Mail erneut senden“ entsteht schon in 01a (CheckEmailScreen) und wird in 01b wiederverwendet. | +| E3 | **Links in Mails zeigen auf eine App-Seite `/auth/confirm?token_hash=…&type=…`**, nicht auf `{{ .ConfirmationURL }}`. Die Seite löst `verifyOTP` erst per Knopfdruck aus. | Funktioniert geräteübergreifend: kein PKCE-Verifier nötig, z. B. Registrierung am Handy und Mail am PC. Mail-Scanner (z. B. Safe Links) verbrauchen den Einmal-Link nicht. Die Einmaligkeit bleibt serverseitig erhalten (Kriterium 8). | +| E4 | **In R1 landen Mail-Links immer in der Web-App.** Universal Links/App Links (iOS/Android) sind nicht Teil von #1. | Die Web-App kann alle Schritte ausführen. Universal Links brauchen AASA/assetlinks-Hosting und Store-Signaturen; das ist eine eigene Aufgabe und sollte ein Folge-Issue werden. | +| E5 | **Mailsprache über `user_metadata.locale`**: Je Mailtyp gibt es *eine* Vorlage mit Go-Template-Verzweigung de/en (Standard Englisch, F6). Ein neuer DB-Trigger hält `auth.users.raw_user_meta_data.locale` synchron zu `profiles.locale`. | GoTrue kennt nur eine Vorlage je Typ und stellt `.Data` (= `user_metadata`) bereit. Die Profilsprache ist maßgeblich (Kriterium 11, KTO-11 Kriterium 2). Ein Send-Email-Hook (Edge Function + eigener SMTP-Versand) wäre mehr bewegliche Teile ohne zweiten Anwendungsfall. Er ist der Rückfall, falls der Spike B0 zeigt, dass Betreffzeilen `.Data` nicht auswerten. | +| E6 | **Validierung zweistufig**: Feldfehler im Client (Kriterien 2, 3, 10). Der Server erzwingt dieselben Regeln als Schutz (Trigger, `minimum_password_length = 10`). | Der Client gibt sofort Rückmeldung am Feld. Der Server vertraut nie dem Client. GoTrue meldet Trigger-Fehler nur als generischen 500er, deshalb ist der Client die nutzersichtbare Stelle. | +| E7 | **„Liste häufiger Passwörter“** wird nur im Client als gebündelte Liste geprüft; der Server erzwingt die Mindestlänge 10. **Kein Have I Been Pwned** (entschieden, F2): `GOTRUE_PASSWORD_HIBP_ENABLED=false`. | HIBP würde einen SHA-1-Präfix an einen US-Dienst (Cloudflare/HIBP) übertragen (Klärung 21/47). Außerdem gibt es HIBP in der lokalen Supabase CLI nicht. Restrisiko: Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen (R14). | +| E8 | **Drosselung und Linkgültigkeit**: Es gelten die Supabase-Standardwerte, die *explizit gepinnt* werden, identisch in `supabase/config.toml` (lokal/CI) und im Env-Block für Staging/Produktion. Die Werte stehen in `docs/auth-konfiguration.md` und werden in Tests über die Doku-ID referenziert. | Klärung 18. Die Standardwerte unterscheiden sich zwischen GoTrue-Binary, CLI-Vorlage und Docker-Setup (z. B. OTP-Gültigkeit 86400 s im Binary gegenüber 3600 s in der CLI, `enable_confirmations` in der CLI standardmäßig `false`). Ohne Pinning wären „die Standards“ nicht eindeutig. | +| E9 | **Sitzung bleibt bestehen** über den Standard-Speicher von `supabase_flutter` (Mobile: SharedPreferences, Web: localStorage). Kein Timebox, kein Inaktivitäts-Timeout. | Kriterium 5. Ein zusätzliches Paket (z. B. flutter_secure_storage) gibt es erst bei konkretem Bedarf, siehe Risiko R7. | + +## 2. Akzeptanzkriterien + +Übernommen aus Issue #1 / `20-r1-akzeptanzkriterien.md` § KTO-01. Referenz in Tests und PRs: „KTO-01 Kriterium n“. + +1. Gegeben ich habe kein Konto, wenn ich E-Mail-Adresse, Passwort und Anzeigenamen eingebe und bestätige, dass ich mindestens 16 Jahre alt bin, dann wird ein Konto angelegt und ich erhalte eine E-Mail zur Bestätigung. +2. Gegeben ich bestätige das Mindestalter nicht, wenn ich mich registrieren will, dann wird die Registrierung abgelehnt und ich sehe den Grund. +3. Gegeben das Passwort hat weniger als 10 Zeichen oder steht auf einer Liste häufiger Passwörter, wenn ich es eingebe, dann erklärt mir die App am Feld, was fehlt. +4. Gegeben die E-Mail-Adresse ist schon registriert, wenn ich mich damit registriere, dann erfahre ich nicht, ob ein Konto existiert, und es entsteht kein zweites Konto. +5. Gegeben ein bestätigtes Konto, wenn ich die richtigen Zugangsdaten eingebe, dann bin ich angemeldet und bleibe es auf diesem Gerät, bis ich mich abmelde. +6. Gegeben ein unbestätigtes Konto, wenn ich mich anmelde, dann werde ich auf die ausstehende Bestätigung hingewiesen und kann die E-Mail erneut senden lassen. +7. Gegeben falsche Zugangsdaten, wenn ich mich anmelde, dann sehe ich eine allgemeine Fehlermeldung, die nicht verrät, ob E-Mail oder Passwort falsch war; nach mehreren Fehlversuchen wird der Zugang zeitweise gebremst. Anzahl der Versuche und Dauer der Bremse folgen den Standards der Supabase-Auth-Konfiguration (self-hosted); die Werte sind im Repo dokumentiert und in den Tests referenziert (Klärung 18). +8. Gegeben ich habe das Passwort vergessen, wenn ich das Zurücksetzen anfordere, dann erhalte ich einen zeitlich begrenzten Link, mit dem ich ein neues Passwort setze; der Link funktioniert nur einmal. Die Gültigkeit von Reset- und Bestätigungslink folgt der Supabase-Auth-Konfiguration und ist im Repo dokumentiert (Klärung 18). +9. Gegeben ich habe das Passwort zurückgesetzt, dann bin ich auf allen anderen Geräten abgemeldet. +10. Gegeben ich registriere mich, wenn der Anzeigename leer ist oder länger als 30 Zeichen, dann kann ich mich nicht registrieren und sehe den Fehler am Feld; es gelten dieselben Regeln wie in KTO-02 Kriterium 1 (Klärung 22). +11. Gegeben Shuffle sendet mir eine E-Mail zur Bestätigung oder zum Zurücksetzen des Passworts, dann ist sie in der Sprache meines Profils; andere E-Mails verschickt Shuffle in R1 nicht, außer Bestätigungslink und Info-Mail beim Wechsel der E-Mail-Adresse (KTO-11 Kriterium 4) (Klärung 45, N2). + +**Relevante Querschnittskriterien** (Referenz „QUE-04 Kriterium n“ usw.): + +- **Datenschutz (QUE-04) Kriterium 1:** Datenschutzerklärung und Nutzungsbedingungen sind vor der Registrierung sichtbar, und die Zustimmung ist ausdrücklich (eigene Checkbox). +- **Datenschutz Kriterien 2 und 4:** In den Auth-Abläufen gibt es keine optionalen personenbeziehbaren Dienste, kein Tracking-SDK und keinen HIBP-Abgleich (F2). +- **Datenschutz, Nachweis der Zustimmung:** Gespeichert werden Zeitpunkt (`terms_accepted_at`) und Version (`terms_version`) der zugestimmten Rechtstexte (F5). +- **Sicherheit der Daten Kriterium 2:** Ohne Anmeldung keine privaten Daten. Router-Guard im Client, RLS im Server; die RLS für `profiles` ist bereits getestet. +- **Sicherheit der Daten Kriterium 3:** Neue DB-Objekte (Trigger) bekommen pgTAP-Tests. +- **Sprachen (QUE-05) Kriterien 1, 2, 4:** Alle Texte stehen in ARB, Fallback ist Englisch. Die Sprache bei der Registrierung ist die aktive Oberflächensprache (KTO-11 Kriterium 11, N3a). +- **Barrierefreiheit (QUE-06) Kriterien 1–4:** Labels, Fehler werden per Screenreader angesagt, Kontrast AA, 48 dp, 200 % Schrift, Tastaturbedienung im Web. +- **Zustände, Fehler und Netz Kriterien 2, 3:** Offline gibt es einen verständlichen Hinweis; Eingaben bleiben bei Fehlern erhalten. +- **Plattformen Kriterien 1, 2:** iOS, Android, Web; auf breiten Bildschirmen ein zentriertes Formular mit begrenzter Breite. + +## 3. Datenmodell + +### 3.1 Bestand (Migration `20261102100100_profiles.sql`) gegen die Kriterien + +| Regel | Bestand | Abgleich | Handlung | +|---|---|---|---| +| Mindestalter (Krit. 1, 2) | Trigger `handle_new_user` wirft `age_confirmation_required`, wenn `age_confirmed <> 'true'`; `profiles.age_confirmed_at not null` | passt | keine | +| Zustimmung Rechtstexte (QUE-04 Krit. 1) | Trigger wirft `terms_acceptance_required`; `terms_accepted_at not null` | Zeitpunkt passt; die Version der Texte wird nicht gespeichert | neue Spalte `terms_version` (3.2, entschieden F5) | +| Anzeigename 1–30 (Krit. 10) | Check `char_length(display_name) between 1 and 30`. Der Trigger **füllt aber leere Namen mit dem E-Mail-Präfix auf und kürzt zu lange Namen auf 30**. Ein Name nur aus Leerzeichen (`' '`) besteht den Check. | **widerspricht Krit. 10** (Registrierung muss abgelehnt werden, nicht stillschweigend korrigiert) und KTO-02 Krit. 1 („leer“ schließt reine Leerzeichen ein) | neue Migration (3.2) | +| Sprache (Krit. 11, KTO-11 Krit. 11) | `locale` aus `user_metadata.locale`, wenn `de`/`en`, sonst `'de'` | Der Fallback `'de'` und der Spalten-Standard `'de'` weichen von QUE-05 ab. Entschieden (F6): Ohne Angabe gilt die Gerätesprache, sonst Englisch. Die Gerätesprache kennt nur der Client; er sendet sie immer als aktive Oberflächensprache. Der Server fällt deshalb auf `'en'` zurück. | neue Migration (3.2): Standard und Trigger-Fallback `'en'` | +| Mailsprache folgt Profil (Krit. 11) | Mailvorlagen sehen nur `user_metadata`. `profiles.locale` und `user_metadata.locale` laufen auseinander, sobald KTO-11 die Sprache ändert. | **Lücke** | neue Migration (3.2): Sync-Trigger | +| RLS `profiles` | nur das eigene Profil lesen und ändern; Spaltenrechte; anlegen nur per Trigger | passt (pgTAP `01_profiles.test.sql`) | keine | + +### 3.2 Neue Migration `supabase/migrations/_auth_registration_rules.sql` + +Nur additiv: `create or replace function` und neue Objekte, keine Änderung an bestehenden Dateien. + +| Tabelle/Objekt | Spalte | Typ | Hinweis | +|---|---|---|---| +| `public.profiles` | `display_name` | text | **neuer Check** `profiles_display_name_not_blank`: `char_length(btrim(display_name)) between 1 and 30`. Er gilt auch für KTO-02 (Updates). Bestehende Seed-Zeilen erfüllen ihn. | +| `public.profiles` | `terms_version` | text, nullable | **neue Spalte** (F5): Version der zugestimmten Rechtstexte, z. B. `2026-11-draft`. Check `terms_version is null or char_length(terms_version) between 1 and 50`. Nullable nur wegen bereits vorhandener Zeilen (nur Entwicklung); für neue Konten erzwingt der Trigger einen Wert. **Nicht** in den Update-Rechten von `authenticated`, der Nutzer kann sie also nicht ändern. Der Zeitstempel der Zustimmung steht bereits in `terms_accepted_at` (Trigger setzt `now()`). Eine erneute Zustimmung nach einer Änderung der Rechtstexte ist nicht Teil von R1 (siehe 9.4). | +| `public.profiles` | `locale` | text | **Standard geändert** auf `'en'` (`alter column locale set default 'en'`, F6). Der Check `('de','en')` bleibt. | +| `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := btrim(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := btrim(meta->>'display_name')`. Ist der Wert `null`, leer oder länger als 30 Codepoints, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). | +| `public.sync_profile_locale_to_auth()` | – | Trigger-Funktion, `security definer`, `search_path = ''` | `after update of locale on public.profiles`, wenn sich der Wert ändert: `update auth.users set raw_user_meta_data = coalesce(raw_user_meta_data,'{}') \|\| jsonb_build_object('locale', new.locale) where id = new.id`. Ausführen nur für den Trigger: `revoke all … from public, anon, authenticated`. | + +**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(btrim(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). Bekannte Abweichung: Dart-`trim()` entfernt auch Unicode-Leerraum (z. B. NBSP), `btrim` nur Leerzeichen. Der Client sendet bereits getrimmte Werte, die Abweichung ist deshalb nur über die rohe API erreichbar und unkritisch. + +**Keine neuen Tabellen.** `auth.users` und `auth.sessions` verwaltet GoTrue. + +**RLS in Klartext:** Es gibt keine neuen Policies. Profile legt weiterhin nur der Trigger bei der Registrierung an. Jeder Nutzer liest und ändert nur sein eigenes Profil. Bestätigungszeitpunkte, `terms_version` und Löschmarke kann der Nutzer nicht ändern, lesen darf er sie (eigenes Profil). Der Sync-Trigger schreibt als Definer nur die Sprache des **eigenen** Nutzers (`where id = new.id`). Ein Nutzer kann so nur seine eigene Mailsprache beeinflussen. + +**Doku:** `docs/datenmodell-r1.md` (Regel Anzeigename, `terms_version`, Standard `locale = 'en'`, Sync-Trigger) und `supabase/README.md` werden im selben PR aktualisiert. + +## 4. Backend + +### 4.1 Supabase-Auth-Konfiguration (self-hosted) + +Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue-Env-Block in `docs/auth-konfiguration.md` (Staging/Produktion, ohne Secrets). Die Tabelle unten ist der **Entwurf**. Spike B0 prüft jeden Wert gegen die tatsächlich eingesetzten Versionen (CLI und Image `supabase/gotrue`) und trägt das Ergebnis in die Doku ein. Tests referenzieren die Zeilen-ID (z. B. `AUTH-CFG-07`). + +| ID | Einstellung | CLI `config.toml` | GoTrue-Env (self-hosted) | Wert (Entwurf) | Quelle / Anmerkung | Krit. | +|---|---|---|---|---|---|---| +| AUTH-CFG-01 | Site-URL | `auth.site_url` | `GOTRUE_SITE_URL` | lokal `http://localhost:3000`; Prod `https://shuffle.cshomelabs.work` (Platzhalter-Domain, F8, leicht änderbar); Staging-Subdomain legt devops fest | Basis für Mail-Links (`{{ .SiteURL }}`) | 1, 8 | +| AUTH-CFG-02 | Redirect-Allow-List | `auth.additional_redirect_urls` | `GOTRUE_URI_ALLOW_LIST` | nur eigene Web-URLs (`…/auth/confirm`) | Mail-Links bauen auf Site-URL auf | 1, 8 | +| AUTH-CFG-03 | Registrierung erlaubt | `auth.enable_signup`, `auth.email.enable_signup` | `GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_EXTERNAL_EMAIL_ENABLED=true` | an | – | 1 | +| AUTH-CFG-04 | E-Mail-Bestätigung Pflicht | `auth.email.enable_confirmations = true` | `GOTRUE_MAILER_AUTOCONFIRM=false` | Bestätigung Pflicht | CLI-Standard ist `false`, muss gesetzt werden. **Voraussetzung für den Enumeration-Schutz** (4.3). | 1, 4, 6 | +| AUTH-CFG-05 | Passwort-Mindestlänge | `auth.minimum_password_length` | `GOTRUE_PASSWORD_MIN_LENGTH` | **10** | O10; Standard wäre 6 | 3 | +| AUTH-CFG-06 | Zeichenklassen | `auth.password_requirements` | `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` | keine (leer) | Nicht gefordert | 3 | +| AUTH-CFG-07 | Gültigkeit Bestätigungs- und Reset-Link | `auth.email.otp_expiry` | `GOTRUE_MAILER_OTP_EXP` | **3600 s (1 h)** | Entschieden (F3). Entspricht dem CLI- und Cloud-Standard; das Self-hosted-Binary hätte 86400, deshalb im Env explizit setzen. Ein Wert für beide Linkarten. | 1, 8 | +| AUTH-CFG-08 | Mindestabstand zwischen Mails je Nutzer | `auth.email.max_frequency` | `GOTRUE_SMTP_MAX_FREQUENCY` | Staging/Prod: 60 s (Standard); lokal/CI: 1 s | Lokale Abweichung ist bewusst, sonst scheitert der Resend-Test (Krit. 6) direkt nach dem Signup an 429. Ein eigener Test prüft die 60 s auf Staging (Release-Checkliste). | 6, 8 | +| AUTH-CFG-09 | Mails pro Stunde (Instanz) | `auth.rate_limit.email_sent` | `GOTRUE_RATE_LIMIT_EMAIL_SENT` | lokal/CI: 100 (sonst blockieren E2E-Tests); Staging/Prod: **30** (Binary-Standard, entschieden F4) | Wahrscheinlich **instanzweit**; B0 prüft das. Risiko R3. | 1, 6, 8 | +| AUTH-CFG-10 | Drosselung Anmeldung (Passwort) | `auth.rate_limit.token_refresh` (laut Code) bzw. `sign_in_sign_ups` (laut CLI-Doku) | `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` | 150 je 5 min je IP, Burst 30 | GoTrue-Code: Der Passwort-Login nutzt den Token-Limiter (`RateLimitTokenRefresh/300 s`, Burst 30). B0 klärt empirisch, welcher Schlüssel greift. **Keine Sperre je Konto, nur je IP.** | 7 | +| AUTH-CFG-11 | Drosselung Registrierung, Reset, erneut senden | `auth.rate_limit.sign_in_sign_ups` | `GOTRUE_RATE_LIMIT_OTP` | 30 je 5 min je IP, Burst 30 | Signups, Recover, Resend teilen sich diesen Limiter | 1, 6, 8 | +| AUTH-CFG-12 | Drosselung Link-Prüfung | `auth.rate_limit.token_verifications` | `GOTRUE_RATE_LIMIT_VERIFY` | 30 je 5 min je IP | – | 8 | +| AUTH-CFG-13 | Client-IP hinter Proxy | – (lokal irrelevant) | `GOTRUE_RATE_LIMIT_HEADER` (z. B. `X-Forwarded-For`) + Proxy überschreibt den Header | Prod: gesetzt und nicht fälschbar | Ohne diese Einstellung teilen sich alle Nutzer die IP des Gateways (Risiko R2) | 7 | +| AUTH-CFG-14 | JWT-Laufzeit | `auth.jwt_expiry` | `GOTRUE_JWT_EXP` | 3600 s | Bleibt (entschieden F1). Bestimmt die Verzögerung bei Krit. 9: andere Geräte sind spätestens nach 60 min abgemeldet (bewusstes Restrisiko R8). | 5, 9 | +| AUTH-CFG-15 | Refresh-Token-Rotation | `auth.enable_refresh_token_rotation`, `refresh_token_reuse_interval` | `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED`, `…_REUSE_INTERVAL` | an, 10 s | Standard | 5, 9 | +| AUTH-CFG-16 | Sitzungsbegrenzung | `auth.sessions.timebox`, `inactivity_timeout` | `GOTRUE_SESSIONS_TIMEBOX`, `…_INACTIVITY_TIMEOUT` | **nicht gesetzt** | Krit. 5: angemeldet bis zum Abmelden | 5 | +| AUTH-CFG-17 | Mailvorlage Bestätigung | `auth.email.template.confirmation.{subject,content_path}` | `GOTRUE_MAILER_TEMPLATES_CONFIRMATION` (URL), `GOTRUE_MAILER_SUBJECTS_CONFIRMATION` | `supabase/templates/confirmation.html` | Self-hosted lädt Vorlagen **per URL**; ist die URL nicht erreichbar, fällt GoTrue still auf die englische Standardvorlage zurück (Risiko R5) | 1, 11 | +| AUTH-CFG-18 | Mailvorlage Reset | `auth.email.template.recovery.{…}` | `GOTRUE_MAILER_TEMPLATES_RECOVERY`, `…_SUBJECTS_RECOVERY` | `supabase/templates/recovery.html` | wie oben | 8, 11 | +| AUTH-CFG-19 | Benachrichtigungs-Mails (`password_changed`, `email_changed`, `phone_changed`, `mfa_*`, `identity_*`) | `auth.email.notification..enabled` | `GOTRUE_MAILER_NOTIFICATIONS__ENABLED` | **alle aus** | Krit. 11. `email_changed` (Info-Mail an die alte Adresse, N2) schaltet KTO-11 ein. | 11 | +| AUTH-CFG-20 | Magic Link / OTP-Login | – (kein Schalter) | – (kein Schalter) | Prod: `POST /auth/v1/otp` und `/auth/v1/magiclink` am Gateway sperren | Sonst kann jeder Magic-Link-Mails an beliebige Adressen auslösen (Verstoß gegen Krit. 11) und Konten ohne Passwort anlegen (Risiko R4) | 11, 3 | +| AUTH-CFG-21 | Leaked-Password-Prüfung (HIBP) | nicht in der CLI | `GOTRUE_PASSWORD_HIBP_ENABLED`, `…_FAIL_CLOSED` | **aus** (`GOTRUE_PASSWORD_HIBP_ENABLED=false`, explizit gesetzt) | Entschieden (F2): kein HIBP, keine Drittlandübermittlung | 3 | +| AUTH-CFG-22 | SMTP | lokal: CLI-Mailfänger (Mailpit bzw. Inbucket, je nach CLI-Version) | `GOTRUE_SMTP_*` | Staging/Prod: EU-Anbieter mit AVV, **Anbieter und Absender-Domain noch offen** (F7, später) | Release-Blocker über D1, kein Merge-Blocker | 1, 8, 11 | +| AUTH-CFG-23 | E-Mail-Wechsel beide Adressen bestätigen | `auth.email.double_confirm_changes` | `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED` | **in #1 nicht festlegen**, Entscheidung in KTO-11 | Der Standard `true` schickt einen Bestätigungslink auch an die alte Adresse; N2 verlangt dort nur eine Info-Mail | (KTO-11) | + +**Mailvorlagen** (`supabase/templates/confirmation.html`, `recovery.html`): + +- Je eine Vorlage mit Verzweigung `{{ if and .Data (eq (index .Data "locale") "de") }}…DE…{{ else }}…EN…{{ end }}`. Der Else-Zweig ist Englisch und entspricht damit dem neuen Profil-Standard `'en'` (F6). +- Die Betreffzeile wird gleich verzweigt. Zeigt B0, dass Betreffzeilen `.Data` nicht sehen, gilt der Rückfall: zweisprachiger Betreff „Confirm your email address / Bestätige deine E-Mail-Adresse“. +- Link: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email` (Bestätigung) bzw. `…&type=recovery` (Reset). Die genauen `type`-Werte prüft B0. +- Inhalt: kurzer Text, Gültigkeitsdauer in Klartext („1 Stunde“), Hinweis „Wenn du das nicht warst, ignoriere diese E-Mail“. Kein Tracking-Pixel, keine externen Bilder. + +### 4.2 RPCs / Edge Functions + +**Keine.** Alle Abläufe laufen über die GoTrue-REST-API (`supabase_flutter`): + +| Ablauf | Aufruf | Eingabe | Ausgabe / Fehler | +|---|---|---|---| +| Registrieren | `auth.signUp(email, password, data: {display_name, age_confirmed: true, terms_accepted: true, terms_version, locale})` | siehe links | 200 mit Nutzer, Sitzung `null` (Bestätigung offen). **Auch bei bereits registrierter Adresse 200** (4.3). `weak_password` (422, `reasons`), `over_request_rate_limit` (429), Trigger-Fehler → 500 „Database error saving new user“, Netzfehler | +| Bestätigung erneut senden | `auth.resend(type: OtpType.signup, email)` | E-Mail | 200 immer; 429 bei Drosselung | +| Anmelden | `auth.signInWithPassword(email, password)` | – | Sitzung; `invalid_credentials` (400), `email_not_confirmed` (400, **nur bei richtigem Passwort**, siehe 4.3), 429 | +| Reset anfordern | `auth.resetPasswordForEmail(email)` | E-Mail | 200 immer (keine Enumeration); 429 | +| Link einlösen | `auth.verifyOTP(tokenHash, type: email \| recovery)` | aus der URL | Sitzung; bei Reset Ereignis `passwordRecovery`; `otp_expired` bzw. ungültig (403) | +| Neues Passwort | `auth.updateUser(UserAttributes(password))` in der Recovery-Sitzung | Passwort | 200; `weak_password` (422), `same_password` (422). GoTrue beendet **alle anderen Sitzungen** des Nutzers (`LogoutAllExceptMe`) | + +### 4.3 Verhalten von GoTrue, auf das sich die Spec stützt (B0 prüft jeden Punkt) + +- **Krit. 4 (Enumeration):** Bei `autoconfirm=false` liefert `signUp` mit einer **bestätigten** Adresse 200 und einen erfundenen Nutzer (zufällige ID, leere `identities`). Es wird keine Mail verschickt und kein Konto angelegt. Bei einer **unbestätigten** Adresse geht die Bestätigungsmail erneut an das bestehende Konto, Passwort und Metadaten bleiben unverändert, und es kommt der echte Nutzer zurück. Die Antworten unterscheiden sich also auf API-Ebene; das ist das bekannte Restrisiko des Supabase-Standards (R6). **Die App zeigt nach jeder 200-Antwort denselben Bildschirm „Prüfe dein Postfach“ und wertet `user.identities` ausdrücklich nicht aus.** +- **Krit. 6/7:** `email_not_confirmed` kommt erst **nach** erfolgreicher Passwortprüfung. Wer das Passwort nicht kennt, erfährt nichts über den Bestätigungsstatus. +- **Krit. 9:** `UpdatePassword` mit Sitzungs-ID ruft `LogoutAllExceptMe` auf. Andere Refresh-Tokens werden ungültig, bereits ausgestellte Access-JWTs bleiben aber bis zu `jwt_expiry` (3600 s) gültig, weil PostgREST keine Sitzungen prüft. Chris hat diese Verzögerung von bis zu 60 min akzeptiert (F1, Restrisiko R8). Texte in App und Hilfe versprechen deshalb nicht „sofort“. +- **Krit. 8 (einmalig):** Mit `verifyOTP` ist der Token verbraucht. Eine neue Reset-Anforderung ersetzt den alten Token. Nach `updateUser(password)` löscht GoTrue alle Einmal-Tokens des Nutzers. + +### 4.4 Fehlerfälle → Domänenfehler (Mapping-Tabelle, verbindlich für `flutter-dev`) + +| GoTrue (`AuthException.code` / HTTP) | Domänenfehler | Anzeige | +|---|---|---| +| `invalid_credentials` | `AuthFailure.invalidCredentials` | allgemein: „E-Mail-Adresse oder Passwort ist nicht korrekt.“ (ein Text für beide Fälle) | +| `email_not_confirmed` | `AuthFailure.emailNotConfirmed` | Hinweis „Bestätige zuerst deine E-Mail-Adresse“ + Aktion „E-Mail erneut senden“ | +| `over_request_rate_limit`, `over_email_send_rate_limit`, HTTP 429 | `AuthFailure.rateLimited` | „Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.“ | +| `weak_password` (`reasons: length, characters`; `pwned` tritt ohne HIBP nicht auf, wird aber defensiv mitgemappt) | `AuthFailure.weakPassword(reasons)` | am Passwortfeld, je Grund | +| `same_password` | `AuthFailure.samePassword` | am Passwortfeld: „Wähle ein anderes als dein bisheriges Passwort.“ | +| `otp_expired`, `invalid` / 403 bei `verifyOTP` | `AuthFailure.linkInvalidOrExpired` | „Der Link ist abgelaufen oder wurde schon benutzt.“ + Aktion „Neuen Link anfordern“ | +| `email_address_invalid`, `validation_failed` | `AuthFailure.invalidEmail` | am E-Mail-Feld | +| HTTP 500 bei `signUp` (Trigger) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) | +| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ | +| sonst | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) | + +## 5. Frontend + +### 5.1 Voraussetzung: Fundament-Issue #31 (siehe 8.1) + +Vorhanden sein müssen: Projektgerüst, Riverpod mit Codegen, go_router mit `usePathUrlStrategy()`, l10n de/en mit englischem Fallback, Theme-Basis, Logger, Env-Konfiguration (`--dart-define-from-file`), Supabase-Client-Provider sowie `appLocaleProvider` (aktive Sprache: Gerätesprache de/en, sonst en). + +**Platzhalter (F8, F9, zentral und leicht änderbar):** + +| Wert | Platzhalter | Ort | Hinweis | +|---|---|---|---| +| App-Name | „Shuffle“ | Plattform-Manifeste, l10n | – | +| Bundle-/Application-ID | `work.cshomelabs.shuffle` | iOS/Android-Projekt | **vor dem Store-Release endgültig festlegen**; nachträgliche Änderung ist nach Veröffentlichung nicht möglich | +| Web-Domain | `shuffle.cshomelabs.work` | `SITE_URL`, AUTH-CFG-01/02 | – | +| Rechtstexte | `LEGAL_PRIVACY_URL`, `LEGAL_TERMS_URL` → Platzhalter-URLs | `app/env/*.json` | Chris liefert die Texte vor dem Release (R11) | +| Version der Rechtstexte | `TERMS_VERSION` (z. B. `2026-11-draft`) | `app/env/*.json` → `AppConfig.termsVersion` | wird bei jeder Registrierung als `terms_version` mitgeschickt (F5); bei neuen Rechtstexten hochzählen | + +### 5.2 Struktur `app/lib/features/auth/` + +``` +domain/ + auth_user.dart AuthUser (freezed): id, email, emailConfirmed + auth_status.dart sealed AuthStatus: signedOut | signedIn(AuthUser) | passwordRecovery(AuthUser) + auth_failure.dart sealed AuthFailure (siehe 4.4) + auth_repository.dart Interface (siehe unten) + validation/ + display_name_rule.dart trim + runes.length 1..30 → DisplayNameError.empty | tooLong + password_policy.dart minLength 10, häufige Passwörter → PasswordError.tooShort(missing) | common + email_format.dart einfache Syntaxprüfung (Feinprüfung macht der Server) + common_passwords.dart Interface CommonPasswordList (Implementierung in data/) +data/ + supabase_auth_repository.dart Implementierung + Fehler-Mapping 4.4 + asset_common_password_list.dart lädt assets/security/common-passwords.txt (lazy) +presentation/ + auth_status_provider.dart @riverpod Stream aus onAuthStateChange + register/ register_screen.dart, register_controller.dart + login/ login_screen.dart, login_controller.dart + check_email/ check_email_screen.dart (+ resend_controller.dart) + forgot_password/ forgot_password_screen.dart, forgot_password_controller.dart + email_link/ email_link_screen.dart, email_link_controller.dart + reset_password/ reset_password_screen.dart, reset_password_controller.dart +``` + +Die Domäne ist frei von Flutter- und Supabase-Imports. `DisplayNameRule` und `PasswordPolicy` werden in KTO-02 und KTO-11 wiederverwendet. Ein zweiter Anwendungsfall ist damit belegt, deshalb liegen beide in der Domäne von `auth`. Bei KTO-02 ist zu prüfen, ob `DisplayNameRule` nach `features/profile/domain` wandert. + +**`AuthRepository`:** + +```dart +Stream watchStatus(); +AuthStatus get currentStatus; +Future> signUp(RegistrationInput input); // email, password, displayName, ageConfirmed, termsAccepted, termsVersion, locale +Future> resendConfirmation(String email); +Future> signIn(String email, String password); +Future> requestPasswordReset(String email); +Future> verifyEmailLink(String tokenHash, EmailLinkType type); // confirmation | recovery +Future> setNewPassword(String password); +Future signOut(); // genutzt bei abgebrochener Recovery; das UI zum Abmelden kommt mit KTO-11 +``` + +`Result` ist ein einfacher eigener sealed Typ in `core/` (kein zusätzliches Paket). + +### 5.3 Routen (go_router) + +| Pfad | Screen | Zugang | +|---|---|---| +| `/` | Startseite (Fundament: Platzhalter; später QUE-09 bzw. öffentliche Katalogsuche) | öffentlich | +| `/login` | LoginScreen | nur abgemeldet; angemeldet → `/` | +| `/register` | RegisterScreen | nur abgemeldet | +| `/register/check-email` | CheckEmailScreen (E-Mail als `extra`, nicht in der URL) | öffentlich | +| `/forgot-password` | ForgotPasswordScreen → danach CheckEmailScreen (Modus Reset) | nur abgemeldet | +| `/auth/confirm?token_hash&type` | EmailLinkScreen | öffentlich | +| `/reset-password` | ResetPasswordScreen | **nur** im Status `passwordRecovery` | + +**Redirect-Regeln** (zentral im Router, `refreshListenable` aus `authStatusProvider`): + +- Status `passwordRecovery` → jede Route außer `/reset-password` leitet nach `/reset-password` um. +- Status `signedIn` auf `/login`, `/register` oder `/forgot-password` → `/`. +- Private Routen (kommen mit späteren Stories) → bei `signedOut` nach `/login?from=`. +- Status wechselt zu `signedOut`, etwa durch einen fehlgeschlagenen Refresh nach Krit. 9 → private Provider werden invalidiert (`ref.invalidate` über einen zentralen `onSignedOut`-Hook), Navigation zu `/login`. + +### 5.4 Screens und Zustände + +Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zustände 3). Senden ist während `loading` deaktiviert; der Knopf zeigt den Ladezustand. Leer- und Datenzustände im Listensinn gibt es hier nicht; die Zustände sind **idle / submitting / error(AuthFailure) / success**. + +**RegisterScreen** (01a, Krit. 1, 2, 3, 4, 10; QUE-04 Krit. 1) + +- Felder: E-Mail, Passwort (mit Sichtbarkeitsschalter), Anzeigename, Checkbox „Ich bin mindestens 16 Jahre alt“, Checkbox „Ich habe die [Datenschutzerklärung] gelesen und stimme den [Nutzungsbedingungen] zu“. Die Links öffnen die Rechtstexte (Platzhalter-URLs aus Env, `url_launcher`; F9). +- Validierung: beim Verlassen des Feldes und beim Absenden. Das Passwort wird zusätzlich beim Tippen geprüft (Krit. 3: „wenn ich es eingebe“). Fehler stehen am Feld: + - Passwort zu kurz: „Mindestens 10 Zeichen – es fehlen noch n.“ + - Passwort häufig: „Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.“ + - Anzeigename leer bzw. zu lang (`n/30`-Zähler sichtbar). + - Alter nicht bestätigt: „Shuffle ist erst ab 16 Jahren nutzbar.“ (Krit. 2: Grund sichtbar) + - Zustimmung fehlt: „Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.“ +- Absenden mit `locale = appLocaleProvider` (aktive Oberflächensprache = Gerätesprache de/en, sonst en; KTO-11 Krit. 11, F6) und `terms_version = AppConfig.termsVersion` (F5). +- Erfolg (jede 200-Antwort) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4). +- `weak_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP). + +**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; 429 → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. + +**LoginScreen** (01b, Krit. 5, 6, 7) + +- Felder E-Mail und Passwort, Links „Passwort vergessen?“ und „Konto erstellen“. +- Fehler: `invalidCredentials` → allgemeiner Fehler als Banner über dem Formular (nicht an einem Feld, um nichts zu verraten); `emailNotConfirmed` → Info-Banner mit Aktion „Bestätigungs-E-Mail erneut senden“; `rateLimited` → Drosselungshinweis. +- Erfolg → `from` oder `/`. Autofill: `AutofillGroup` plus `TextInput.finishAutofillContext()` bei Erfolg. + +**ForgotPasswordScreen** (01c, Krit. 8): Feld E-Mail, danach immer CheckEmailScreen (Modus Reset), unabhängig davon, ob die Adresse existiert. + +**EmailLinkScreen `/auth/confirm`** (Krit. 1, 8) + +- Liest `token_hash` und `type`. Ohne gültige Parameter → Fehlerzustand. +- Zeigt einen Knopf „E-Mail-Adresse bestätigen“ bzw. „Weiter zum neuen Passwort“. Erst der Klick löst `verifyEmailLink` aus (Schutz vor Mail-Scannern, E3). +- Bestätigung erfolgreich → Hinweis „E-Mail bestätigt“ → `/`. Der Nutzer ist auf diesem Gerät angemeldet. Auf Mobile-Web zusätzlich: „Du kannst dich jetzt auch in der App anmelden.“ +- Recovery erfolgreich → Status `passwordRecovery` → `/reset-password`. +- `linkInvalidOrExpired` → Fehlerzustand mit Aktion „Neuen Link anfordern“ (→ `/login` bzw. `/forgot-password`). + +**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ → `/` (Formulierung passend zu F1). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 prüft deshalb, ob das Recovery-JWT einen `amr`-Eintrag mit Methode `recovery` (bzw. `otp`) trägt: +- **Wenn ja:** `authStatusProvider` leitet `passwordRecovery` auch aus dem `amr`-Claim der wiederhergestellten Sitzung ab. Der Guard überlebt dann den Reload. +- **Wenn nein:** Die Recovery-Sitzung bleibt nach einem Reload eine normale Anmeldung. Das ist vertretbar, weil sie den Besitz des Postfachs nachweist, wie ein Magic Link. Es ist als Risiko R12 festgehalten. Das alte Passwort bleibt dann gültig, bis der Nutzer es ändert (KTO-11). + +### 5.5 Provider / Notifier + +- `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy). +- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`. +- Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue` bzw. freezed-Formzustand. Keine Logik in Widgets. + +### 5.6 Liste häufiger Passwörter (Krit. 3) + +- Asset `app/assets/security/common-passwords.txt`, ca. 10.000 Einträge (Größenordnung 100 KB), in Kleinschreibung, mit deutschen Einträgen (z. B. `passwort1234`, `hallo12345`). Nur Einträge mit mindestens 10 Zeichen sind relevant, kürzere fängt schon die Längenregel. +- Quelle und Lizenz prüft `test-engineer` in Aufgabe A1 und dokumentiert sie in `app/assets/security/README.md`. Kandidat: SecLists (MIT); deutsche Ergänzung mit Quellenangabe. Keine Liste ohne klare Lizenz. +- Vergleich ohne Groß-/Kleinschreibung, nach Trim. Die Liste wird lazy beim ersten Öffnen eines Passwortfeldes geladen; im Web fällt sie damit nicht beim Start ins Gewicht. + +### 5.7 Neue Pakete (pub.dev geprüft 2026-11-02) + +Stand der pub.dev-Suche (MCP `pub_dev_search`) am 2026-11-02: + +| Paket | Version (pub.dev) | Zweck | Lizenz | Herausgeber | Hinweis | Issue | +|---|---|---|---|---|---|---| +| `supabase_flutter` | 2.18.3 | Auth, Sitzung, Client | MIT | supabase.io (verifiziert) | 150/160 Punkte, sehr verbreitet | #31 | +| `flutter_riverpod` / `riverpod_annotation` / `riverpod_generator` | 3.4.3 / 4.0.7 / 4.0.9 | State mit Codegen | MIT | dash-overflow.net | Versionen von Annotation und Generator gemeinsam pinnen | #31 | +| `go_router` | 18.1.2 | Navigation, Web-URLs | BSD-3 (flutter/packages) | flutter.dev | Die Suche zeigt für die aktuelle Version keine Punkte/Lizenz an (vermutlich frisch veröffentlicht); Lizenz und Plattformen im PR auf der Paketseite bestätigen | #31 | +| `freezed` / `freezed_annotation` | 4.0.3 / 3.1.0 | Modelle | MIT | dash-overflow.net | – | #31 | +| `json_serializable` / `json_annotation` | 6.15.2 / 4.12.0 | JSON | BSD-3 | google.dev | – | #31 | +| `build_runner` | aktuell (dart.dev) | Codegen | BSD-3 | tools.dart.dev | im PR pinnen | #31 | +| `logging` | 1.3.0 | Logger-Basis | BSD-3 | dart.dev | 160/160 | #31 | +| `flutter_localizations`, `intl` | SDK / passend zum SDK | l10n | BSD-3 | flutter.dev / dart.dev | – | #31 | +| `mocktail` (dev) | 1.0.5 | Mocks | MIT | felangel.dev | 160/160 | #31 | +| `url_launcher` | 6.3.3 | Rechtstexte öffnen | BSD-3 | flutter.dev | iOS, Android, Web | #1 (01a) | + +Alle Pakete unterstützen iOS, Android und Web. `flutter-dev` pinnt die Versionen im Fundament-PR und vermerkt Abweichungen dort. + +## 6. Plattform-Besonderheiten + +| Thema | Web | iOS / Android | +|---|---|---| +| Sitzung (Krit. 5) | `localStorage`; bleibt nach Reload und Browser-Neustart erhalten. Kein Abgleich zwischen Tabs: ein zweiter Tab merkt die Abmeldung erst beim nächsten Refresh. | SharedPreferences (App-Sandbox); bleibt nach Neustart erhalten. Bei Neuinstallation ist die Sitzung weg. | +| Mail-Links (E3, E4) | `/auth/confirm` braucht `usePathUrlStrategy` **und** eine SPA-Rewrite-Regel im Webserver (alle Pfade → `index.html`, Aufgabe D1). | Links öffnen in R1 den Browser (Web-App). Universal/App Links kommen in einem Folge-Issue. | +| Autofill / Passwortmanager | `AutofillHints.email`, `newPassword`, `password`; Flutter Web unterstützt Passwortmanager nur eingeschränkt (bekannt, im PR vermerken) | Autofill iOS/Android inkl. „Starkes Passwort vorschlagen“; Associated Domains erst mit Universal Links | +| Tastatur | Tab-Reihenfolge = Lesereihenfolge, Enter sendet ab, sichtbarer Fokus (QUE-06 Krit. 4) | `TextInputType.emailAddress`, `TextInputAction.next/done` | +| Offline | Login/Registrierung/Reset brauchen Verbindung → Hinweis statt technischem Fehler (Querschnitt Zustände 2). Kein Connectivity-Paket; der Fehler wird beim Aufruf erkannt. | gleich | +| Layout (Plattformen Krit. 2) | ab 600 dp zentrierte Karte, max. 480 dp breit | Vollbild, scrollbar (200 % Schrift) | +| Push, Kamera, Dateien | nicht betroffen | nicht betroffen | + +## 7. Teststrategie + +Werkzeuge: `flutter_test` + `mocktail` (Unit/Widget), pgTAP (`supabase test db`), **Playwright** in `e2e/` mit zwei Projekten: +- `api`: HTTP direkt gegen das lokale GoTrue, plus Helfer für den CLI-Mailfänger (Inbucket bzw. Mailpit, siehe B0). Liest Mails, Betreff, Sprache und Links. +- `web`: Browser gegen den lokalen Web-Build (`flutter build web`, ausgeliefert auf Port 3000). + +Testnamen und Beschreibungen enthalten die Referenz („KTO-01 Kriterium n“). Drosselungs- und Gültigkeitstests referenzieren `AUTH-CFG-xx` und lesen die Zahlen aus einer gemeinsamen Konstantendatei `e2e/support/auth-config.ts`, die mit `docs/auth-konfiguration.md` übereinstimmen muss. + +### 7.1 Abdeckungsmatrix (Entwurf) + +| AK | Unit | Widget | Integration/E2E (Playwright) | RLS/pgTAP | Task | +|---|---|---|---|---|---| +| KTO-01 Krit. 1 | `RegisterController` ruft `signUp` mit allen Metadaten inkl. `locale` | gültiges Formular → Navigation zu CheckEmail | api: Signup → Nutzer + Profil existieren (inkl. `terms_version`), Bestätigungsmail im Fänger; web: kompletter Ablauf bis „E-Mail bestätigt“ | `05_auth_registration`: gültige Metadaten → Profil mit getrimmtem Namen, `locale`, `terms_version`, `terms_accepted_at` gesetzt | B1, B2, A2, T1–T3 | +| Krit. 2 | Formular-Validierung ohne Altersbestätigung → Fehler | Checkbox nicht gesetzt → Fehlertext sichtbar, kein Aufruf | api: Signup ohne `age_confirmed` → kein Nutzer angelegt (500) | bestehend `01_profiles` (age_confirmation_required) | B1, A2 | +| Krit. 3 | `PasswordPolicy`: 9/10 Zeichen, Liste (Groß/Klein, Trim), Codepoints; Mapping `weak_password` | Live-Fehler beim Tippen am Feld (beide Gründe); Server-`weak_password` erscheint am Feld | api: Signup mit 9 Zeichen → 422 `weak_password` (AUTH-CFG-05) | – | B2, A1, A2, A4 | +| Krit. 4 | Controller wertet `identities` nicht aus (Test: Antwort mit leeren `identities` → gleicher Erfolgspfad) | gleicher Bildschirm für neue und vorhandene Adresse | api: Signup mit bestätigter Adresse → 200, Anzahl `auth.users` unverändert, **keine** Mail; mit unbestätigter Adresse → 200, Passwort unverändert (Login mit altem Passwort nach Bestätigung möglich) | – | B2, A2, T3 | +| Krit. 5 | `authStatusProvider` mappt Ereignisse | App-Start mit gespeicherter Sitzung → angemeldeter Zustand | web: Login → Reload und neuer Browser-Kontext mit gleichem Storage → weiterhin angemeldet; api: `sessions.timebox` nicht gesetzt (Konfig-Check) | – | A3, T3 | +| Krit. 6 | Mapping `email_not_confirmed` | Hinweis + Resend-Aktion; Resend-Erfolg/429 | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 | +| Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | api: n+1 Logins mit falschem Passwort von einer IP → 429 nach dem Burst aus AUTH-CFG-10; web: Drosselungshinweis sichtbar | – | B0, B2, A3, T3 | +| Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322) und setzt den maßgeblichen Zeitstempel um `otp_expiry` + 1 s zurück. Welche Spalte das ist, klärt B0. Wert aus AUTH-CFG-07.; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 | +| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 | +| Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 | +| Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 | +| QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 | +| Sicherheit Krit. 2 | Router-Redirect-Logik | private Route abgemeldet → `/login` | – | bestehend (`anon` ohne Zugriff) | A1 | +| QUE-05 Krit. 2, 4 | – | Alle Screens in de und en ohne fehlende Schlüssel; Test „fehlende Übersetzung → Englisch“ im Fundament | – | – | F5, A2–A4 | +| QUE-06 Krit. 1–4 | – | `meetsGuideline(androidTapTargetGuideline, labeledTapTargetGuideline, textContrastGuideline)` je Screen; `textScaler` 2.0 ohne Overflow; Fehler als Live-Region | web: Ablauf nur per Tastatur | – | U1, T2 | +| Zustände Krit. 2, 3 | Mapping Netzfehler | Offline-Hinweis; Eingaben bleiben nach Fehler erhalten | – | – | A1–A4, T2 | + +**Nicht automatisiert testbar, manuelle Prüfung im PR:** echter Versand über den Staging-SMTP (sobald F7 entschieden ist), HIBP im Prod-Env aus (AUTH-CFG-21), IP-Erkennung hinter dem Produktions-Gateway (AUTH-CFG-13), Sperre `/otp` am Gateway (AUTH-CFG-20). Diese Punkte stehen als Checkliste in `docs/auth-konfiguration.md` und sind vor dem Release abzuhaken. + +## 8. Aufgaben + +### 8.1 Fundament-Issue #31 + +Angelegt am 2026-11-02: [#31 „chore: App-Fundament – Flutter-Gerüst und Supabase-Konfiguration“](https://git.cshomelabs.work/cschlaefke/Shuffle/issues/31). Milestone R1, Etappe R1.1, Labels `type/tech`, `epic/querschnitt`, `prio/must`, `size/l`. #1 hängt in Forgejo davon ab (blockierende Abhängigkeit); danach braucht es auch #3, #4 und #28. Branch: `chore/31-app-fundament`. Die Tasks F1–F8 stehen dort als Checkliste. + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| F1 | devops | Repo-Hygiene: `supabase init` → `supabase/config.toml` committen (zunächst ohne Auth-Anpassungen); `.gitignore` um `app/env/*.json` (Ausnahme `!app/env/*.example.json`), `supabase/.temp/`, `supabase/.branches/` ergänzen. Hinweis: `env/*.json` greift nur im Repo-Root. CI-Pfade prüfen. | – | +| F2 | flutter-dev | `flutter create app` (Plattformen ios, android, web; Platzhalter laut 5.1: App-Name „Shuffle“, ID `work.cshomelabs.shuffle`, zentral änderbar); `pubspec.yaml` mit den Paketen aus 5.7 (gepinnt); `analysis_options.yaml` (flutter_lints, strikt); Ordnerstruktur `core/`, `features/` | F1 | +| F3 | flutter-dev | `core/config`: `AppConfig` aus `--dart-define-from-file` (`SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SITE_URL`, `LEGAL_PRIVACY_URL`, `LEGAL_TERMS_URL`, `TERMS_VERSION`; Platzhalterwerte laut 5.1); `app/env/dev.example.json`, `app/env/ci.example.json`; `core/logging` (Wrapper um `package:logging`, kein `print`, keine personenbezogenen Daten im Log); globale Fehlerbehandlung; `supabaseClientProvider`; `Result`-Typ | F2 | +| F4 | flutter-dev | `core/routing`: go_router-Provider, `usePathUrlStrategy`, Redirect-Hook mit austauschbarer Auth-Quelle, Startseite `/` als Platzhalter, 404-Seite | F2 | +| F5 | flutter-dev | `core/l10n`: gen-l10n, `app_de.arb`/`app_en.arb`, englischer Fallback (QUE-05 Krit. 4), `appLocaleProvider` (Gerätesprache de/en, sonst en; manuelle Wahl folgt in KTO-11) | F2 | +| F6 | ui-designer | Theme-Basis Material 3: Farbschema mit geprüften AA-Kontrasten (hell; dunkel optional), Typografie, Abstände, Breakpoints 600/1024 dp, Fokus-Stil Web | F2 | +| F7 | test-engineer | Test-Grundlagen: `test/helpers/pump_app.dart` (l10n, ProviderScope-Overrides), Beispiel-Unit- und Widget-Test, Fallback-Test l10n | F3–F5 | +| F8 | devops | CI: Flutter-Job läuft mit `app/` (`env/ci.example.json`); `supabase-tests` läuft mit committeter `config.toml` | F1, F2 | + +### 8.2 Issue #1 – Backend-PR (vor 01a) + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| B0 | backend-dev | **Spike (max. ½ Tag)** gegen die lokale CLI. Zu klären: (a) Wertet die Betreffzeile `.Data.locale` aus? (b) Exakte `type`-Werte für `token_hash` (email/signup, recovery). (c) Welcher Limiter greift beim Passwort-Login, mit welchen Zahlen (AUTH-CFG-10)? (d) Ist `email_sent` instanzweit? (e) Welcher Mailfänger ist im Einsatz (Inbucket oder Mailpit, API)? (f) Wie erscheinen Trigger-Fehler bei `signUp` (500, Text)? (g) Welche `reasons` liefert `weak_password`? (h) Stimmen die GoTrue-Version der CLI und das geplante Docker-Image überein? (i) Welche Spalte prüft GoTrue für den Linkablauf: `auth.users.confirmation_sent_at`/`recovery_sent_at` oder `auth.one_time_tokens.created_at`? Das braucht der E2E-Ablauftest. (j) Trägt das JWT einer Recovery-Sitzung einen `amr`-Eintrag, der sie von einer normalen Anmeldung unterscheidet (5.4, R12)? Ergebnis als Abschnitt „Verifiziert“ in `docs/auth-konfiguration.md`. | F1 | +| B1 | backend-dev | Migration `_auth_registration_rules.sql` (3.2): neues `handle_new_user` (Anzeigename streng, `terms_version` Pflicht, `locale`-Fallback `'en'`), Spalte `terms_version` mit Check, Standard `locale = 'en'`, Check `profiles_display_name_not_blank`, Sync-Trigger `profiles.locale` → `auth.users`. pgTAP: `01_profiles.test.sql` anpassen (Erwartung „Fallback E-Mail-Präfix“ und „kürzen auf 30“ wird zu „wird abgelehnt“); Erwartung „ohne Sprache → `'de'`“ wird zu `'en'`. Fixtures in `01_profiles`, `02_catalog`, `03_collection` und `04_plays` um `display_name` (wo fehlend) und `terms_version` ergänzen (R9). Neu `05_auth_registration.test.sql` (Krit. 10, 11, Trim, `terms_version` Pflicht und nicht änderbar, `locale`-Fallback `'en'`, Sync nur für eigenes Konto). Doku `datenmodell-r1.md`, `supabase/README.md`. | F1 (nur für `supabase start`), sonst keine | +| B2 | backend-dev | `config.toml` Auth-Abschnitt gemäß 4.1 (alle Werte explizit); Mailvorlagen `supabase/templates/confirmation.html`, `recovery.html` (de/en, Links nach E3, barrierearmes HTML, nur Text und Links); Benachrichtigungen aus | B0 | +| B3 | backend-dev | `docs/auth-konfiguration.md`: Tabelle 4.1 mit verifizierten Werten (ID, CLI-Schlüssel, Env, Wert lokal, Wert Staging/Prod, Quelle, Test-Referenz), Env-Block für Self-Hosting ohne Secrets, Release-Checkliste (manuelle Prüfungen aus 7.1) | B0, B2 | +| D1 | devops | Betriebsanteil dokumentieren und vorbereiten (kein Eingriff in Server): Auslieferung der Mailvorlagen per URL für GoTrue; `GOTRUE_RATE_LIMIT_HEADER` + Proxy-Konfiguration (Header überschreiben, nicht anhängen); Gateway-Sperre `/auth/v1/otp` und `/auth/v1/magiclink`; SPA-Rewrite für die Web-App; `GOTRUE_PASSWORD_HIBP_ENABLED=false` und `GOTRUE_MAILER_OTP_EXP=3600` explizit; SMTP, sobald Anbieter und Absender-Domain entschieden sind (F7, später). **Blockiert das Release, nicht den Merge von #1** (Hosting-Setup nach ADR-0002 steht noch aus). | B3 | + +### 8.3 Issue #1 – UI und App (drei PRs) + +| # | Agent | Aufgabe | PR | abhängig von | +|---|---|---|---|---| +| U1 | ui-designer | Komponenten in `core/widgets/`: `AppTextField` (Label, Hilfetext, `errorText` als Live-Region), `AppPasswordField` (Sichtbarkeitsschalter mit Semantics-Label, Regelhinweis), `AppCheckboxField` (Fehlerzustand, Label mit eingebetteten Links, ganzes Label als Touch-Ziel ≥ 48 dp), `AppPrimaryButton` (Ladezustand), `AppFormScaffold` (zentrierte Karte ab 600 dp, scrollbar), `AppBanner` (Info/Fehler). Layout-Skizzen der sechs Auth-Screens, Textvorschläge de/en für alle Meldungen aus 4.4 und 5.4. | 01a | F6 | +| A1 | flutter-dev | Auth-Domäne und -Daten: Modelle, `AuthFailure`, Validatoren (`DisplayNameRule`, `PasswordPolicy`, `EmailFormat`), `AuthRepository` + Supabase-Implementierung mit Mapping 4.4, `authStatusProvider`, Router-Anbindung (Redirect-Regeln 5.3, `onSignedOut`-Hook), Asset „häufige Passwörter“ (Lizenzprüfung gemeinsam mit test-engineer) | 01a | F2–F5 | +| A2 | flutter-dev | RegisterScreen, CheckEmailScreen (inkl. Resend), EmailLinkScreen (Typ Bestätigung), Rechtstext-Links (`url_launcher`), ARB-Texte | 01a | A1, U1, B1, B2 | +| A3 | flutter-dev | LoginScreen (allgemeiner Fehler, Hinweis bei unbestätigtem Konto + Resend, Drosselung, Offline), Autofill, Weiterleitung `from` | 01b | A1, U1 (parallel zu A2 möglich) | +| A4 | flutter-dev | ForgotPasswordScreen, EmailLinkScreen (Typ Recovery), ResetPasswordScreen mit Recovery-Guard und Abbruch → `signOut`, Erfolgsmeldung Krit. 9 | 01c | A2 (EmailLinkScreen), A1, U1 | +| T1 | test-engineer | Unit-Tests: Validatoren, Fehler-Mapping (jede Zeile aus 4.4), Controller (mocktail), `authStatusProvider`, Router-Redirects | je PR | A1–A4 (begleitend) | +| T2 | test-engineer | Widget-Tests je Screen: Feldfehler, identische Meldung bei Krit. 7, Zustände, a11y-Guidelines, `textScaler` 2.0, de/en | je PR | U1, A2–A4 | +| T3 | test-engineer | Playwright `e2e/`: Projekte `api` (GoTrue + Mailfänger-Helfer), `web` und `throttle` (Drosselungstests, läuft zuletzt, R13); Tests je Kriterium nach 7.1; `e2e/support/auth-config.ts` mit Bezug auf AUTH-CFG-IDs; `e2e/support/db.ts` für Zeitstempel-Manipulation (nur lokal/CI). Jeder Test nutzt eigene, zufällige E-Mail-Adressen. Der Teil `api` kann schon nach B2 starten, unabhängig von der App. | api: 01a; web: je PR | B2 (api), A2–A4 (web) | +| D2 | devops | CI-Job `e2e` auf dem Runner `docker-host`: `supabase start` (mit Mailfänger), `flutter build web --dart-define-from-file=env/ci.example.json`, statisch auf Port 3000 ausliefern, `npx playwright test`; Artefakte (Report, Traces) bei Fehlern | 01a | F8, T3 | + +### 8.4 Reihenfolge und Parallelität + +``` +Phase 0 F1 (devops, klein; Issue #31) +Phase 1 parallel: F2→F3/F4/F5 (flutter-dev) | F6 (ui-designer) | B0→B2→B3, B1 (backend-dev) +Phase 2 parallel: F7, F8 | U1 | A1 | T3-api (nach B2) | D1 (Doku, nach B3) +Phase 3 PR 01a: A2 + T1/T2/T3-web + D2 +Phase 4 parallel: PR 01b: A3 (+Tests) | PR 01c: A4 (+Tests, braucht A2) +``` + +Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge ist Fundament (#31) → Backend → 01a → 01b/01c. Alle PRs: `Refs #1`, der letzte `Closes #1`. Branches: `feature/1-auth-backend`, `feature/1-auth-registrierung`, `feature/1-auth-anmeldung`, `feature/1-auth-passwort-reset`. Der aktuell ausgecheckte Branch `feature/1-auth-registrierung-anmeldung` trägt die Spec. + +## 9. Risiken & offene Fragen + +### 9.1 Risiken + +| # | Risiko | Auswirkung | Gegenmaßnahme | +|---|---|---|---| +| R1 | Supabase-Standardwerte unterscheiden sich je nach Quelle (Binary, CLI, Docker) und Version | Tests sind grün, Produktion verhält sich anders (Krit. 7, 8) | Alle Werte pinnen (E8), B0 verifiziert gegen das geplante Image, Release-Checkliste | +| R2 | Ohne `GOTRUE_RATE_LIMIT_HEADER` und saubere Proxy-Konfiguration sieht GoTrue nur die Gateway-IP, oder Clients fälschen `X-Forwarded-For` | Ein Angreifer drosselt alle Nutzer, oder die Drosselung lässt sich umgehen (Krit. 7) | D1; manuelle Prüfung vor dem Release | +| R3 | `email_sent` ist wahrscheinlich instanzweit (30 Mails/h) | Ab der 31. Registrierung oder dem 31. Reset pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen | B0 prüft das; 30/h reicht laut Chris zum Start (F4); Monitoring auf 429 `over_email_send_rate_limit`, Wert bei Bedarf erhöhen | +| R4 | GoTrue hat keinen Schalter gegen Magic Link/OTP (`/otp`) | Mails außerhalb von Krit. 11, Konten ohne Passwort | Gateway-Sperre (AUTH-CFG-20, D1). Lokal nicht absicherbar, deshalb nur dokumentiert | +| R5 | Self-hosted GoTrue lädt Mailvorlagen per URL; bei Fehlern still auf die englische Standardvorlage | Krit. 11 verletzt, ohne dass es auffällt | Vorlagen-URL im Health-Check, Release-Checkliste: Testmail de/en auf Staging | +| R6 | Enumeration auf API-Ebene: Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter Adresse einen erfundenen | Mit Skripten lässt sich die Existenz unbestätigter Konten erkennen | Supabase-Standardverhalten; die App verrät nichts (Krit. 4 bezieht sich auf das Erleben in der App). Bei Bedarf später Captcha oder eigener Proxy. | +| R7 | Sitzung im Web in `localStorage`, auf iOS in unverschlüsselten UserDefaults | Bei XSS bzw. Gerätezugriff ist das Refresh-Token lesbar | CSP für den Web-Build (devops), kein Fremd-JS; Secure Storage auf Mobile als möglicher Folgeschritt | +| R8 | Krit. 9: Access-JWTs bleiben bis zu 3600 s gültig | Ein anderes Gerät kann bis zu 1 h weiter auf Daten zugreifen | **Bewusst akzeptiertes Restrisiko** (Chris, F1, 2026-11-02); `jwt_expiry` bleibt 3600 s. App reagiert sofort auf fehlgeschlagenen Refresh; Texte versprechen nicht „sofort“. | +| R9 | Der neue Trigger lehnt Registrierungen ohne Anzeigenamen oder ohne `terms_version` ab | Bestehende pgTAP-Tests legen Nutzer ohne diese Angaben an und brechen: ohne `display_name` `01_profiles` (ben), `02_catalog` und `03_collection` (anna, ben); ohne `terms_version` alle vier Test-Dateien 01–04. `seed.sql` legt keine Nutzer an. | B1 ergänzt die Test-Fixtures (Tests sind keine Migrationen, Änderung zulässig) | +| R10 | `user_metadata` behält `display_name` aus der Registrierung, auch wenn KTO-02 ihn später ändert | Veralteter Wert im JWT, keine fachliche Wirkung | Maßgeblich ist immer `profiles`; Hinweis in KTO-02 | +| R11 | Rechtliche Prüfung (Mindestalter per Selbstbestätigung) steht aus, Datenschutzerklärung und Nutzungsbedingungen existieren noch nicht (Klärung 21/47, F9) | Release-Blocker | Bleibt bei Chris; bis dahin Platzhalter-URLs und `TERMS_VERSION`-Platzhalter, alles per Env änderbar | +| R12 | Abgebrochene Recovery (Tab geschlossen) bleibt nach einem Reload als normale Anmeldung bestehen, falls der `amr`-Claim keine Unterscheidung erlaubt (B0) | Nutzer ist angemeldet, ohne ein neues Passwort gesetzt zu haben | Vertretbar (Postfachbesitz nachgewiesen); bei vorhandenem `amr` wird der Guard daraus abgeleitet (5.4) | +| R13 | Drosselungstests leeren den gemeinsamen IP-Bucket des CI-Runners für bis zu 5 min | Nachfolgende Login-Tests scheitern zufällig | Drosselungstests (AUTH-CFG-10/11) in einem eigenen Playwright-Projekt, das zuletzt läuft, oder Neustart des Auth-Containers davor. **Keine** gefälschten `X-Forwarded-For`-Header zur Isolation. | +| R14 | Ohne HIBP prüft nur der Client die Liste häufiger Passwörter (F2) | Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen | Bewusst akzeptiert; der Server erzwingt die Mindestlänge 10, die Drosselung (AUTH-CFG-10) bremst Rateversuche | +| R15 | Bundle-/Application-ID ist ein Platzhalter (F8) | Nach der ersten Store-Veröffentlichung nicht mehr änderbar | Vor dem Store-Release festlegen (Release-Checkliste) | +| R16 | SMTP-Anbieter und Absender-Domain sind offen (F7) | Ohne SMTP keine Mails auf Staging/Prod | Lokal Mailpit/Inbucket; D1 ist Release-Blocker, kein Merge-Blocker | + +### 9.2 Fragen an Chris (alle entschieden am 2026-11-02) + +| # | Frage | Status | Entscheidung | Wirkung in der Spec | +|---|---|---|---|---| +| F1 | Reicht es, wenn andere Geräte nach einem Reset spätestens nach Ablauf des Access-Tokens (bis zu 60 min) abgemeldet sind? | entschieden | Ja. `jwt_expiry` bleibt 3600 s; bewusstes Restrisiko. | AUTH-CFG-14, 4.3, 5.4 (Erfolgstext), 7.1 Krit. 9, R8. A4 ist nicht mehr blockiert. | +| F2 | Have I Been Pwned aktivieren? | entschieden | Nein. Nur die im Client gebündelte Liste häufiger Passwörter, dazu die Server-Mindestlänge 10. | E7, AUTH-CFG-21 (`false` explizit), 4.4, 5.4, R14 | +| F3 | Gültigkeit von Bestätigungs- und Reset-Link? | entschieden | 1 Stunde für beide | AUTH-CFG-07, D1 | +| F4 | Reichen 30 Mails pro Stunde zum Start? | entschieden | Ja | AUTH-CFG-09, R3 | +| F5 | Version der zugestimmten Rechtstexte speichern? | entschieden | Ja: Spalte `terms_version`, Zeitstempel über `terms_accepted_at`. Der Client übergibt die Version aus einer konfigurierbaren Konstante (`TERMS_VERSION`). | 3.1, 3.2, 4.2, 5.1, 5.4, 7.1, B1, F3, R9 | +| F6 | Profilsprache ohne Angabe? | entschieden | Gerätesprache, sonst Englisch (QUE-05). Der Client sendet immer die aktive Sprache; der Server fällt auf `'en'` zurück (Standard und Trigger, neue Migration). | 3.1, 3.2, Mailvorlagen 4.1 (Else-Zweig Englisch), 5.4, 7.1, B1 | +| F7 | SMTP-Anbieter und Absender-Domain? | **vertagt** (Entscheidung später) | Lokal Mailpit/Inbucket; D1 bleibt Release-Blocker, kein Merge-Blocker | AUTH-CFG-22, D1, R16 | +| F8 | App-Identität? | entschieden (Platzhalter) | App-Name „Shuffle“, Bundle-ID `work.cshomelabs.shuffle`, Web-Domain `shuffle.cshomelabs.work`; leicht änderbar halten, Bundle-ID vor dem Store-Release festlegen | 5.1, AUTH-CFG-01, F2, R15 | +| F9 | Wo liegen die Rechtstexte? | entschieden | Sie existieren noch nicht; konfigurierbare Platzhalter-URLs, Chris liefert vor dem Release | 5.1, 5.4, R11 | +| F10 | Fundament als eigenes Issue? | entschieden, umgesetzt | Ja: Issue #31 angelegt, #1 hängt davon ab | E1, 5.1, 8.1, 8.4 | + +Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release. + +### 9.3 Ausstehende Prozessschritte + +- Diese Spec ist noch nicht committet. Nach dem Commit auf `feature/1-auth-registrierung-anmeldung`: Kommentar mit Link zur Spec in Issue #1 und Label `status/spec-review` setzen. +- Freigabe der Spec durch Chris, danach Umsetzung beginnend mit #31. + +### 9.4 Hinweise für KTO-11 (#28), nicht Teil von #1 + +- `double_confirm_changes` (AUTH-CFG-23) muss für N2 vermutlich `false` sein. Die Info-Mail an die alte Adresse kommt dann über die Benachrichtigung `email_changed` (AUTH-CFG-19). +- Passwort ändern mit aktuellem Passwort (N13): GoTrue-Einstellung `secure_password_change` bzw. `UpdatePasswordRequireCurrentPassword` prüfen; Recovery-Sitzungen sind davon ausgenommen (passt zu #1). +- Der Sync-Trigger aus B1 deckt die Mailsprache nach einem Sprachwechsel in KTO-11 bereits ab. +- Abmelden-UI: `AuthRepository.signOut()` existiert, das UI liefert KTO-11. +- Erneute Zustimmung bei geänderten Rechtstexten (Vergleich `profiles.terms_version` mit `TERMS_VERSION`) ist nicht Teil von R1. Falls gewünscht, als eigene Story erfassen; dafür bräuchte es dann eine RPC zum Aktualisieren von `terms_version`.