Adds display name validation, terms_version, locale sync trigger and pgTAP tests (KTO-01 criteria 1, 2, 10, 11). Includes GoTrue spike results. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
18 KiB
18 KiB
Spike B0 – Ergebnisse zu GoTrue (Spec #1, KTO-01)
| Issue | #1 (KTO-01), Aufgabe B0 |
| Spec | docs/specs/1-auth-registrierung-anmeldung.md (§ 4.1, 4.3, 5.4, 7.1, 8.2, 9.1) |
| Datum | 2026-11-02 |
| Autor | backend-dev |
| Versionen | Supabase CLI 3.0.0, GoTrue v2.197.0 (Image public.ecr.aws/supabase/gotrue:v2.197.0), Postgres 17.11, Mailpit v1.31.3 |
Vorgehen und Grenzen der Aussagekraft
- Die lokale CLI wurde mit einer temporären
config.tomlgestartet:enable_confirmations = true,minimum_password_length = 10,email_sent = 100,max_frequency = "1s", Vorlagen für Bestätigung und Reset mit.Data.locale-Verzweigung (Vorlagen und Änderungen sind wieder entfernt;config.tomlim Repo ist unverändert, das ist Aufgabe B2). - Alle Requests liefen gegen
http://127.0.0.1:54321/auth/v1(Kong, wie die App). Die Drosselungstests (c, d) liefen zusätzlich gegen einen zweiten GoTrue-Container desselben Images im Netz der CLI, mit den Umgebungsvariablen des CLI-Containers plusGOTRUE_RATE_LIMIT_HEADER=X-Forwarded-ForundGOTRUE_RATE_LIMIT_EMAIL_SENT=5. Das entspricht dem Self-Hosting-Pfad (Env stattconfig.toml). - Korrektur während des Spikes: Die erste Messung zu Krit. 4 zeigte 422
user_already_exists; die Ursache warGOTRUE_SMS_AUTOCONFIRM=true(CLI-Standard), nicht GoTrue selbst. Ein Wiederholungstest mit[auth.sms] enable_confirmations = truebestätigt das Verhalten der Spec (siehe A1). - Nicht aussagekräftig lokal: SMTP-Anbieter, Verhalten hinter dem echten Produktions-Gateway, Header-Behandlung durch den Proxy, Mailzustellung. Die Mail-Rate-Limits der CLI gelten nicht (siehe d).
- Die Versionsprüfung (h) basiert auf der
docker-compose.ymlim Supabase-Repository (Stand heute, Branchmaster); eine eigene Self-Hosting-Instanz gibt es noch nicht.
Kurzfassung: Abweichungen von der Spec
| # | Abweichung | Wirkung |
|---|---|---|
| A1 | Der Enumerationsschutz (Krit. 4) hängt an einer weiteren Einstellung: GOTRUE_SMS_AUTOCONFIRM muss false sein. Die CLI-Vorlage setzt [auth.sms] enable_confirmations = false und damit GOTRUE_SMS_AUTOCONFIRM=true. Dann liefert Signup mit einer bestätigten Adresse 422 user_already_exists. Mit [auth.sms] enable_confirmations = true (GOTRUE_SMS_AUTOCONFIRM=false) kommt wie in der Spec 200 mit erfundenem Nutzer (zufällige ID, identities leer, keine Mail, Log user_repeated_signup). |
Neue Zeile AUTH-CFG-24 für B2/B3 (auth.sms.enable_confirmations = true, Env GOTRUE_SMS_AUTOCONFIRM=false). Die Selbsthosting-Vorlage docker/.env.example setzt ENABLE_PHONE_AUTOCONFIRM=true, die Falle gilt dort also auch. Die Spec-Annahme 4.3 stimmt dann. |
| A2 | Ohne GOTRUE_RATE_LIMIT_HEADER gibt es gar keine IP-Drosselung (weder Login noch Signup noch Refresh). Auch mit gesetzter Konfiguration wird ein Request ohne diesen Header nicht gedrosselt (60 Fehllogins ohne X-Forwarded-For gegen den Container mit Header-Konfiguration: kein 429). Die lokale CLI kann den Header nicht setzen. |
AUTH-CFG-13 ist Pflicht, nicht optional. GoTrue darf nur über den Proxy erreichbar sein, der den Header setzt (sonst umgeht ein direkter Zugriff die Drosselung). Drosselungstests (Krit. 7) sind gegen die CLI nicht möglich (Frage Q1). Betrifft B2, B3, D1, D2, T3. |
| A3 | email_sent wirkt in der lokalen CLI nicht (GOTRUE_RATE_LIMIT_EMAIL_SENT=360000, obwohl email_sent = 100 gesetzt war). Wirkt im Self-Hosting-Env und ist dort instanzweit. |
AUTH-CFG-09 lokal nicht testbar; Seiteneffekt auf Enumeration (siehe d). |
| A4 | Trigger-Fehler kommen als HTTP 500 mit der Meldung des Triggers im Body ({"code":"P0001","message":"display_name_invalid"}), nicht als generisches „Database error saving new user“. |
Der Client kann den Grund unterscheiden. Verhalten ist versionsabhängig, siehe (f). |
| A5 | Das JWT einer Recovery-Sitzung trägt amr = [{"method":"otp"}], identisch zur Sitzung nach Bestätigungslink. Es ist also nicht als Recovery erkennbar. |
Der amr-Weg aus 5.4 entfällt; es gilt R12 (Pfad „Wenn nein“). |
| A6 | Für den Linkablauf zählt auth.users.confirmation_sent_at bzw. recovery_sent_at, nicht auth.one_time_tokens.created_at. |
T3 setzt die Spalten an auth.users zurück. |
| A7 | /otp und /magiclink lassen sich nicht abschalten, und ein neues Konto per /otp ist möglich, wenn der Aufrufer gültige Metadaten (data) mitschickt. Ohne Metadaten blockiert der Trigger (500). |
AUTH-CFG-20 (Gateway-Sperre) bleibt nötig; der Trigger ersetzt sie nicht. |
Alle übrigen Annahmen der Spec wurden bestätigt (Details unten).
Ergebnis je Punkt
(a) Betreffzeile und Vorlage werten .Data.locale aus
- Annahme: Vorlage und Betreff lassen sich mit
{{ if and .Data (eq (index .Data "locale") "de") }}verzweigen; sonst Englisch. - Test: Signup mit
localede, en und ohnelocale; Mails in Mailpit gelesen. Danachprofiles.localeaufengesetzt (Sync-Trigger) und Passwort-Reset angefordert. - Ergebnis: bestätigt.
deführte zu deutschem Betreff und Text,enund fehlende Angabe zu Englisch. Betreff und Text verzweigen gleich. Nach der Profiländerung aufenkam die Reset-Mail englisch (Sync-Trigger wirkt Ende-zu-Ende).index .Data "locale"ist sicher, wenn der Schlüssel fehlt (leer, kein Fehler). Die CLI liefert die Vorlagen per interner URL (GOTRUE_MAILER_TEMPLATES_*=http://…kong:8088/email/…), wie beim Self-Hosting. - Konsequenz B2: Der Rückfall „zweisprachiger Betreff“ ist nicht nötig.
{{ .Data.locale }}undindexfunktionieren beide; für Else-Zweigindexverwenden.
(b) type-Werte für token_hash
- Test:
POST /verifymittoken_hashaus der Mail. - Ergebnis: bestätigt. Bestätigung:
type=emailundtype=signupwerden akzeptiert (beide getestet, Sitzung wird ausgestellt,email_confirmed_atgesetzt). Reset:type=recovery. Ein Token ist nach der Einlösung verbraucht: zweiter Versuch 403otp_expired(„Email link is invalid or has expired“). - Konsequenz B2/A2/A4: Bestätigungsvorlage mit
type=email, Reset mittype=recovery. Der Client mapptotp_expired(403) auflinkInvalidOrExpired, auch für „schon benutzt“.
(c) Limiter beim Passwort-Login (AUTH-CFG-10)
- Annahme: Passwort-Login nutzt den Token-Limiter (150 je 5 min je IP, Burst 30).
- Test: Fehlgeschlagene Logins in Schleife. (1) Gegen die CLI über Kong: 600 Logins, 300 Refresh-Aufrufe, 80 Signups, 80 Recovers in wenigen Sekunden, kein einziges 429. (2) Gegen den Spike-Container mit
GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-Forund fester Quell-IP: Login #31 wird mit 429over_request_rate_limitabgelehnt (Burst 30). Nach 20 s waren wieder 10 Logins möglich (etwa 0,5 pro Sekunde, also 150 je 300 s). Signup: ebenfalls Burst 30, danach 429 (LimiterRATE_LIMIT_OTP= 30 je 5 min). Eine zweite Quell-IP war währenddessen nicht betroffen. Die 429-Antwort hat keinenRetry-After- oderX-RateLimit-*-Header, nurX-Sb-Error-Code: over_request_rate_limit. - Ergebnis: Limiter bestätigt, aber mit schwerwiegender Einschränkung (A2). Passwort-Login nutzt
GOTRUE_RATE_LIMIT_TOKEN_REFRESH(150 je 5 min, Burst 30); Signup/Recover/Resend nutzenGOTRUE_RATE_LIMIT_OTP(30 je 5 min). OhneGOTRUE_RATE_LIMIT_HEADERgreift keiner der Limiter. Die CLI setzt den Header nicht und bietet keinen Schalter dafür; lokal und im CI (CLI) gibt es deshalb keine Drosselung. Zusätzlich zeigen die Audit-Logsip_address: "". - Konsequenz:
- AUTH-CFG-13 ist für Staging/Prod zwingend (sonst keine Drosselung von Passwort-Raten, Krit. 7). Der Proxy muss den Header setzen und Client-Werte überschreiben.
- T3/D2: Der Drosselungstest (Krit. 7) kann nicht gegen die CLI laufen. Optionen siehe Frage Q1.
- Die Tabelle AUTH-CFG-10/11: Env-Namen stimmen (
…_TOKEN_REFRESH,…_OTP). Die Aussage „Signups, Recover, Resend teilen sich diesen Limiter“ ist nur für Signup geprüft;/recoverwurde bei unbekannten Adressen nicht gedrosselt, weil keine Mail ausgelöst wird (nur das IP-Limit gilt, und das greift ohne Header nicht).
(d) email_sent instanzweit?
- Test: Spike-Container mit
GOTRUE_RATE_LIMIT_EMAIL_SENT=5. Sechs Signups mit verschiedenen Adressen und verschiedenen Quell-IPs. - Ergebnis: bestätigt, instanzweit. Signup 1–5 ok, ab Signup 6 429
over_email_send_rate_limit(„email rate limit exceeded“) für alle IPs. Das gilt auch für/recoveran bestehende Adressen. In der lokalen CLI wirktemail_sentnicht: der CLI-Container läuft mitGOTRUE_RATE_LIMIT_EMAIL_SENT=360000, obwohlconfig.toml100 enthielt (die CLI-Doku nennt „Requires auth.email.smtp to be enabled“). Der Standard der CLI-Vorlage (email_sent = 2) greift also lokal ebenfalls nicht. - Neue Nebenwirkung auf Krit. 4: Ist das Mail-Limit erschöpft, antwortet
/recoverfür eine bestehende Adresse mit 429, für eine unbekannte Adresse mit 200 (es wird keine Mail ausgelöst). Das ist ein Enumerations-Seitenkanal während der Sperre. Mögliche Gegenmaßnahmen: Limit höher setzen oder ein Proxy vor GoTrue. Siehe Frage Q2. - Konsequenz B2/B3: AUTH-CFG-09 nur im Env-Block wirksam; in
config.tomlden Wert dokumentieren, aber als „lokal ohne Wirkung“ kennzeichnen. R3 bleibt bestehen.
(e) Mailfänger
- Ergebnis: Mailpit v1.31.3 (Container
supabase_inbucket_shuffle, Name historisch). Web-Oberfläche und API:http://127.0.0.1:54324. Relevante Endpunkte:GET /api/v1/messages(Liste mitTo[].Address,Subject,ID),GET /api/v1/message/{id}(HTML,Text),DELETE /api/v1/messages. SMTP-Port nach außen nur mitsmtp_portin[local_smtp]. - Konsequenz T3: Der Mail-Helfer spricht die Mailpit-API. Das Token steht im HTML (und im Text) doppelt; das erste Vorkommen genügt. Mails je Test per
Tofiltern (zufällige Adressen), nicht per globalem Löschen, wenn Tests parallel laufen.
(f) Trigger-Fehler bei signUp
- Test: Signup ohne
display_name, ohne Altersbestätigung (age_confirmed:"false"), ohneterms_version. - Ergebnis: abweichend (A4). HTTP 500, Header
X-Sb-Error-Code: unexpected_failure, Body{"code":"P0001","message":"display_name_invalid"}(bzw.age_confirmation_required, …). Der Text des Triggers wird also durchgereicht. Im GoTrue-Log steht zusätzlich „current transaction is aborted“. Es entsteht kein Nutzer. - Konsequenz A1/A2 (Flutter):
registrationRejecteddarf anAuthException.messageunterscheiden, ist aber nur zum Logging und für Zusatzhinweise gedacht; die Hauptvalidierung bleibt im Client. Das Format ist nicht stabil vertraglich zugesichert (bei GoTrue-Updates erneut prüfen; T3 sichert es mit einem API-Test ab).
(g) weak_password
- Test: Signup mit 9 Zeichen.
- Ergebnis: bestätigt. HTTP 422,
error_code: weak_password,msg: "Password should be at least 10 characters.",weak_password.reasons = ["length"]. Mitpassword_requirements = ""kommt kein Grundcharacters. - Konsequenz A1: Mapping 4.4 stimmt.
(h) Versionen CLI und Self-Hosting-Image
- Ergebnis: bestätigt. CLI 3.0.0 startet GoTrue
v2.197.0. Diedocker-compose.ymldes Supabase-Repos (Branchmaster, heute) verwendetsupabase/gotrue:v2.197.0. Das Imagepublic.ecr.aws/supabase/gotrue:v2.197.0ließ sich mit den Env-Variablen der CLI direkt starten (zusätzlich erforderlich:API_EXTERNAL_URL). - Konsequenz B3/D1: Das Self-Hosting-Image beim Aufsetzen auf
v2.197.0pinnen (nichtlatest) und bei jedem Update die Punkte (c), (d), (f) wiederholen. Die Postgres-Version im Compose (17.6.1.136) weicht von der CLI (17.11.0.011) im Patchstand ab; Hauptversion 17 stimmt mitconfig.tomlüberein.
(i) Welche Spalte begrenzt die Linkgültigkeit?
- Test: Neuen Reset angefordert, nur
auth.users.recovery_sent_atum 2 h zurückgesetzt, Token eingelöst. Dann neuen Reset, nurauth.one_time_tokens.created_atum 2 h zurückgesetzt, Token eingelöst. Für die Bestätigungconfirmation_sent_atebenso. - Ergebnis: bestätigt für
auth.users, abweichend von der Tabelle. Zurückgesetztesrecovery_sent_at(bzw.confirmation_sent_at) ergibt 403otp_expired. Zurückgesetztesone_time_tokens.created_atallein hat keine Wirkung (Einlösung gelang). - Konsequenz T3:
e2e/support/db.tssetztauth.users.recovery_sent_at(Reset) bzw.confirmation_sent_at(Bestätigung) aufnow() - (otp_expiry + 1 s). Direktzugriff alspostgres(Port 54322) genügt.
(j) amr im Recovery-JWT
- Test: Access-Token nach
verifymittype=recovery, nachverifymittype=emailund nach Passwort-Login dekodiert. - Ergebnis: abweichend (A5). Recovery:
amr = [{"method":"otp"}],aal1. Bestätigung: ebenfallsotp. Passwort-Login:password. Recovery ist von einer Bestätigungssitzung nicht unterscheidbar; nach dem Passwortwechsel in der Sitzung bleibt der Eintrag gleich. - Konsequenz A4/R12: Pfad „Wenn nein“ aus 5.4: Der Guard
passwordRecoverylebt nur im laufenden Prozess (EreignispasswordRecovery). Eine Ableitung ausamr = otpwürde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist nicht zulässig. R12 bleibt als akzeptiertes Restrisiko.
Weitere geprüfte Annahmen (4.3 und Tabelle 4.1)
| Annahme | Test | Ergebnis |
|---|---|---|
| Krit. 4, bestätigte Adresse: 200 mit erfundenem Nutzer, keine Mail | Signup mit bestätigter Adresse, zuerst mit CLI-Standard, dann mit [auth.sms] enable_confirmations = true |
Nur mit GOTRUE_SMS_AUTOCONFIRM=false bestätigt (A1): Dann 200, zufällige ID (nicht die des echten Nutzers), identities leer, keine Mail, kein neues Konto, das ursprüngliche Passwort bleibt gültig. Mit dem CLI-Standard (SMS_AUTOCONFIRM=true) kommt stattdessen 422 user_already_exists. |
| Krit. 4, unbestätigte Adresse: Mail erneut, Passwort und Metadaten unverändert | Signup mit neuem Passwort und neuer locale für unbestätigte Adresse |
Bestätigt: 200 mit echtem Nutzer, Bestätigungsmail erneut, Passwort-Hash und user_metadata unverändert, kein zweites Konto. |
| Resend / Reset immer 200 | /resend und /recover mit bestätigter, unbestätigter und unbekannter Adresse |
Bestätigt (200 {}); Mail geht nur an eine existierende, passende Adresse. Einschränkung bei Mail-Limit: siehe (d). |
Krit. 6/7: email_not_confirmed erst nach richtigem Passwort |
Login unbestätigt mit richtigem/falschem Passwort, unbekannte Adresse | Bestätigt: richtig → 400 email_not_confirmed; falsch und unbekannt → 400 invalid_credentials, identische Meldung. |
| Krit. 8: Reset-Link einmalig | Zweites Einlösen | Bestätigt (403 otp_expired). |
| Krit. 9: Reset beendet andere Sitzungen | Sitzungen A, B; Reset über Recovery-Sitzung; Passwort geändert | Bestätigt: Refresh-Token von A und B → refresh_token_not_found. Der alte Access-Token von A wird von GoTrue (/user) mit 403 abgelehnt, von PostgREST aber weiter akzeptiert (200), bis er abläuft. Das ist R8 (bis 60 min), bewusst akzeptiert. |
same_password |
Reset mit altem Passwort | Bestätigt: 422 same_password. |
| Passwortmindestlänge 10 | Signup mit 9 Zeichen | Bestätigt (siehe g). |
| Benachrichtigungen aus (Krit. 11) | Passwortänderung beobachtet | Bestätigt: Es kommt keine Mail. Der CLI-Standard ist „aus“; in config.toml trotzdem explizit setzen. |
| Magic Link abschaltbar (AUTH-CFG-20) | /otp und /magiclink für bestätigten Nutzer; /otp für neue Adresse |
Nicht abschaltbar (A7): Bestätigter Nutzer bekommt Mail „Your sign-in link“. Neue Adresse ohne Metadaten: 500 durch den Trigger (age_confirmation_required), kein Nutzer. Neue Adresse mit gültigen Metadaten: Konto wird angelegt (unbestätigt, mit Passwort-Hash eines Zufallswerts) und eine Bestätigungsmail geht raus. Ein zweiter Aufruf innerhalb von max_frequency ergibt 429. |
| Zusätzliche Auffälligkeit | – | GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true (CLI-Standard) bleibt für KTO-11 offen (AUTH-CFG-23). |
Konsequenzen für B2, B3 und die übrigen Aufgaben
- B2: Alle Werte aus 4.1 in
config.toml.email_sentund die Rate-Limit-Einstellungen sind lokal wirkungslos bzw. nur mit Header wirksam; das im Kommentar kenntlich machen. Vorlagen:type=email/type=recovery,index .Data "locale". - B3: Der Env-Block für Self-Hosting muss
GOTRUE_RATE_LIMIT_HEADERals Pflicht aufführen, plusGOTRUE_RATE_LIMIT_EMAIL_SENT,…_TOKEN_REFRESH,…_OTP,…_VERIFY. Release-Checkliste: Drosselung am echten Gateway mit 31 Fehllogins einer IP prüfen; Test, dass ein gefälschterX-Forwarded-Forüberschrieben wird. - Spec-Anpassungen (nach Entscheidung von Chris): 4.3 (Krit. 4, Enumeration), 5.4 (
amr), 7.1 (Krit. 7, 8, 4), 8.2 (B0-Text), R2, R3, R6, R12. - B2/B3 (neu, A1): Zeile AUTH-CFG-24 ergänzen:
auth.sms.enable_confirmations = truebzw.GOTRUE_SMS_AUTOCONFIRM=false(SMS ist ausgeschaltet, die Einstellung steuert trotzdem den Enumerationsschutz bei E-Mail-Signup). T3 sichert das mit einem API-Test für Krit. 4 ab (bestätigte Adresse → 200,identitiesleer). - T3: Drosselungstests nur gegen einen GoTrue mit gesetztem Header und Requests mit Header (Q1).
- KTO-11-Hinweis:
user_metadata.localekann der Nutzer selbst überauth.updateUser(data: …)ändern, die Mailsprache kann so vonprofiles.localeabweichen (Wirkung nur auf das eigene Konto). Der Client ändert die Sprache ausschließlich überprofiles; der Sync-Trigger ziehtuser_metadatabei der nächsten Profiländerung nach.
Offene Fragen an Chris
| # | Frage | Hintergrund |
|---|---|---|
| Q1 | Krit. 7: Die lokale CLI kann keine IP-Drosselung (kein RATE_LIMIT_HEADER). Soll der Drosselungstest im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem Header laufen (wie in diesem Spike), oder nur als manuelle Prüfung auf Staging? |
A2, Aufwand für D2/T3. |
| Q2 | Seitenkanal bei erschöpftem Mail-Limit (/recover bestehende Adresse 429, unbekannte 200). Akzeptiert (Limit 30/h, Angriff erzeugt vor allem Ausfälle), oder soll das Limit höher gesetzt bzw. durch einen Proxy ergänzt werden? |
(d), R3, R6. |