From 47721a48e863b2c9fcc79f68cbb2ddb310a954dd Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Mon, 2 Nov 2026 23:26:03 +0100 Subject: [PATCH] docs(auth): align spec with spike results and add AUTH-CFG-25 Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- docs/auth-konfiguration.md | 3 +- docs/specs/1-auth-registrierung-anmeldung.md | 156 +++++++++++-------- supabase/config.toml | 2 +- 3 files changed, 96 insertions(+), 65 deletions(-) diff --git a/docs/auth-konfiguration.md b/docs/auth-konfiguration.md index 9681354..e8c09b4 100644 --- a/docs/auth-konfiguration.md +++ b/docs/auth-konfiguration.md @@ -40,8 +40,9 @@ Spalte „Wirkung“: **lokal** = wirkt in der CLI über `config.toml`; **Env** | AUTH-CFG-22 | SMTP / Mailfänger | lokal Mailpit; Staging/Prod EU-Anbieter mit AVV (**offen**, F7) | lokal + Env | `local_smtp` / `GOTRUE_SMTP_*` | Release-Blocker, kein Merge-Blocker | 1, 8, 11 | | AUTH-CFG-23 | E-Mail-Wechsel auf beiden Adressen bestätigen | **in #1 nicht festgelegt** (CLI-Standard `true`) | – | `auth.email.double_confirm_changes` / `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED` | Entscheidung fällt in KTO-11 (N2) | (KTO-11) | | AUTH-CFG-24 | SMS-Autoconfirm aus | `SMS_AUTOCONFIRM=false` | lokal + Env | `auth.sms.enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`; in der Self-Hosting-Vorlage `ENABLE_PHONE_AUTOCONFIRM=false` | SMS ist nicht im Einsatz, die Einstellung steuert aber den Enumerationsschutz beim E-Mail-Signup. Mit `true` (CLI-Standard und Vorlage `docker/.env.example`!) liefert Signup mit bestätigter Adresse 422 `user_already_exists` | 4 | +| AUTH-CFG-25 | Passwortänderung ohne erneute Anmeldung | `false` (Standard, ausdrücklich gesetzt) | lokal + Env | `auth.email.secure_password_change` / `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` (Env-Name vor Release gegen das Image prüfen) | Recovery-Sitzungen sind ausgenommen; ob das aktuelle Passwort verlangt wird, entscheidet KTO-11 (N13) | (KTO-11) | -Ohne eigene ID: `auth.email.secure_password_change = false` (Standard, ausdrücklich gesetzt). Recovery-Sitzungen sind davon ausgenommen; ob für die Passwortänderung das aktuelle Passwort verlangt wird, entscheidet KTO-11 (N13). +AUTH-CFG-25: `auth.email.secure_password_change = false` (Standard, ausdrücklich gesetzt). Recovery-Sitzungen sind davon ausgenommen; ob für die Passwortänderung das aktuelle Passwort verlangt wird, entscheidet KTO-11 (N13). ## 2. Env-Block für Staging und Produktion (GoTrue, ohne Secrets) diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 2cafcaf..e5bd31c 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -3,11 +3,11 @@ | | | |---|---| | Issue | #1 (KTO-01) | -| Status | Entwurf (offene Fragen F1–F10 von Chris entschieden am 2026-11-02, Freigabe der Spec steht aus) | +| 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`, ADR-0001, ADR-0002 | +| 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 @@ -23,8 +23,8 @@ Gleichzeitig ist #1 die **erste Story mit App-Code**. Das Flutter-Gerüst existi | E2 | **Ein Issue #1, drei PRs** (01a Registrierung, 01b Anmelden, 01c Passwort zurücksetzen), davor ein Backend-PR (Migration + Auth-Konfiguration) | Die Kriterien teilen Backend-Konfiguration und Domänenschicht. Getrennte Issues würden Abhängigkeiten nur verschieben. Die PRs referenzieren `Refs #1`, der letzte `Closes #1`. **Abweichung vom Issue-Vorschlag:** Krit. 6 liegt in 01b statt 01a, weil der Hinweis auf die ausstehende Bestätigung beim *Anmelden* erscheint und den LoginScreen braucht. Die Aktion „E-Mail erneut senden“ entsteht schon in 01a (CheckEmailScreen) und wird in 01b wiederverwendet. | | E3 | **Links in Mails zeigen auf eine App-Seite `/auth/confirm?token_hash=…&type=…`**, nicht auf `{{ .ConfirmationURL }}`. Die Seite löst `verifyOTP` erst per Knopfdruck aus. | Funktioniert geräteübergreifend: kein PKCE-Verifier nötig, z. B. Registrierung am Handy und Mail am PC. Mail-Scanner (z. B. Safe Links) verbrauchen den Einmal-Link nicht. Die Einmaligkeit bleibt serverseitig erhalten (Kriterium 8). | | E4 | **In R1 landen Mail-Links immer in der Web-App.** Universal Links/App Links (iOS/Android) sind nicht Teil von #1. | Die Web-App kann alle Schritte ausführen. Universal Links brauchen AASA/assetlinks-Hosting und Store-Signaturen; das ist eine eigene Aufgabe und sollte ein Folge-Issue werden. | -| E5 | **Mailsprache über `user_metadata.locale`**: Je Mailtyp gibt es *eine* Vorlage mit Go-Template-Verzweigung de/en (Standard Englisch, F6). Ein neuer DB-Trigger hält `auth.users.raw_user_meta_data.locale` synchron zu `profiles.locale`. | GoTrue kennt nur eine Vorlage je Typ und stellt `.Data` (= `user_metadata`) bereit. Die Profilsprache ist maßgeblich (Kriterium 11, KTO-11 Kriterium 2). Ein Send-Email-Hook (Edge Function + eigener SMTP-Versand) wäre mehr bewegliche Teile ohne zweiten Anwendungsfall. Er ist der Rückfall, falls der Spike B0 zeigt, dass Betreffzeilen `.Data` nicht auswerten. | -| E6 | **Validierung zweistufig**: Feldfehler im Client (Kriterien 2, 3, 10). Der Server erzwingt dieselben Regeln als Schutz (Trigger, `minimum_password_length = 10`). | Der Client gibt sofort Rückmeldung am Feld. Der Server vertraut nie dem Client. GoTrue meldet Trigger-Fehler nur als generischen 500er, deshalb ist der Client die nutzersichtbare Stelle. | +| 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), 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. | @@ -94,39 +94,41 @@ Nur additiv: `create or replace function` und neue Objekte, keine Änderung an b ### 4.1 Supabase-Auth-Konfiguration (self-hosted) -Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue-Env-Block in `docs/auth-konfiguration.md` (Staging/Produktion, ohne Secrets). Die Tabelle unten ist der **Entwurf**. Spike B0 prüft jeden Wert gegen die tatsächlich eingesetzten Versionen (CLI und Image `supabase/gotrue`) und trägt das Ergebnis in die Doku ein. Tests referenzieren die Zeilen-ID (z. B. `AUTH-CFG-07`). +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 (Entwurf) | Quelle / Anmerkung | Krit. | +| 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.enable_signup` | `GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_EXTERNAL_EMAIL_ENABLED=true` | an | – | 1 | -| AUTH-CFG-04 | E-Mail-Bestätigung Pflicht | `auth.email.enable_confirmations = true` | `GOTRUE_MAILER_AUTOCONFIRM=false` | Bestätigung Pflicht | CLI-Standard ist `false`, muss gesetzt werden. **Voraussetzung für den Enumeration-Schutz** (4.3). | 1, 4, 6 | +| AUTH-CFG-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) | Wahrscheinlich **instanzweit**; B0 prüft das. Risiko R3. | 1, 6, 8 | -| AUTH-CFG-10 | Drosselung Anmeldung (Passwort) | `auth.rate_limit.token_refresh` (laut Code) bzw. `sign_in_sign_ups` (laut CLI-Doku) | `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` | 150 je 5 min je IP, Burst 30 | GoTrue-Code: Der Passwort-Login nutzt den Token-Limiter (`RateLimitTokenRefresh/300 s`, Burst 30). B0 klärt empirisch, welcher Schlüssel greift. **Keine Sperre je Konto, nur je IP.** | 7 | -| AUTH-CFG-11 | Drosselung Registrierung, Reset, erneut senden | `auth.rate_limit.sign_in_sign_ups` | `GOTRUE_RATE_LIMIT_OTP` | 30 je 5 min je IP, Burst 30 | Signups, Recover, Resend teilen sich diesen Limiter | 1, 6, 8 | -| AUTH-CFG-12 | Drosselung Link-Prüfung | `auth.rate_limit.token_verifications` | `GOTRUE_RATE_LIMIT_VERIFY` | 30 je 5 min je IP | – | 8 | -| AUTH-CFG-13 | Client-IP hinter Proxy | – (lokal irrelevant) | `GOTRUE_RATE_LIMIT_HEADER` (z. B. `X-Forwarded-For`) + Proxy überschreibt den Header | Prod: gesetzt und nicht fälschbar | Ohne diese Einstellung teilen sich alle Nutzer die IP des Gateways (Risiko R2) | 7 | +| AUTH-CFG-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..enabled` | `GOTRUE_MAILER_NOTIFICATIONS__ENABLED` | **alle aus** | Krit. 11. `email_changed` (Info-Mail an die alte Adresse, N2) schaltet KTO-11 ein. | 11 | -| AUTH-CFG-20 | Magic Link / OTP-Login | – (kein Schalter) | – (kein Schalter) | Prod: `POST /auth/v1/otp` und `/auth/v1/magiclink` am Gateway sperren | Sonst kann jeder Magic-Link-Mails an beliebige Adressen auslösen (Verstoß gegen Krit. 11) und Konten ohne Passwort anlegen (Risiko R4) | 11, 3 | +| AUTH-CFG-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 bzw. Inbucket, je nach CLI-Version) | `GOTRUE_SMTP_*` | Staging/Prod: EU-Anbieter mit AVV, **Anbieter und Absender-Domain noch offen** (F7, später) | Release-Blocker über D1, kein Merge-Blocker | 1, 8, 11 | +| AUTH-CFG-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 }}`. Der Else-Zweig ist Englisch und entspricht damit dem neuen Profil-Standard `'en'` (F6). -- Die Betreffzeile wird gleich verzweigt. Zeigt B0, dass Betreffzeilen `.Data` nicht sehen, gilt der Rückfall: zweisprachiger Betreff „Confirm your email address / Bestätige deine E-Mail-Adresse“. -- Link: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email` (Bestätigung) bzw. `…&type=recovery` (Reset). Die genauen `type`-Werte prüft B0. +- Je eine Vorlage mit Verzweigung `{{ if and .Data (eq (index .Data "locale") "de") }}…DE…{{ else }}…EN…{{ end }}` (`index` ist 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` (auch `signup`) und `recovery` werden 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 @@ -135,19 +137,21 @@ Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue- | Ablauf | Aufruf | Eingabe | Ausgabe / Fehler | |---|---|---|---| -| Registrieren | `auth.signUp(email, password, data: {display_name, age_confirmed: true, terms_accepted: true, terms_version, locale})` | siehe links | 200 mit Nutzer, Sitzung `null` (Bestätigung offen). **Auch bei bereits registrierter Adresse 200** (4.3). `weak_password` (422, `reasons`), `over_request_rate_limit` (429), Trigger-Fehler → 500 „Database error saving new user“, Netzfehler | +| 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)` | E-Mail | 200 immer; 429 bei Drosselung | | Anmelden | `auth.signInWithPassword(email, password)` | – | Sitzung; `invalid_credentials` (400), `email_not_confirmed` (400, **nur bei richtigem Passwort**, siehe 4.3), 429 | -| Reset anfordern | `auth.resetPasswordForEmail(email)` | E-Mail | 200 immer (keine Enumeration); 429 | +| Reset anfordern | `auth.resetPasswordForEmail(email)` | E-Mail | 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 (B0 prüft jeden Punkt) +### 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):** Bei `autoconfirm=false` liefert `signUp` mit einer **bestätigten** Adresse 200 und einen erfundenen Nutzer (zufällige ID, leere `identities`). Es wird keine Mail verschickt und kein Konto angelegt. Bei einer **unbestätigten** Adresse geht die Bestätigungsmail erneut an das bestehende Konto, Passwort und Metadaten bleiben unverändert, und es kommt der echte Nutzer zurück. Die Antworten unterscheiden sich also auf API-Ebene; das ist das bekannte Restrisiko des Supabase-Standards (R6). **Die App zeigt nach jeder 200-Antwort denselben Bildschirm „Prüfe dein Postfach“ und wertet `user.identities` ausdrücklich nicht aus.** -- **Krit. 6/7:** `email_not_confirmed` kommt erst **nach** erfolgreicher Passwortprüfung. Wer das Passwort nicht kennt, erfährt nichts über den Bestätigungsstatus. -- **Krit. 9:** `UpdatePassword` mit Sitzungs-ID ruft `LogoutAllExceptMe` auf. Andere Refresh-Tokens werden ungültig, bereits ausgestellte Access-JWTs bleiben aber bis zu `jwt_expiry` (3600 s) gültig, weil PostgREST keine Sitzungen prüft. Chris hat diese Verzögerung von bis zu 60 min akzeptiert (F1, Restrisiko R8). Texte in App und Hilfe versprechen deshalb nicht „sofort“. -- **Krit. 8 (einmalig):** Mit `verifyOTP` ist der Token verbraucht. Eine neue Reset-Anforderung ersetzt den alten Token. Nach `updateUser(password)` löscht GoTrue alle Einmal-Tokens des Nutzers. +- **Krit. 4 (Enumeration):** **Voraussetzung sind beide Autoconfirm-Schalter auf `false`:** `GOTRUE_MAILER_AUTOCONFIRM=false` (AUTH-CFG-04) **und** `GOTRUE_SMS_AUTOCONFIRM=false` (AUTH-CFG-24). Ist SMS-Autoconfirm an (CLI-Standard, Self-Hosting-Vorlage), liefert `signUp` mit einer bestätigten Adresse **422 `user_already_exists`** (B0, A1). Mit beiden Schaltern aus (bestätigt): `signUp` mit einer **bestätigten** Adresse liefert 200 und einen erfundenen Nutzer (zufällige ID, leere `identities`). 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 wertet `user.identities` ausdrücklich nicht aus.** +- **Krit. 4 / Reset, Seitenkanal bei erschöpftem Mail-Limit:** Ist `email_sent` (AUTH-CFG-09, instanzweit) erschöpft, antwortet `/recover` für eine **bestehende** Adresse mit 429 `over_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):** Bei `over_email_send_rate_limit` zeigen 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_confirmed` kommt erst **nach** erfolgreicher Passwortprüfung. Falsches Passwort und unbekannte Adresse liefern beide `invalid_credentials` mit identischer Meldung (bestätigt). +- **Krit. 7 (Drosselung):** Nur je IP und nur, wenn `GOTRUE_RATE_LIMIT_HEADER` gesetzt ist **und** der Request den Header trägt (AUTH-CFG-13). Requests ohne den Header werden nicht gedrosselt. Die 429-Antwort trägt kein `Retry-After`; die App kann keine Wartezeit nennen. +- **Krit. 9:** `UpdatePassword` mit Sitzungs-ID ruft `LogoutAllExceptMe` auf. Andere Refresh-Tokens werden ungültig (`refresh_token_not_found`, bestätigt), bereits ausgestellte Access-JWTs bleiben aber bis zu `jwt_expiry` (3600 s) gültig, weil PostgREST keine Sitzungen prüft (bestätigt: GoTrue `/user` lehnt 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 `verifyOTP` ist der Token verbraucht; ein zweites Einlösen ergibt 403 `otp_expired` (bestätigt). Eine neue Reset-Anforderung ersetzt den alten Token. Nach `updateUser(password)` löscht GoTrue alle Einmal-Tokens des Nutzers. Maßgeblich für den Ablauf ist `auth.users.recovery_sent_at` bzw. `confirmation_sent_at`, nicht `auth.one_time_tokens.created_at` (B0 (i)). ### 4.4 Fehlerfälle → Domänenfehler (Mapping-Tabelle, verbindlich für `flutter-dev`) @@ -155,12 +159,13 @@ Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue- |---|---|---| | `invalid_credentials` | `AuthFailure.invalidCredentials` | allgemein: „E-Mail-Adresse oder Passwort ist nicht korrekt.“ (ein Text für beide Fälle) | | `email_not_confirmed` | `AuthFailure.emailNotConfirmed` | Hinweis „Bestätige zuerst deine E-Mail-Adresse“ + Aktion „E-Mail erneut senden“ | -| `over_request_rate_limit`, `over_email_send_rate_limit`, HTTP 429 | `AuthFailure.rateLimited` | „Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut.“ | +| `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 bei `signUp` (Trigger) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) | +| HTTP 500 bei `signUp` (Trigger; Body `{"code":"P0001","message":""}`, 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:** maßgeblich ist der HTTP-Status 500 beim `signUp`, **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 darf nur fürs Log und für Zusatzhinweise genutzt werden; das Format ist nicht zugesichert. A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. | | `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ | | sonst | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) | @@ -257,18 +262,18 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - Alter nicht bestätigt: „Shuffle ist erst ab 16 Jahren nutzbar.“ (Krit. 2: Grund sichtbar) - Zustimmung fehlt: „Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu.“ - Absenden mit `locale = appLocaleProvider` (aktive Oberflächensprache = Gerätesprache de/en, sonst en; KTO-11 Krit. 11, F6) und `terms_version = AppConfig.termsVersion` (F5). -- Erfolg (jede 200-Antwort) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4). +- 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_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP). -**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; 429 → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. +**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, 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“; `rateLimited` → Drosselungshinweis. +- 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 mit `ResendOrigin.login`: hier zeigt auch `emailSendLimited` den Drosselungshinweis, F12); `rateLimited` → Drosselungshinweis. - Erfolg → `from` oder `/`. Autofill: `AutofillGroup` plus `TextInput.finishAutofillContext()` bei Erfolg. -**ForgotPasswordScreen** (01c, Krit. 8): Feld E-Mail, danach immer CheckEmailScreen (Modus Reset), unabhängig davon, ob die Adresse existiert. +**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) @@ -278,9 +283,9 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - Recovery erfolgreich → Status `passwordRecovery` → `/reset-password`. - `linkInvalidOrExpired` → Fehlerzustand mit Aktion „Neuen Link anfordern“ (→ `/login` bzw. `/forgot-password`). -**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ → `/` (Formulierung passend zu F1). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 prüft deshalb, ob das Recovery-JWT einen `amr`-Eintrag mit Methode `recovery` (bzw. `otp`) trägt: -- **Wenn ja:** `authStatusProvider` leitet `passwordRecovery` auch aus dem `amr`-Claim der wiederhergestellten Sitzung ab. Der Guard überlebt dann den Reload. -- **Wenn nein:** Die Recovery-Sitzung bleibt nach einem Reload eine normale Anmeldung. Das ist vertretbar, weil sie den Besitz des Postfachs nachweist, wie ein Magic Link. Es ist als Risiko R12 festgehalten. Das alte Passwort bleibt dann gültig, bis der Nutzer es ändert (KTO-11). +**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ → `/` (Formulierung passend zu F1). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 (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 `passwordRecovery` lebt **nur im laufenden Prozess** (Ereignis `passwordRecovery` aus `onAuthStateChange`). `authStatusProvider` leitet ihn **nicht** aus dem `amr`-Claim ab; eine Ableitung aus `amr = otp` wü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 @@ -328,25 +333,26 @@ Alle Pakete unterstützen iOS, Android und Web. `flutter-dev` pinnt die Versione ## 7. Teststrategie Werkzeuge: `flutter_test` + `mocktail` (Unit/Widget), pgTAP (`supabase test db`), **Playwright** in `e2e/` mit zwei Projekten: -- `api`: HTTP direkt gegen das lokale GoTrue, plus Helfer für den CLI-Mailfänger (Inbucket bzw. Mailpit, siehe B0). Liest Mails, Betreff, Sprache und Links. +- `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 per `To` filtern, 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 setzt `GOTRUE_RATE_LIMIT_HEADER` nicht, ohne ihn gibt es keine IP-Drosselung (B0, A2; entschieden F11). Alle Requests in diesem Projekt tragen `X-Forwarded-For` mit 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 (Entwurf) +### 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` → kein Nutzer angelegt (500) | bestehend `01_profiles` (age_confirmation_required) | B1, A2 | +| 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) | gleicher Bildschirm für neue und vorhandene Adresse | api: Signup mit bestätigter Adresse → 200, Anzahl `auth.users` unverändert, **keine** Mail; mit unbestätigter Adresse → 200, Passwort unverändert (Login mit altem Passwort nach Bestätigung möglich) | – | B2, A2, T3 | +| Krit. 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` | Hinweis + Resend-Aktion; Resend-Erfolg/429 | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 | -| Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | api: n+1 Logins mit falschem Passwort von einer IP → 429 nach dem Burst aus AUTH-CFG-10; web: Drosselungshinweis sichtbar | – | B0, B2, A3, T3 | -| Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322) und setzt den maßgeblichen Zeitstempel um `otp_expiry` + 1 s zurück. Welche Spalte das ist, klärt B0. Wert aus AUTH-CFG-07.; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 | +| Krit. 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; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 | -| Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 | +| Krit. 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 | @@ -354,7 +360,7 @@ Testnamen und Beschreibungen enthalten die Referenz („KTO-01 Kriterium n“). | QUE-06 Krit. 1–4 | – | `meetsGuideline(androidTapTargetGuideline, labeledTapTargetGuideline, textContrastGuideline)` je Screen; `textScaler` 2.0 ohne Overflow; Fehler als Live-Region | web: Ablauf nur per Tastatur | – | U1, T2 | | Zustände Krit. 2, 3 | Mapping Netzfehler | Offline-Hinweis; Eingaben bleiben nach Fehler erhalten | – | – | A1–A4, T2 | -**Nicht automatisiert testbar, manuelle Prüfung im PR:** echter Versand über den Staging-SMTP (sobald F7 entschieden ist), HIBP im Prod-Env aus (AUTH-CFG-21), IP-Erkennung hinter dem Produktions-Gateway (AUTH-CFG-13), Sperre `/otp` am Gateway (AUTH-CFG-20). Diese Punkte stehen als Checkliste in `docs/auth-konfiguration.md` und sind vor dem Release abzuhaken. +**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 @@ -377,11 +383,11 @@ Angelegt am 2026-11-02: [#31 „chore: App-Fundament – Flutter-Gerüst und Sup | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B0 | backend-dev | **Spike (max. ½ Tag)** gegen die lokale CLI. Zu klären: (a) Wertet die Betreffzeile `.Data.locale` aus? (b) Exakte `type`-Werte für `token_hash` (email/signup, recovery). (c) Welcher Limiter greift beim Passwort-Login, mit welchen Zahlen (AUTH-CFG-10)? (d) Ist `email_sent` instanzweit? (e) Welcher Mailfänger ist im Einsatz (Inbucket oder Mailpit, API)? (f) Wie erscheinen Trigger-Fehler bei `signUp` (500, Text)? (g) Welche `reasons` liefert `weak_password`? (h) Stimmen die GoTrue-Version der CLI und das geplante Docker-Image überein? (i) Welche Spalte prüft GoTrue für den Linkablauf: `auth.users.confirmation_sent_at`/`recovery_sent_at` oder `auth.one_time_tokens.created_at`? Das braucht der E2E-Ablauftest. (j) Trägt das JWT einer Recovery-Sitzung einen `amr`-Eintrag, der sie von einer normalen Anmeldung unterscheidet (5.4, R12)? Ergebnis als Abschnitt „Verifiziert“ in `docs/auth-konfiguration.md`. | F1 | +| 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 `_auth_registration_rules.sql` (3.2): neues `handle_new_user` (Anzeigename streng, `terms_version` Pflicht, `locale`-Fallback `'en'`), Spalte `terms_version` mit Check, Standard `locale = 'en'`, Check `profiles_display_name_not_blank`, Sync-Trigger `profiles.locale` → `auth.users`. pgTAP: `01_profiles.test.sql` anpassen (Erwartung „Fallback E-Mail-Präfix“ und „kürzen auf 30“ wird zu „wird abgelehnt“); Erwartung „ohne Sprache → `'de'`“ wird zu `'en'`. Fixtures in `01_profiles`, `02_catalog`, `03_collection` und `04_plays` um `display_name` (wo fehlend) und `terms_version` ergänzen (R9). Neu `05_auth_registration.test.sql` (Krit. 10, 11, Trim, `terms_version` Pflicht und nicht änderbar, `locale`-Fallback `'en'`, Sync nur für eigenes Konto). Doku `datenmodell-r1.md`, `supabase/README.md`. | F1 (nur für `supabase start`), sonst keine | -| B2 | backend-dev | `config.toml` Auth-Abschnitt gemäß 4.1 (alle Werte explizit); Mailvorlagen `supabase/templates/confirmation.html`, `recovery.html` (de/en, Links nach E3, barrierearmes HTML, nur Text und Links); Benachrichtigungen aus | B0 | -| B3 | backend-dev | `docs/auth-konfiguration.md`: Tabelle 4.1 mit verifizierten Werten (ID, CLI-Schlüssel, Env, Wert lokal, Wert Staging/Prod, Quelle, Test-Referenz), Env-Block für Self-Hosting ohne Secrets, Release-Checkliste (manuelle Prüfungen aus 7.1) | B0, B2 | -| D1 | devops | Betriebsanteil dokumentieren und vorbereiten (kein Eingriff in Server): Auslieferung der Mailvorlagen per URL für GoTrue; `GOTRUE_RATE_LIMIT_HEADER` + Proxy-Konfiguration (Header überschreiben, nicht anhängen); Gateway-Sperre `/auth/v1/otp` und `/auth/v1/magiclink`; SPA-Rewrite für die Web-App; `GOTRUE_PASSWORD_HIBP_ENABLED=false` und `GOTRUE_MAILER_OTP_EXP=3600` explizit; SMTP, sobald Anbieter und Absender-Domain entschieden sind (F7, später). **Blockiert das Release, nicht den Merge von #1** (Hosting-Setup nach ADR-0002 steht noch aus). | B3 | +| 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) @@ -394,15 +400,15 @@ Angelegt am 2026-11-02: [#31 „chore: App-Fundament – Flutter-Gerüst und Sup | A4 | flutter-dev | ForgotPasswordScreen, EmailLinkScreen (Typ Recovery), ResetPasswordScreen mit Recovery-Guard und Abbruch → `signOut`, Erfolgsmeldung Krit. 9 | 01c | A2 (EmailLinkScreen), A1, U1 | | T1 | test-engineer | Unit-Tests: Validatoren, Fehler-Mapping (jede Zeile aus 4.4), Controller (mocktail), `authStatusProvider`, Router-Redirects | je PR | A1–A4 (begleitend) | | T2 | test-engineer | Widget-Tests je Screen: Feldfehler, identische Meldung bei Krit. 7, Zustände, a11y-Guidelines, `textScaler` 2.0, de/en | je PR | U1, A2–A4 | -| T3 | test-engineer | Playwright `e2e/`: Projekte `api` (GoTrue + Mailfänger-Helfer), `web` und `throttle` (Drosselungstests, läuft zuletzt, R13); Tests je Kriterium nach 7.1; `e2e/support/auth-config.ts` mit Bezug auf AUTH-CFG-IDs; `e2e/support/db.ts` für Zeitstempel-Manipulation (nur lokal/CI). Jeder Test nutzt eigene, zufällige E-Mail-Adressen. Der Teil `api` kann schon nach B2 starten, unabhängig von der App. | api: 01a; web: je PR | B2 (api), A2–A4 (web) | -| D2 | devops | CI-Job `e2e` auf dem Runner `docker-host`: `supabase start` (mit Mailfänger), `flutter build web --dart-define-from-file=env/ci.example.json`, statisch auf Port 3000 ausliefern, `npx playwright test`; Artefakte (Report, Traces) bei Fehlern | 01a | F8, T3 | +| 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_`), 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→B2→B3, B1 (backend-dev) -Phase 2 parallel: F7, F8 | U1 | A1 | T3-api (nach B2) | D1 (Doku, nach B3) +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) ``` @@ -415,22 +421,22 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge | # | Risiko | Auswirkung | Gegenmaßnahme | |---|---|---|---| -| R1 | Supabase-Standardwerte unterscheiden sich je nach Quelle (Binary, CLI, Docker) und Version | Tests sind grün, Produktion verhält sich anders (Krit. 7, 8) | Alle Werte pinnen (E8), B0 verifiziert gegen das geplante Image, Release-Checkliste | -| R2 | Ohne `GOTRUE_RATE_LIMIT_HEADER` und saubere Proxy-Konfiguration sieht GoTrue nur die Gateway-IP, oder Clients fälschen `X-Forwarded-For` | Ein Angreifer drosselt alle Nutzer, oder die Drosselung lässt sich umgehen (Krit. 7) | D1; manuelle Prüfung vor dem Release | -| R3 | `email_sent` ist wahrscheinlich instanzweit (30 Mails/h) | Ab der 31. Registrierung oder dem 31. Reset pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen | B0 prüft das; 30/h reicht laut Chris zum Start (F4); Monitoring auf 429 `over_email_send_rate_limit`, Wert bei Bedarf erhöhen | -| R4 | GoTrue hat keinen Schalter gegen Magic Link/OTP (`/otp`) | Mails außerhalb von Krit. 11, Konten ohne Passwort | Gateway-Sperre (AUTH-CFG-20, D1). Lokal nicht absicherbar, deshalb nur dokumentiert | +| 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). | +| 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: Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter Adresse einen erfundenen | Mit Skripten lässt sich die Existenz unbestätigter Konten erkennen | Supabase-Standardverhalten; die App verrät nichts (Krit. 4 bezieht sich auf das Erleben in der App). Bei Bedarf später Captcha oder eigener Proxy. | +| 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, falls der `amr`-Claim keine Unterscheidung erlaubt (B0) | Nutzer ist angemeldet, ohne ein neues Passwort gesetzt zu haben | Vertretbar (Postfachbesitz nachgewiesen); bei vorhandenem `amr` wird der Guard daraus abgeleitet (5.4) | -| R13 | Drosselungstests leeren den gemeinsamen IP-Bucket des CI-Runners für bis zu 5 min | Nachfolgende Login-Tests scheitern zufällig | Drosselungstests (AUTH-CFG-10/11) in einem eigenen Playwright-Projekt, das zuletzt läuft, oder Neustart des Auth-Containers davor. **Keine** gefälschten `X-Forwarded-For`-Header zur Isolation. | +| 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/Inbucket; D1 ist Release-Blocker, kein Merge-Blocker | +| 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 | ### 9.2 Fragen an Chris (alle entschieden am 2026-11-02) @@ -442,22 +448,46 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge | F4 | Reichen 30 Mails pro Stunde zum Start? | entschieden | Ja | AUTH-CFG-09, R3 | | F5 | Version der zugestimmten Rechtstexte speichern? | entschieden | Ja: Spalte `terms_version`, Zeitstempel über `terms_accepted_at`. Der Client übergibt die Version aus einer konfigurierbaren Konstante (`TERMS_VERSION`). | 3.1, 3.2, 4.2, 5.1, 5.4, 7.1, B1, F3, R9 | | F6 | Profilsprache ohne Angabe? | entschieden | Gerätesprache, sonst Englisch (QUE-05). Der Client sendet immer die aktive Sprache; der Server fällt auf `'en'` zurück (Standard und Trigger, neue Migration). | 3.1, 3.2, Mailvorlagen 4.1 (Else-Zweig Englisch), 5.4, 7.1, B1 | -| F7 | SMTP-Anbieter und Absender-Domain? | **vertagt** (Entscheidung später) | Lokal Mailpit/Inbucket; D1 bleibt Release-Blocker, kein Merge-Blocker | AUTH-CFG-22, D1, R16 | +| 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 | Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release. ### 9.3 Ausstehende Prozessschritte -- Diese Spec ist noch nicht committet. Nach dem Commit auf `feature/1-auth-registrierung-anmeldung`: Kommentar mit Link zur Spec in Issue #1 und Label `status/spec-review` setzen. -- Freigabe der Spec durch Chris, danach Umsetzung beginnend mit #31. +- 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 vermutlich `false` sein. Die Info-Mail an die alte Adresse kommt dann über die Benachrichtigung `email_changed` (AUTH-CFG-19). -- Passwort ändern mit aktuellem Passwort (N13): GoTrue-Einstellung `secure_password_change` bzw. `UpdatePasswordRequireCurrentPassword` prüfen; Recovery-Sitzungen sind davon ausgenommen (passt zu #1). +- Passwort ändern mit aktuellem Passwort (N13): AUTH-CFG-25 (`secure_password_change`, in #1 `false`) 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_version` mit `TERMS_VERSION`) ist nicht Teil von R1. Falls gewünscht, als eigene Story erfassen; dafür bräuchte es dann eine RPC zum Aktualisieren von `terms_version`. +- `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true` ist CLI-Standard (B0); Entscheidung bleibt bei KTO-11 (AUTH-CFG-23). +- `user_metadata.locale` kann der Nutzer selbst über `auth.updateUser(data: …)` ändern; die Mailsprache kann dann vom Profil abweichen (nur eigenes Konto). Der Client ändert die Sprache ausschließlich über `profiles`; 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). + +1. **Statuskopf und Quellen:** „freigegeben 2026-11-02, angepasst an Spike-Ergebnisse“; Spike-Dokument und Migration `20261102212208_auth_registration_rules.sql` als Quellen. +2. **Neue Konfiguration AUTH-CFG-24** (A1): `[auth.sms] enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`, im Self-Hosting `ENABLE_PHONE_AUTOCONFIRM=false` (Vorlage `docker/.env.example` setzt `true`). Sonst 422 `user_already_exists` statt Enumerationsschutz (Krit. 4). AUTH-CFG-04 verweist darauf. +3. **4.1:** Einleitung nicht mehr „Entwurf“; AUTH-CFG-09 instanzweit und lokal wirkungslos; AUTH-CFG-10 Schlüssel `token_refresh` bestätigt, kein `Retry-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. +4. **Fehlerbehandlung (E6, 4.2, 4.4)** (A4): Trigger-Fehler kommen als HTTP 500 mit `{"code":"P0001","message":""}` im Body (Header `X-Sb-Error-Code: unexpected_failure`). Mapping auf `registrationRejected` über HTTP 500 bei `signUp`, nicht über `P0001`; die Meldung nur für Log/Zusatzhinweise. 429 ohne Dauerangabe. +5. **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`. +6. **5.4** (A5): Der `amr`-Weg entfällt (Recovery-JWT `amr = otp`, nicht unterscheidbar); Guard nur im laufenden Prozess, Ableitung aus `amr` unzulässig. +7. **7 / 7.1:** neues Playwright-Projekt `throttle` gegen Extra-Container; Krit. 2 und 10 erwarten 500 mit Body-Meldung; Krit. 4 prüft `identities` leer (Regressionstest AUTH-CFG-24) und nennt den akzeptierten Seitenkanal; Krit. 7 echter Test im Projekt `throttle` (Login #31 → 429, zweite IP frei), Web-Hinweis per gemocktem 429; Krit. 8 Spalten `recovery_sent_at` / `confirmation_sent_at`, zweites Einlösen 403 `otp_expired`; manuelle Checkliste erweitert (AUTH-CFG-09, -13, -24). +8. **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.md` nach `docs/auth-konfiguration.md`**; D1 pinnt `gotrue:v2.197.0`, setzt `ENABLE_PHONE_AUTOCONFIRM=false`, GoTrue nur über den Proxy; **D2 startet im CI einen zusätzlichen GoTrue-Container** mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For`; **T3** Projekt `throttle` gegen diesen Container; Phasenplan 8.4 ergänzt. +9. **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 `/recover` 429 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 Projekt `throttle`). +10. **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. +11. **F12 entschieden (Chris, 2026-11-02):** Neuer Domänenfehler `AuthFailure.emailSendLimited` für `over_email_send_rate_limit` bei `signUp` und `resetPasswordForEmail`; `RegisterController` und `ForgotPasswordController` behandeln 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. +12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst. +13. **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üssel `auth.email.enabled` (statt `auth.email.enable_signup`). In B2 noch nachzuziehen: `secure_password_change` dort „ohne eigene ID“ → AUTH-CFG-25. diff --git a/supabase/config.toml b/supabase/config.toml index 772e81e..e5f164c 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -249,7 +249,7 @@ double_confirm_changes = true # AUTH-CFG-04 (wirkt lokal): E-Mail-Bestätigung Pflicht (CLI-Standard wäre false). Voraussetzung für den # Enumerationsschutz zusammen mit AUTH-CFG-24. Staging/Prod: GOTRUE_MAILER_AUTOCONFIRM=false. enable_confirmations = true -# Ohne AUTH-CFG-ID (Spec 9.4): Passwortänderung ohne erneute Anmeldung bleibt wie im Standard. +# AUTH-CFG-25 (Spec 9.4): Passwortänderung ohne erneute Anmeldung bleibt wie im Standard. # Recovery-Sitzungen sind ohnehin ausgenommen; die Entscheidung "aktuelles Passwort verlangen" fällt in KTO-11 (N13). secure_password_change = false # AUTH-CFG-08 (wirkt lokal): Mindestabstand zwischen Mails je Nutzer. Lokal/CI 1 s (sonst scheitert der