95 KiB
Spec #1 – KTO-01 Registrieren, anmelden und Passwort zurücksetzen
| Issue | #1 (KTO-01) |
| Status | freigegeben 2026-11-02, angepasst an Spike-Ergebnisse (B0, siehe Änderungsprotokoll § 10) |
| 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, supabase/migrations/20261102212208_auth_registration_rules.sql, docs/specs/1-auth-spike-ergebnisse.md (Spike B0, maßgeblich für das GoTrue-Verhalten), 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. Spike B0 hat bestätigt, dass Betreff und Text .Data.locale auswerten (Spike-Dokument (a)); ein Rückfall ist nicht nötig. |
| 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 als HTTP 500; der Body enthält zwar die Meldung des Triggers (z. B. display_name_invalid, Spike A4; gilt auch für U+200B im Anzeigenamen), das Format ist aber nicht vertraglich zugesichert. Der Client bleibt deshalb 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“.
- 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.
- Gegeben ich bestätige das Mindestalter nicht, wenn ich mich registrieren will, dann wird die Registrierung abgelehnt und ich sehe den Grund.
- 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.
- 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.
- 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.
- 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.
- 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).
- 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).
- Gegeben ich habe das Passwort zurückgesetzt, dann bin ich auf allen anderen Geräten abgemeldet.
- 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).
- 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
profilesist 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; Entscheidung Chris: U+200B verboten) | 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(public.trim_ws(display_name)) between 1 and 30. Er gilt auch für KTO-02 (Updates). Bestehende Seed-Zeilen erfüllen ihn. Zweiter Check profiles_display_name_no_zwsp: strpos(display_name, chr(8203)) = 0 (U+200B verboten, Update → 23514). Seeds und Bestand enthalten kein U+200B. |
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 := public.trim_ws(meta->>'terms_version'); ist der Wert null, leer oder länger als 50 Zeichen, wirft der Trigger terms_version_required (P0001). (c) display_name := public.trim_ws(meta->>'display_name'). Ist der Wert null, leer, länger als 30 Codepoints oder enthält U+200B, 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(public.trim_ws(…)), der Client mit value.trim().runes.length, nicht mit String.length (UTF-16). trim_ws entfernt denselben Leerraum wie Dart-trim() (auch Unicode-Leerraum wie NBSP), Client und Server sind damit gleichwertig; es gibt keine bekannte Abweichung mehr (frühere Fassung: btrim nur Leerzeichen, siehe § 10). Verbotenes Zeichen (Entscheidung Chris): Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: display_name_invalid bzw. Check profiles_display_name_no_zwsp, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen.
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). Spike B0 hat die Werte gegen CLI 3.0.0 und das Image supabase/gotrue:v2.197.0 geprüft (docs/specs/1-auth-spike-ergebnisse.md); die Tabelle unten enthält die Ergebnisse. Ausnahmen, die lokal ohne Wirkung sind: AUTH-CFG-09 (email_sent) und alle IP-Drosselungen (AUTH-CFG-10 bis -12, nur mit AUTH-CFG-13 wirksam). Tests referenzieren die Zeilen-ID (z. B. AUTH-CFG-07).
| ID | Einstellung | CLI config.toml |
GoTrue-Env (self-hosted) | Wert | 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.enabled (CLI 3.x; frühere Bezeichnung 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), zusammen mit AUTH-CFG-24. |
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) | Instanzweit (B0 (d) bestätigt). Wirkt nur über das Env (Self-Hosting); in der lokalen CLI ohne Wirkung (CLI setzt GOTRUE_RATE_LIMIT_EMAIL_SENT=360000), in config.toml nur dokumentieren und so kennzeichnen. Risiko R3. |
1, 6, 8 |
| AUTH-CFG-10 | Drosselung Anmeldung (Passwort) | auth.rate_limit.token_refresh |
GOTRUE_RATE_LIMIT_TOKEN_REFRESH |
150 je 5 min je IP, Burst 30 | B0 (c) bestätigt: Der Passwort-Login nutzt den Token-Limiter; Login #31 einer IP → 429 over_request_rate_limit, danach ca. 0,5 Versuche/s. Nur wirksam mit AUTH-CFG-13 und nur für Requests, die den Header tragen. Die 429-Antwort hat keinen Retry-After- oder X-RateLimit-*-Header. 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 | Für Signup bestätigt (B0 (c)); dass Recover und Resend denselben Limiter teilen, ist nur aus dem Code abgeleitet. Nur wirksam mit AUTH-CFG-13. | 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 | Nur wirksam mit AUTH-CFG-13. | 8 |
| AUTH-CFG-13 | Client-IP hinter Proxy | – (CLI kann den Header nicht setzen) | GOTRUE_RATE_LIMIT_HEADER (z. B. X-Forwarded-For) + Proxy überschreibt den Header |
Staging/Prod: Pflicht, gesetzt und nicht fälschbar | B0 (c), A2: Ohne diese Einstellung gibt es gar keine IP-Drosselung, auch mit ihr werden Requests ohne den Header nicht gedrosselt. GoTrue darf deshalb nur über den Proxy erreichbar sein, der den Header setzt. 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). B0 (A7): beide Endpunkte sind nicht abschaltbar; /otp legt mit gültigen Metadaten ein Konto an, der Trigger ersetzt die Sperre also nicht. |
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 v1.31.3 (Container heißt historisch supabase_inbucket_…, API http://127.0.0.1:54324/api/v1) |
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) |
| AUTH-CFG-24 | SMS-Autoconfirm aus (Voraussetzung Enumerationsschutz) | auth.sms.enable_confirmations = true |
GOTRUE_SMS_AUTOCONFIRM=false; in der Supabase-Self-Hosting-Vorlage über ENABLE_PHONE_AUTOCONFIRM=false in docker/.env (die Vorlage docker/.env.example setzt true!) |
an (SMS_AUTOCONFIRM=false) |
B0 (A1): SMS ist nicht im Einsatz, die Einstellung steuert aber trotzdem den Enumerationsschutz beim E-Mail-Signup. Mit SMS_AUTOCONFIRM=true (CLI-Standard und Self-Hosting-Vorlage) liefert Signup mit bestätigter Adresse 422 user_already_exists und verrät damit das Konto. Ein API-Test (7.1 Krit. 4) sichert die Einstellung ab. |
4 |
| AUTH-CFG-25 | Aktuelles Passwort beim Passwortwechsel verlangen | auth.email.secure_password_change |
GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION (Env-Name bei B3 gegen das Image v2.197.0 bestätigen) |
false (Standard, explizit gesetzt); in #1 nicht entschieden, Entscheidung in KTO-11 (N13) |
Recovery-Sitzungen (Krit. 8) sind davon nicht betroffen; für #1 ohne Wirkung. Wie AUTH-CFG-23 nur als Platzhalter-ID für KTO-11 geführt. | (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 }}(indexist sicher, wenn der Schlüssel fehlt; B0 (a)). Der Else-Zweig ist Englisch und entspricht damit dem neuen Profil-Standard'en'(F6). - Die Betreffzeile wird gleich verzweigt (B0 (a) bestätigt, kein zweisprachiger Rückfall-Betreff nötig).
- Link:
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email(Bestätigung) bzw.…&type=recovery(Reset). B0 (b) bestätigt:email(auchsignup) undrecoverywerden akzeptiert. - 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 / over_email_send_rate_limit (429), Trigger-Fehler → HTTP 500, Header X-Sb-Error-Code: unexpected_failure, Body {"code":"P0001","message":"display_name_invalid"} (bzw. age_confirmation_required, terms_acceptance_required, terms_version_required; B0 (f)), Netzfehler |
| Bestätigung erneut senden | auth.resend(type: OtpType.signup, email) |
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) |
200 (keine Enumeration); 429. Ausnahme bei erschöpftem Mail-Limit: bestehende Adresse 429 over_email_send_rate_limit, unbekannte 200 (Seitenkanal nur auf API-Ebene, die App zeigt in beiden Fällen denselben Screen, 4.4, F12) |
|
| 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 (verifiziert in B0, siehe docs/specs/1-auth-spike-ergebnisse.md)
- Krit. 4 (Enumeration): Voraussetzung sind beide Autoconfirm-Schalter auf
false:GOTRUE_MAILER_AUTOCONFIRM=false(AUTH-CFG-04) undGOTRUE_SMS_AUTOCONFIRM=false(AUTH-CFG-24). Ist SMS-Autoconfirm an (CLI-Standard, Self-Hosting-Vorlage), liefertsignUpmit einer bestätigten Adresse 422user_already_exists(B0, A1). Mit beiden Schaltern aus (bestätigt):signUpmit einer bestätigten Adresse liefert 200 und einen erfundenen Nutzer (zufällige ID, leereidentities). Es wird keine Mail verschickt und kein Konto angelegt; das ursprüngliche Passwort bleibt gültig. 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 wertetuser.identitiesausdrücklich nicht aus. - Krit. 4 / Reset, Seitenkanal bei erschöpftem Mail-Limit: Ist
email_sent(AUTH-CFG-09, instanzweit) erschöpft, antwortet/recoverfür eine bestehende Adresse mit 429over_email_send_rate_limit, für eine unbekannte mit 200 (B0 (d), getestet). Regel dahinter: 429 kommt genau dann, wenn GoTrue eine Mail senden würde. Für Signup und Resend ist das abgeleitet, nicht getestet: Resend → unbestätigte Adresse 429, unbekannte und bestätigte 200; Signup umgekehrt → neue oder unbestätigte Adresse 429, bestätigte 200. Über die rohe API lässt sich während der Sperre die Existenz eines Kontos erkennen (akzeptiert, R3, F12). In der App bleibt der Kanal verdeckt (F12): Beiover_email_send_rate_limitzeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ wie bei 200 (Mapping 4.4). Damit bleibt Krit. 4 in der App erfüllt. - Krit. 6/7:
email_not_confirmedkommt erst nach erfolgreicher Passwortprüfung. Falsches Passwort und unbekannte Adresse liefern beideinvalid_credentialsmit identischer Meldung (bestätigt). - Krit. 7 (Drosselung): Nur je IP und nur, wenn
GOTRUE_RATE_LIMIT_HEADERgesetzt ist und der Request den Header trägt (AUTH-CFG-13). Requests ohne den Header werden nicht gedrosselt. Die 429-Antwort trägt keinRetry-After; die App kann keine Wartezeit nennen. - Krit. 9:
UpdatePasswordmit Sitzungs-ID ruftLogoutAllExceptMeauf. Andere Refresh-Tokens werden ungültig (refresh_token_not_found, bestätigt), bereits ausgestellte Access-JWTs bleiben aber bis zujwt_expiry(3600 s) gültig, weil PostgREST keine Sitzungen prüft (bestätigt: GoTrue/userlehnt ab, PostgREST akzeptiert). 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
verifyOTPist der Token verbraucht; ein zweites Einlösen ergibt 403otp_expired(bestätigt). Eine neue Reset-Anforderung ersetzt den alten Token. NachupdateUser(password)löscht GoTrue alle Einmal-Tokens des Nutzers. Maßgeblich für den Ablauf istauth.users.recovery_sent_atbzw.confirmation_sent_at, nichtauth.one_time_tokens.created_at(B0 (i)).
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_email_send_rate_limit (429) bei signUp, resetPasswordForEmail und resend |
AuthFailure.emailSendLimited |
Kein Fehler sichtbar (F12), wo die Adresse frei eingegeben werden kann: RegisterController und ForgotPasswordController behandeln ihn wie Erfolg und navigieren zum CheckEmailScreen (gleicher Text wie bei 200, Krit. 4); der ResendController auf dem CheckEmailScreen bestätigt ihn wie einen erfolgreichen Versand. Ausnahme: „E-Mail erneut senden“ im LoginScreen nach email_not_confirmed (Krit. 6, Passwort war richtig, nichts zu verbergen) zeigt den Drosselungshinweis wie rateLimited. Der ResendController erhält dafür den Aufrufkontext (ResendOrigin.checkEmail | login). Nur ins Log (ohne E-Mail-Adresse). Damit Nutzer bei verzögerter Mail nicht ratlos sind, enthält der CheckEmailScreen immer (unabhängig vom Fall) den neutralen Zusatz „Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.“ Die Entscheidung trifft der Controller, nicht das Widget. |
over_request_rate_limit (IP-Drosselung, unabhängig vom Konto), emailSendLimited beim Resend aus dem LoginScreen, sonstiges HTTP 429 |
AuthFailure.rateLimited |
„Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.“ (Ohne konkrete Dauer: GoTrue sendet kein Retry-After, B0 (c).) |
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 mit bekannter Trigger-Meldung bei signUp (Trigger; Body {"code":"P0001","message":"<grund>"}, Header X-Sb-Error-Code: unexpected_failure) |
AuthFailure.registrationRejected |
allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) Mapping-Regel (F13): maßgeblich sind der HTTP-Status 500 beim signUp und eine bekannte Trigger-Meldung (sie entscheidet über Banner oder neutral; ein 500 ohne bekannte Meldung, z. B. SMTP-Ausfall, ist unexpected und neutral), nicht code == 'P0001' (welches Feld AuthException.code in supabase_flutter füllt, ist offen; vermutlich unexpected_failure). Die Trigger-Meldung (display_name_invalid, age_confirmation_required, terms_acceptance_required, terms_version_required) steht in AuthException.message und wird (nur) zur Unterscheidung Eingabefehler/SMTP-Ausfall und fürs Log genutzt; das Format ist nicht zugesichert (T3 sichert es ab). A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. |
HTTP 5xx mit Status bei signUp (ohne bekannte Trigger-Meldung), resetPasswordForEmail, resend (z. B. SMTP-Ausfall, unexpected_failure; F13) |
AuthFailure.unexpected |
Kein Fehler sichtbar (F13): Register-, ForgotPassword- und Resend-Controller behandeln ihn wie Erfolg (neutraler CheckEmailScreen bzw. Resend-Bestätigung, auch im LoginScreen). Das Repository loggt Operation, Fehlerart, Status und Code (ohne E-Mail). Sichtbar bleiben nur eine Allowlist: invalidEmail, weakPassword, registrationRejected (bekannter Grund), network (ohne Status) und rateLimited. |
AuthRetryableFetchException, SocketException, ClientException, Timeout (nicht HTTP 5xx mit Status bei signUp/resetPasswordForEmail/resend, siehe F13-Zeile oben) |
AuthFailure.network |
Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ |
sonst (außer signUp/recover/resend auf CheckEmail, Register und Forgot: dort neutral, F13) |
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);
void finishPasswordRecovery(); // beendet den Recovery-Guard nach dem Passwortwechsel (01c, siehe 5.5)
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 |
/forgot-password/check-email |
CheckEmailScreen im Modus Reset (Modus im Pfad, kein extra, übersteht einen Reload) |
öffentlich |
/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-passwordleitet nach/reset-passwordum. - Status
signedInauf/login,/registeroder/forgot-password→/. - Private Routen (kommen mit späteren Stories) → bei
signedOutnach/login?from=<pfad>. - Status wechselt zu
signedOut, etwa durch einen fehlgeschlagenen Refresh nach Krit. 9 → private Provider werden invalidiert (ref.invalidateüber einen zentralenonSignedOut-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, zu lang (
n/30-Zähler sichtbar) oder mit U+200B an beliebiger Stelle:DisplayNameRulemeldet U+200B als Fehler am Feld, mit derselben Meldung wie „ungültig“ (kein eigener Text). - 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) undterms_version = AppConfig.termsVersion(F5). - Erfolg (jede 200-Antwort und
emailSendLimited, F12) →/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_passwordvom 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. go_router 18 würde extra per JSON in den Browser-Verlaufszustand (History-State) schreiben (RouteMatchListCodec, match.dart); das verhindert der VolatileExtraCodec am Router (extraCodec, extra bleibt nur im Speicher). Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), 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; emailSendLimited → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); rateLimited (IP-Drosselung) → 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“ (Resend mitResendOrigin.login: hier zeigt auchemailSendLimitedden Drosselungshinweis, F12);rateLimited→ Drosselungshinweis. - Erfolg →
fromoder/. Autofill:AutofillGroupplusTextInput.finishAutofillContext()bei Erfolg.
ForgotPasswordScreen (01c, Krit. 8): Feld E-Mail, danach immer CheckEmailScreen (Modus Reset), unabhängig davon, ob die Adresse existiert, auch bei emailSendLimited (F12).
EmailLinkScreen /auth/confirm (Krit. 1, 8)
- Liest
token_hashundtype. Ohne gültige Parameter → Fehlerzustand. - Zeigt einen Knopf „E-Mail-Adresse bestätigen“ bzw. „Weiter zum neuen Passwort“. Erst der Klick löst
verifyEmailLinkaus (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“ (→/loginbzw./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) mit Knopf „Weiter“ → / (Guard-Ende, siehe 5.5 „Ergänzung 01c“). „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 (j) hat gezeigt: Das Recovery-JWT trägt amr = [{"method":"otp"}], identisch zur Sitzung nach dem Bestätigungslink; eine Recovery-Sitzung ist daran nicht erkennbar. Verbindlich deshalb:
- Der Guard
passwordRecoverylebt nur im laufenden Prozess (EreignispasswordRecoveryausonAuthStateChange).authStatusProviderleitet ihn nicht aus demamr-Claim ab; eine Ableitung ausamr = otpwürde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist unzulässig. - Nach einem Reload ist die Recovery-Sitzung eine normale Anmeldung. Das ist vertretbar, weil sie den Besitz des Postfachs nachweist, wie ein Magic Link (Risiko R12). 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 derAuthChangeEvents:signedIn/tokenRefreshed/userUpdated→signedIn,passwordRecovery→passwordRecovery,signedOut→signedOut; Ausnahmen während einer laufenden Recovery siehe die beiden folgenden Punkte (Abweichung, Ergänzung 01c).- Abweichung (umgesetzt in
AuthStatusTracker, bewusst): Während einer laufenden Recovery hebentokenRefreshedundsignedInden StatuspasswordRecoverynicht auf. Der Guard endet nur mitAuthRepository.finishPasswordRecovery()(nach dem Passwortwechsel, siehe „Ergänzung 01c“),signedOutoder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutessignedInbeim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12). - Ergänzung 01c (bewusst): Auch
userUpdatedbeendet den Guard nicht mehr. GoTrue sendet es asynchron, unabhängig vom Zeitpunkt der Rückkehr vonupdateUser(möglicherweise danach); ein Redirect „angemeldet auf/reset-password→/“ hätte den Screen entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Der Tracker setzt stattdessenAuthStatus.passwordRecovery(passwordChanged: true)(beiuserUpdatedund direkt nach erfolgreichemsetNewPassword, idempotent; das Formular bleibt bis dahin gesperrt), der Router hält den Nutzer auf/reset-password(Erfolgsansicht mit Knopf „Weiter“), und erstAuthRepository.finishPasswordRecovery()beendet den Guard (ohne gesetztepasswordChanged-Markierung ohne Wirkung).signedOut(Abbrechen, Refresh-Fehler) beendet ihn weiterhin sofort. Der Zustand lebt weiter nur im Prozess (R12). - Je Screen ein
@riverpod-Notifier (RegisterControllerusw.) mit ZustandAsyncValue<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 dokumentiert
app/assets/security/README.md. Entschieden (Chris, 2026-11-03): Die SecLists-Listen (MIT, Herkunft dokumentiert, Commit gepinnt) werden genutzt; die Frage, dass einzelne Listen aus Datenlecks bzw. Sammlungen Dritter stammen, ist damit abgenommen (die Liste enthält nur Passwörter, keine Konten oder E-Mail-Adressen). Der MIT-Text steht inapp/THIRD_PARTY.md, wird als Asset gebündelt und zur Laufzeit überLicenseRegistryinshowLicensePageausgeliefert. - 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 (über Kong,http://127.0.0.1:54321/auth/v1), plus Helfer für den CLI-Mailfänger Mailpit (GET /api/v1/messages,GET /api/v1/message/{id}; Mails je Test perTofiltern, nicht global löschen). Liest Mails, Betreff, Sprache und Links.web: Browser gegen den lokalen Web-Build (flutter build web, ausgeliefert auf Port 3000).throttle: Drosselungstests (Krit. 7, AUTH-CFG-10/11) gegen einen zusätzlichen GoTrue-Container (siehe D2), nicht gegen die CLI. Grund: Die CLI setztGOTRUE_RATE_LIMIT_HEADERnicht, ohne ihn gibt es keine IP-Drosselung (B0, A2; entschieden F11). Alle Requests in diesem Projekt tragenX-Forwarded-Formit einer je Test zufälligen IP (simuliert den Proxy).
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
| 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 → HTTP 500, Body-message age_confirmation_required, kein Nutzer angelegt |
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); Repository mappt over_email_send_rate_limit bei signUp/resetPasswordForEmail auf emailSendLimited, over_request_rate_limit weiter auf rateLimited; RegisterController und ForgotPasswordController behandeln emailSendLimited wie Erfolg (F12) |
gleicher Bildschirm für neue und vorhandene Adresse; F12: RegisterScreen und ForgotPasswordScreen mit gemocktem emailSendLimited → Navigation zum CheckEmailScreen, kein Drosselungshinweis, Text identisch zum 200-Fall (Golden-String-Vergleich), neutraler Zusatz „kann einige Minuten dauern“ sichtbar |
api: Signup mit bestätigter Adresse → 200 (nicht 422), identities leer, ID ≠ ID des echten Nutzers, Anzahl auth.users unverändert, keine Mail, altes Passwort weiter gültig (Regressionstest für AUTH-CFG-04 und AUTH-CFG-24); mit unbestätigter Adresse → 200, Passwort unverändert (Login mit altem Passwort nach Bestätigung möglich). Der API-Seitenkanal bei erschöpftem Mail-Limit (R3) wird nicht E2E getestet (lokal wirkungslos, akzeptiert, F12); die App-Seite sichern Unit- und Widget-Test ab. |
– | B2, A2, A4, T1, T2, 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; ResendController: emailSendLimited mit ResendOrigin.checkEmail → Erfolg, mit ResendOrigin.login → Drosselungshinweis (F12) |
Hinweis + Resend-Aktion; Resend-Erfolg/429; F12: CheckEmailScreen mit gemocktem emailSendLimited → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit emailSendLimited → Drosselungshinweis |
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) | throttle (Extra-Container mit GOTRUE_RATE_LIMIT_HEADER): 30 Fehllogins mit gleichem X-Forwarded-For → jeweils invalid_credentials, Login #31 → 429 over_request_rate_limit (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne Retry-After (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright page.route simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) |
– | B2, A3, T3, D2 |
| 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; zweites Einlösen → 403 otp_expired; abgelaufener Link: Helfer e2e/support/db.ts verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle postgres) und setzt auth.users.recovery_sent_at (Reset) bzw. confirmation_sent_at (Bestätigung) auf now() - (otp_expiry + 1 s) (B0 (i); auth.one_time_tokens.created_at ist wirkungslos). 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, indem er die Browser-Uhr um 2 h vorstellt; ein geändertes expires_at im localStorage wirkt nicht, weil Session.expiresAt in supabase_flutter aus dem JWT-Claim exp berechnet wird; 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 → HTTP 500, Body {"code":"P0001","message":"display_name_invalid"} (sichert das Format aus B0 (f) ab), 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: 31 Fehllogins einer IP → 429; ein vom Client gefälschter X-Forwarded-For wird überschrieben; GoTrue ist nicht direkt erreichbar), Sperre /otp und /magiclink am Gateway (AUTH-CFG-20), GOTRUE_SMS_AUTOCONFIRM=false im Prod-Env (AUTH-CFG-24, Signup mit bestätigter Adresse → 200), Mail-Limit email_sent (AUTH-CFG-09, lokal wirkungslos). 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 | Erledigt 2026-11-02. Ergebnisse und Abweichungen A1–A7: docs/specs/1-auth-spike-ergebnisse.md (in diese Spec eingearbeitet, siehe § 10). Ursprünglicher Auftrag: 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 (überführt B3). |
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, inkl. AUTH-CFG-24 [auth.sms] enable_confirmations = true); email_sent und die Rate-Limits im Kommentar als „lokal ohne Wirkung“ bzw. „nur mit GOTRUE_RATE_LIMIT_HEADER wirksam“ kennzeichnen; Mailvorlagen supabase/templates/confirmation.html, recovery.html (de/en mit index .Data "locale", Links type=email / type=recovery nach E3, barrierearmes HTML, nur Text und Links); Benachrichtigungen explizit aus |
B0 |
| B3 | backend-dev | docs/auth-konfiguration.md: überführt den Inhalt von docs/specs/1-auth-spike-ergebnisse.md (Abschnitt „Verifiziert“ mit Versionen, Ergebnissen je Punkt, Abweichungen A1–A7). Dazu 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 (GOTRUE_RATE_LIMIT_HEADER als Pflicht, GOTRUE_RATE_LIMIT_EMAIL_SENT, …_TOKEN_REFRESH, …_OTP, …_VERIFY, GOTRUE_SMS_AUTOCONFIRM=false bzw. ENABLE_PHONE_AUTOCONFIRM=false), Release-Checkliste (manuelle Prüfungen aus 7.1; bei jedem GoTrue-Update B0-Punkte (c), (d), (f) wiederholen). Das Spike-Dokument bleibt als Nachweis unter docs/specs/ liegen. |
B0, B2 |
| D1 | devops | Betriebsanteil dokumentieren und vorbereiten (kein Eingriff in Server): GoTrue-Image auf supabase/gotrue:v2.197.0 pinnen (nicht latest, Stand B0 (h)); Auslieferung der Mailvorlagen per URL für GoTrue; GOTRUE_RATE_LIMIT_HEADER (Pflicht) + Proxy-Konfiguration (Header überschreiben, nicht anhängen; GoTrue nur über den Proxy erreichbar); ENABLE_PHONE_AUTOCONFIRM=false bzw. GOTRUE_SMS_AUTOCONFIRM=false (AUTH-CFG-24, Vorlage setzt true); 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 über Kong + Mailpit-Helfer), web und throttle (Drosselungstests gegen den Extra-Container aus D2, Basis-URL aus Env, z. B. THROTTLE_GOTRUE_URL; alle Requests mit X-Forwarded-For = zufällige IP je Test; Login #31 → 429, zweite IP unbeeinflusst, kein Retry-After); Tests je Kriterium nach 7.1, inkl. Regressionstest AUTH-CFG-24 (Krit. 4, identities leer) und Body-Format der Trigger-Fehler (Krit. 10); e2e/support/auth-config.ts mit Bezug auf AUTH-CFG-IDs; e2e/support/db.ts setzt auth.users.recovery_sent_at / confirmation_sent_at zurück (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, throttle: 01a; web: je PR | B2 (api), D2 (throttle), A2–A4 (web) |
| D2 | devops | CI-Job e2e auf dem Runner docker-host: supabase start (mit Mailpit), flutter build web --dart-define-from-file=env/ci.example.json, statisch auf Port 3000 ausliefern, npx playwright test; Artefakte (Report, Traces) bei Fehlern. Zusätzlich für Krit. 7 (F11): nach supabase start einen zweiten GoTrue-Container starten: dasselbe Image wie die CLI (public.ecr.aws/supabase/gotrue:v2.197.0, Version aus dem CLI-Container lesen statt hart kodieren), im Docker-Netz der CLI (supabase_network_<projekt>), Env des CLI-Auth-Containers übernehmen (docker inspect) plus GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For und API_EXTERNAL_URL, eigener Port (z. B. 9998) für das Playwright-Projekt throttle. Der Container nutzt dieselbe DB, hat aber eigene Rate-Limit-Buckets (im Speicher). CI läuft über Docker-in-Docker (siehe Commit 51ab6e2): Port-Freigabe und Netzname im Runner prüfen. Container nach dem Lauf entfernen. Ein Skript e2e/support/start-throttle-gotrue.sh macht den Ablauf auch lokal nutzbar. |
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 (erledigt)→B2→B3, B1 (backend-dev)
Phase 2 parallel: F7, F8 | U1 | A1 | T3-api (nach B2) | D2-Extra-Container + T3-throttle | 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 hat CLI 3.0.0 und Image v2.197.0 verifiziert; Image pinnen (D1) und bei jedem GoTrue-Update B0 (c), (d), (f) wiederholen; Release-Checkliste |
| R2 | Drosselung (Krit. 7) wirkt nur mit GOTRUE_RATE_LIMIT_HEADER (B0, A2). Ohne die Einstellung gibt es gar keine IP-Drosselung; auch mit ihr werden Requests ohne den Header nicht gedrosselt. Fehlt der Proxy oder ist GoTrue direkt erreichbar, ist die Drosselung umgehbar; hängt der Proxy den Header nur an, fälschen Clients ihre IP. Die 429-Antwort trägt kein Retry-After. Lokal (CLI) gibt es keine Drosselung. |
Passwort-Raten ungebremst (Krit. 7), oder Umgehung durch gefälschte IPs | AUTH-CFG-13 Pflicht; D1: Proxy überschreibt den Header, GoTrue nur über den Proxy erreichbar; CI-Test gegen Extra-Container (D2, T3, F11); Release-Checkliste am echten Gateway (31 Fehllogins, gefälschter Header wird überschrieben). App-Text nennt keine Dauer. |
| R3 | email_sent ist instanzweit (B0 (d) bestätigt, 30 Mails/h) und wirkt nur über das Env, in der lokalen CLI nicht |
Ab der 31. Mail pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen. Seitenkanal: Während der Sperre antwortet /recover für bestehende Adressen 429, für unbekannte 200 (Signup abgeleitet umgekehrt, siehe 4.3); über die rohe API wird die Existenz eines Kontos erkennbar. Nebenwirkung der Gegenmaßnahme: Nutzer erfahren in der App nicht, dass ihre Mail wegen der Sperre verzögert ist oder ausbleibt. |
30/h reicht laut Chris zum Start (F4). F12 (Chris, 2026-11-02): In der App verdeckt: over_email_send_rate_limit bei Registrierung und Reset führt zum selben neutralen CheckEmailScreen wie 200, „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt ebenso neutral (4.4); nur nach Login mit richtigem Passwort (Krit. 6) erscheint der Drosselungshinweis. Krit. 4 bleibt in der App erfüllt; der neutrale Zusatz „kann einige Minuten dauern“ steht immer da. Auf API-Ebene akzeptiert. Der Angriff erzeugt vor allem Ausfälle. Monitoring auf 429 over_email_send_rate_limit, Wert bei Bedarf erhöhen; Proxy-Lösung erst bei Bedarf. Nicht automatisiert getestet (lokal wirkungslos). F13 (SMTP-Ausfall): gleiches Muster, siehe R17. |
| 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). B0 (A7): /otp legt mit gültigen Metadaten Konten an, der Trigger schützt also nicht. 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: (a) Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter Adresse einen erfundenen (B0 bestätigt). (b) Fehlkonfiguration: Mit GOTRUE_SMS_AUTOCONFIRM=true (CLI-Standard, Self-Hosting-Vorlage ENABLE_PHONE_AUTOCONFIRM=true) liefert Signup mit bestätigter Adresse 422 user_already_exists (B0, A1). (c) Seitenkanal bei erschöpftem Mail-Limit, siehe R3. |
(a) Mit Skripten lässt sich die Existenz unbestätigter Konten erkennen. (b) Existenz bestätigter Konten offen lesbar, Krit. 4 auf API-Ebene gebrochen. | (a) 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. (b) AUTH-CFG-24 in config.toml und Env; API-Regressionstest (7.1 Krit. 4); Release-Checkliste. (c) in der App verdeckt (neutraler Screen bei Registrierung, Reset und Resend auf dem CheckEmailScreen, 4.4), auf API-Ebene akzeptiert (F12, R3). |
| 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. B0 (j): Das Recovery-JWT trägt amr = [{"method":"otp"}], identisch zur Sitzung nach dem Bestätigungslink; eine Unterscheidung ist nicht möglich. |
Nutzer ist angemeldet, ohne ein neues Passwort gesetzt zu haben; das alte Passwort bleibt gültig | Akzeptiertes Restrisiko: vertretbar, weil der Postfachbesitz nachgewiesen ist (wie Magic Link). Der Guard lebt nur im laufenden Prozess (5.4); eine Ableitung aus amr ist unzulässig. |
| R13 | Drosselungstests leeren IP-Buckets für bis zu 5 min | Nachfolgende Login-Tests scheitern zufällig | Weitgehend entschärft: Drosselungstests laufen gegen den Extra-Container (D2) mit eigenen, im Speicher gehaltenen Buckets; die CLI drosselt ohnehin nicht. Im Projekt throttle nutzt jeder Test eine eigene zufällige X-Forwarded-For-IP (simuliert den Proxy, gewollt). Gegen die gemeinsame CLI-Instanz werden keine gefälschten X-Forwarded-For-Header verwendet. |
| 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; D1 ist Release-Blocker, kein Merge-Blocker |
| R17 | SMTP-Ausfall verrät Konten auf API-Ebene (Spike #37/#38, GoTrue v2.197.0): /recover bekannt 500 unexpected_failure / unbekannt 200; /resend unbestätigt 500 / unbekannt 200; /signup neu 500, bestätigt 200. Kein Zeit-Seitenkanal (< 60 ms). Offen: SMTP, der die Verbindung annimmt, aber nicht antwortet (Timeout). |
Konten-Enumeration über die rohe API; in der App durch F13 verdeckt. Nebenwirkungen: Bei echtem SMTP-Ausfall erfahren Nutzer nichts und warten auf eine Mail. Bewusst: Auch Gateway-502/503/504 (Antwort mit Status) wirken bei signUp/recover/resend wie Erfolg, es gibt dort keinen Offline-Hinweis; nur Fehler ohne Status bleiben network. |
F13 (Chris, 2026-11-03, Option 1): Die App behandelt jeden Nicht-Eingabe-, Nicht-Transport-Fehler dieser Aufrufe wie Erfolg (4.4). Restrisiko rohe API bleibt akzeptiert; zusätzlich ist registrationRejected kontoabhängig (der Trigger läuft nur beim INSERT), relevant nur, wenn Client- und Server-Regeln auseinanderlaufen (der Client verhindert die Fälle sonst vorab) (wie R3, R6). Betrieb: Alarm/Monitoring auf GoTrue-Logs unexpected_failure/500 bei recover/resend/signup (docs/auth-konfiguration.md § 5, Release-Blocker). Der neutrale Zusatz „kann einige Minuten dauern“ bleibt. |
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; 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 |
| F11 | (Spike Q1) Krit. 7: Wo läuft der Drosselungstest, da die CLI keine IP-Drosselung kann? | entschieden (2026-11-02) | Im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem GOTRUE_RATE_LIMIT_HEADER; zusätzlich manuelle Prüfung am echten Gateway vor dem Release |
7, 7.1 Krit. 7, D2, T3, R2, R13 |
| F12 | (Spike Q2) Seitenkanal bei erschöpftem Mail-Limit (/recover: bestehende Adresse 429, unbekannte 200) |
entschieden (2026-11-02) | In der App verdeckt: Bei over_email_send_rate_limit zeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ (Krit. 4 bleibt in der App erfüllt), mit neutralem Zusatz „kann einige Minuten dauern“. Ergänzt (Chris, 2026-11-02): Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt neutral; nach Login mit richtigem Passwort (Krit. 6) bleibt der Drosselungshinweis. Über die rohe API bleibt der Kanal sichtbar: akzeptiert und als Risiko dokumentiert (Limit bleibt bei 30/h laut F4). |
4.2, 4.3, 4.4, 5.4, 7.1 Krit. 4, R3, R6 |
| F13 | (Issue #37, Spike #38) SMTP-Ausfall verrät, ob ein Konto existiert (500 vs. 200 bei /recover, /resend, /signup) |
entschieden (2026-11-03, Chris: Option 1) | Neutrale Seite im Client: Jeder Fehler dieser Aufrufe, der nicht von der Eingabe oder vom Transport kommt und nicht die IP-Drosselung ist, zählt wie Erfolg (analog F12), auch „E-Mail erneut senden“ auf dem CheckEmailScreen (im LoginScreen bleibt unexpected sichtbar, das Konto ist dort bewiesen). Das Logging bleibt über das zentrale Logger-Utility. Betriebshinweis: Monitoring auf SMTP-Fehler. |
4.4, R17, docs/auth-konfiguration.md § 5 |
Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release.
9.3 Ausstehende Prozessschritte
- Spec von Chris freigegeben am 2026-11-02; B0 erledigt, Anpassung an die Spike-Ergebnisse eingearbeitet (§ 10). Kriterientexte unverändert; Krit. 4 bleibt in der App auch während einer Mail-Sperre erfüllt (F12).
- Nächste Schritte: B2/B3 (backend-dev) mit AUTH-CFG-24, D2-Erweiterung (devops), dann T3.
9.4 Hinweise für KTO-11 (#28), nicht Teil von #1
double_confirm_changes(AUTH-CFG-23) muss für N2 vermutlichfalsesein. Die Info-Mail an die alte Adresse kommt dann über die Benachrichtigungemail_changed(AUTH-CFG-19).- Passwort ändern mit aktuellem Passwort (N13): AUTH-CFG-25 (
secure_password_change, in #1false) entscheiden; 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_versionmitTERMS_VERSION) ist nicht Teil von R1. Falls gewünscht, als eigene Story erfassen; dafür bräuchte es dann eine RPC zum Aktualisieren vonterms_version. GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=trueist CLI-Standard (B0); Entscheidung bleibt bei KTO-11 (AUTH-CFG-23).user_metadata.localekann der Nutzer selbst überauth.updateUser(data: …)ändern; die Mailsprache kann dann vom Profil abweichen (nur eigenes Konto). Der Client ändert die Sprache ausschließlich überprofiles; der Sync-Trigger zieht bei der nächsten Profiländerung nach (B0).
10. Änderungsprotokoll
2026-11-02 – Anpassung an Spike B0 (docs/specs/1-auth-spike-ergebnisse.md)
Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien sind unverändert; Q1/Q2 des Spikes hat Chris als F11/F12 entschieden. Krit. 4 bleibt in der App auch während einer Mail-Sperre erfüllt (F12: neutraler Screen); nur über die rohe API ist der Seitenkanal sichtbar (akzeptiert).
- Statuskopf und Quellen: „freigegeben 2026-11-02, angepasst an Spike-Ergebnisse“; Spike-Dokument und Migration
20261102212208_auth_registration_rules.sqlals Quellen. - Neue Konfiguration AUTH-CFG-24 (A1):
[auth.sms] enable_confirmations = true/GOTRUE_SMS_AUTOCONFIRM=false, im Self-HostingENABLE_PHONE_AUTOCONFIRM=false(Vorlagedocker/.env.examplesetzttrue). Sonst 422user_already_existsstatt Enumerationsschutz (Krit. 4). AUTH-CFG-04 verweist darauf. - 4.1: Einleitung nicht mehr „Entwurf“; AUTH-CFG-09 instanzweit und lokal wirkungslos; AUTH-CFG-10 Schlüssel
token_refreshbestätigt, keinRetry-After; AUTH-CFG-11 nur für Signup bestätigt; AUTH-CFG-10 bis -12 nur mit AUTH-CFG-13 wirksam; AUTH-CFG-13 Pflicht; AUTH-CFG-20 um A7 ergänzt; AUTH-CFG-22 Mailpit; Mailvorlagen: kein zweisprachiger Rückfall-Betreff,type-Werte bestätigt. E5 entsprechend angepasst. - Fehlerbehandlung (E6, 4.2, 4.4) (A4): Trigger-Fehler kommen als HTTP 500 mit
{"code":"P0001","message":"<grund>"}im Body (HeaderX-Sb-Error-Code: unexpected_failure). Mapping aufregistrationRejectedüber HTTP 500 beisignUpmit bekannter Meldung (F13), nicht überP0001. 429 ohne Dauerangabe. - 4.3: als verifiziert markiert; Krit. 4 mit beiden Autoconfirm-Voraussetzungen und dem Seitenkanal bei erschöpftem Mail-Limit; Krit. 7 Drosselung nur mit Header; Krit. 8 maßgebliche Spalte
auth.users.*_sent_at. - 5.4 (A5): Der
amr-Weg entfällt (Recovery-JWTamr = otp, nicht unterscheidbar); Guard nur im laufenden Prozess, Ableitung ausamrunzulässig. - 7 / 7.1: neues Playwright-Projekt
throttlegegen Extra-Container; Krit. 2 und 10 erwarten 500 mit Body-Meldung; Krit. 4 prüftidentitiesleer (Regressionstest AUTH-CFG-24) und nennt den akzeptierten Seitenkanal; Krit. 7 echter Test im Projektthrottle(Login #31 → 429, zweite IP frei), Web-Hinweis per gemocktem 429; Krit. 8 Spaltenrecovery_sent_at/confirmation_sent_at, zweites Einlösen 403otp_expired; manuelle Checkliste erweitert (AUTH-CFG-09, -13, -24). - Aufgaben: B0 als erledigt markiert (Verweis auf Spike-Dokument); B2 um AUTH-CFG-24 und Kennzeichnung lokal wirkungsloser Werte ergänzt; B3 überführt den Inhalt von
docs/specs/1-auth-spike-ergebnisse.mdnachdocs/auth-konfiguration.md; D1 pinntgotrue:v2.197.0, setztENABLE_PHONE_AUTOCONFIRM=false, GoTrue nur über den Proxy; D2 startet im CI einen zusätzlichen GoTrue-Container mitGOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For; T3 Projektthrottlegegen diesen Container; Phasenplan 8.4 ergänzt. - Risiken: R1 (Image gepinnt, Wiederholung bei Updates), R2 (Drosselung nur mit Header, Requests ohne Header ungedrosselt, kein
Retry-After), R3 (instanzweit, nur per Env; Seitenkanal/recover429 akzeptiert), R4 (A7), R6 (Fehlkonfiguration SMS-Autoconfirm, Seitenkanal), R12 (amr=otp, nicht erkennbar, akzeptiert), R13 (durch Extra-Container entschärft; gefälschte Header nur im Projektthrottle). - 9.2: F11 (Q1, CI-Drosselungstest mit Extra-Container) und F12 (Q2) ergänzt; 9.3 aktualisiert; 9.4 um zwei KTO-11-Hinweise aus dem Spike ergänzt.
- F12 entschieden (Chris, 2026-11-02): Neuer Domänenfehler
AuthFailure.emailSendLimitedfürover_email_send_rate_limitbeisignUpundresetPasswordForEmail;RegisterControllerundForgotPasswordControllerbehandeln ihn wie Erfolg → neutraler CheckEmailScreen (4.3, 4.4, 5.4). CheckEmailScreen zeigt immer den Zusatz „Die E-Mail kann einige Minuten dauern …“, weil Nutzer eine Verzögerung sonst nicht erfahren. 7.1 Krit. 4 um Unit- und Widget-Tests ergänzt. R3/R6: Kanal in der App verdeckt, auf API-Ebene akzeptiert. Die Aussage „Krit. 4 gilt während einer Mail-Sperre nicht in der App“ ist entfallen. - F12 ergänzt (Chris, 2026-11-02): Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt
over_email_send_rate_limitneutral; im LoginScreen nachemail_not_confirmed(Krit. 6) bleibt der Drosselungshinweis.ResendControllermitResendOrigin.checkEmail | login(4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst. - AUTH-CFG-25 neu:
auth.email.secure_password_change = false, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. ID-Abgleich mit dem B2-Stand (supabase/config.toml,docs/auth-konfiguration.md): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüsselauth.email.enabled(stattauth.email.enable_signup). Nachgezogen in B2:secure_password_change→ AUTH-CFG-25 (erledigt). - Anzeigename: U+200B verboten (Chris, 2026-11-02): Ein Name mit U+200B an beliebiger Stelle ist ungültig (Trigger
display_name_invalid, zusätzlicher Checkprofiles_display_name_no_zwsp, Update → 23514; Migration20261102212208_auth_registration_rules.sql, pgTAP05_auth_registration). U+200C/U+200D/U+FEFF unverändert. Betroffen: E6, 3.1, 3.2 (Check, Trigger, Trim-Regel), RegisterScreen (Client-Regel für 01a:DisplayNameRulemeldet U+200B als Feldfehler, gleiche Meldung wie „ungültig").
2026-11-03 – Review-Findings zu 01a (Branch feature/1-01a-registrierung)
- Passwortliste, Lizenz entschieden (Chris, 2026-11-03): SecLists (MIT) wird genutzt, Herkunft in
app/assets/security/README.mddokumentiert (5.6). Der Lizenztext wird zur Laufzeit inshowLicensePageausgeliefert (LicenseRegistry, AssetTHIRD_PARTY.md). - Rechtstext-Links im Web: Die Links der Einwilligungs-Checkbox sind eigene Semantics-Knoten (
link: true, Label, Tap) stattCustomSemanticsActionam Checkbox-Knoten; Flutter-Web setzt zusätzliche Aktionen nicht in ARIA um. Checkbox-Knoten behält Label, Zustand und Tap. - Externe URLs:
UrlOpeneröffnet nurhttp/httpsmit Host (keinjavascript:,file:,intent:). - Doppel-Submit:
RegisterController.submitsetztsubmittingvor dem Laden der Passwortliste. - Transportfehler: Erkennung per
is(http.ClientException,dart:io-Fehler über bedingten Import) stattruntimeType.toString()(bricht in minifiziertem dart2js).httpist als direkte Abhängigkeit deklariert (1.6.0, bereits transitiv im Lock; keine neue Fremdabhängigkeit). - Trim-Regel (3.2): Der Satz „Bekannte Abweichung: Dart-
trim()…btrimnur Leerzeichen“ war falsch. Der Server nutzttrim_ws(Abschnitt 3.2), das Verhalten ist mit Dart-trim()identisch. Satz korrigiert. - Recovery-Guard (5.5): Abweichung vom Wortlaut dokumentiert:
tokenRefreshed/signedInhebenpasswordRecoverynicht auf. extraund Browser-Verlauf (5.4): Geprüft in go_router 18.1.2 (match.dart,RouteMatchListCodec.encode): OhneextraCodecwirdextraperjson.encoderin den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung:VolatileExtraCodec(core/routing) verwirftextrabeim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert).- Live-Region am Passwortfeld: Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (
liveError: false), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) fürauthPasswordTooShort,safeFromPathlehnt/login/(Schrägstrich am Ende) ab.
2026-11-03 – T3 (Issue #38, Branch feature/38-auth-e2e)
- 7.1 Krit. 9 (web), Rezept korrigiert: Das Setzen von
expires_atimlocalStorageerzwingt keinen Refresh, weilSession.expiresAtinsupabase_flutteraus dem JWT-Claimexpberechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (Date) um 2 h (shiftBrowserClockine2e/support/web.ts); der Server rechnet mit der echten Zeit. - A11y-Befund (QUE-06): Auf
/auth/confirmund der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (pressFirstButton).
2026-11-03 – Umsetzung 01c (Branch feature/1-01c-passwort-reset), Abweichungen zur Freigabe durch Chris
- Guard endet nicht mit
userUpdated(5.5, 5.4):AuthStatus.passwordRecoverybekommtpasswordChanged;AuthRepository.finishPasswordRecovery()beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefertuserUpdatedasynchron, deshalb markiert das Repository die Änderung zusätzlich selbst. - Neue Route
/forgot-password/check-email(5.3): Der Modus (Bestätigung/Reset) steht im Pfad statt imextra, damit er einen Web-Reload übersteht. Die Adresse wird im Modus Reset nicht übergeben. - Kein Resend im Modus Reset (wie 5.4): „Andere E-Mail-Adresse eingeben“ statt „E-Mail erneut senden“.
ResendOrigin.checkEmailgilt weiter nur für den Modus Bestätigung. - Erfolgstext Krit. 9 unverändert (kein „sofort“, ehrlich zu F1), dazu der Knopf „Weiter“;
AppBannerumactionEnablederweitert (Resend im LoginScreen bleibt beim Senden sichtbar, aber deaktiviert);fromwird zwischen Login, Registrierung und „Passwort vergessen“ weitergegeben.
2026-11-03 – F13 SMTP-Ausfall neutral behandeln (Issue #37, Branch fix/37-smtp-neutral)
- Entscheidung Chris (Option 1): Bei
recover,resendundsignUpzeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx /unexpected_failure), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist inopenEmailInputFailureForundresendFailureFor; der Mapper liefert 5xx mit Status bei diesen Aufrufen alsunexpected(stattnetwork), einsignUp-500 ohne bekannte Trigger-Meldung ebenfalls (stattregistrationRejected). - Login-Resend (Krit. 6), Entscheidung Orchestrator (S4, Chris wird informiert): Dort bleibt
unexpectedsichtbar (allgemeiner Fehler mit „Erneut versuchen“), weil das Konto durch das richtige Passwort bereits bewiesen ist und Neutralisieren nur einen echten SMTP-Ausfall verdecken würde.emailSendLimitedzeigt dort weiter den Drosselungshinweis (F12). Neutral bleibt es auf CheckEmail-Resend, Forgot und Register.registrationRejectedist nur mit bekanntem Grund sichtbar. - 4.4, R17, F13 ergänzt; Betriebshinweis in
docs/auth-konfiguration.md.