Shuffle/docs/specs/1-auth-registrierung-anmeldung.md
cschlaefke e56f75a5e1 docs(auth): add spec for KTO-01 registration, login and password reset
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:10:07 +01:00

62 KiB
Raw Blame History

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/<ts>_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.<typ>.enabled GOTRUE_MAILER_NOTIFICATIONS_<TYP>_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<AuthStatus> 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:

Stream<AuthStatus> watchStatus();
AuthStatus get currentStatus;
Future<Result<void, AuthFailure>> signUp(RegistrationInput input); // email, password, displayName, ageConfirmed, termsAccepted, termsVersion, locale
Future<Result<void, AuthFailure>> resendConfirmation(String email);
Future<Result<void, AuthFailure>> signIn(String email, String password);
Future<Result<void, AuthFailure>> requestPasswordReset(String email);
Future<Result<void, AuthFailure>> verifyEmailLink(String tokenHash, EmailLinkType type); // confirmation | recovery
Future<Result<void, AuthFailure>> setNewPassword(String password);
Future<void> 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=<pfad>.
  • 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 AuthChangeEvents: signedIn/tokenRefreshed/userUpdated → signedIn, passwordRecovery → passwordRecovery, signedOut → signedOut.
  • Je Screen ein @riverpod-Notifier (RegisterController usw.) mit Zustand AsyncValue<void> 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“. 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 <ts>_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.