feat(auth): KTO-01 Backend – Registrierungsregeln, Auth-Konfiguration, Mailvorlagen (1/4) #33

Merged
cschlaefke merged 7 commits from feature/1-auth-registrierung-anmeldung into main 2026-11-02 22:57:49 +00:00
5 changed files with 364 additions and 40 deletions
Showing only changes of commit 59a47c5a5d - Show all commits

173
docs/auth-konfiguration.md Normal file
View file

@ -0,0 +1,173 @@
# Auth-Konfiguration (Supabase Auth / GoTrue)
| | |
|---|---|
| Gilt für | KTO-01 (Issue #1) und alle folgenden Auth-Stories |
| Stand | 2026-11-02 |
| Versionen (verifiziert) | Supabase CLI 3.0.0, GoTrue `supabase/gotrue:v2.197.0`, Postgres 17, Mailpit v1.31.3 |
| Quellen | `docs/specs/1-auth-registrierung-anmeldung.md` (§ 4.1, 4.3), `docs/specs/1-auth-spike-ergebnisse.md` (Nachweis, bleibt unverändert liegen) |
| Pflege | Jede Änderung an `supabase/config.toml` (Abschnitt `[auth*]`) und am Env-Block unten gemeinsam ändern. Tests referenzieren die IDs `AUTH-CFG-nn`. |
Zweck: Die Auth-Einstellungen sind explizit festgelegt, weil sich die Standardwerte je nach Quelle unterscheiden (GoTrue-Binary, CLI-Vorlage, Docker-Setup; Klärung 18). Lokal und in der CI gilt `supabase/config.toml`. Staging und Produktion (self-hosted, ADR-0002) setzen dieselben Werte per Umgebungsvariablen.
## 1. Einstellungen (AUTH-CFG)
Spalte „Wirkung“: **lokal** = wirkt in der CLI über `config.toml`; **Env** = wirkt nur im Self-Hosting über Umgebungsvariablen (in `config.toml` nur als Kommentar bzw. Zielwert); **Gateway** = nur am Proxy/Gateway lösbar.
| ID | Einstellung | Wert | Wirkung | `config.toml` / Env | Begründung | Krit. |
|---|---|---|---|---|---|---|
| AUTH-CFG-01 | Site-URL | lokal `http://localhost:3000`; Prod `https://shuffle.cshomelabs.work` (Platzhalter); Staging legt devops fest | lokal + Env | `auth.site_url` / `GOTRUE_SITE_URL` | Basis der Mail-Links (`{{ .SiteURL }}`) | 1, 8 |
| AUTH-CFG-02 | Redirect-Allow-List | nur `<Site-URL>/auth/confirm` | lokal + Env | `auth.additional_redirect_urls` / `GOTRUE_URI_ALLOW_LIST` | Nur eigene Web-URLs erlaubt | 1, 8 |
| AUTH-CFG-03 | Registrierung erlaubt | an | lokal + Env | `auth.enable_signup`, `auth.email.enabled` / `GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_EXTERNAL_EMAIL_ENABLED=true` | Selbstregistrierung ist der Kern von KTO-01 | 1 |
| AUTH-CFG-04 | E-Mail-Bestätigung Pflicht | an | lokal + Env | `auth.email.enable_confirmations = true` / `GOTRUE_MAILER_AUTOCONFIRM=false` | CLI-Standard ist `false`. Voraussetzung für den Enumerationsschutz, zusammen mit AUTH-CFG-24 | 1, 4, 6 |
| AUTH-CFG-05 | Passwort-Mindestlänge | 10 | lokal + Env | `auth.minimum_password_length` / `GOTRUE_PASSWORD_MIN_LENGTH` | Entscheidung O10; Standard wäre 6 | 3 |
| AUTH-CFG-06 | Zeichenklassen | keine | lokal + Env | `auth.password_requirements = ""` / `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` leer | Nicht gefordert | 3 |
| AUTH-CFG-07 | Gültigkeit Bestätigungs- und Reset-Link | 3600 s (1 h) | lokal + Env | `auth.email.otp_expiry` / `GOTRUE_MAILER_OTP_EXP=3600` | Entscheidung F3. Das Self-hosted-Binary hätte 86400, deshalb im Env explizit setzen | 1, 8 |
| AUTH-CFG-08 | Mindestabstand zwischen Mails je Nutzer | lokal/CI 1 s; Staging/Prod 60 s | lokal + Env | `auth.email.max_frequency` / `GOTRUE_SMTP_MAX_FREQUENCY=60s` | Lokal bewusst 1 s, sonst scheitert der Resend-Test direkt nach dem Signup an 429. Die 60 s werden auf Staging geprüft (Checkliste) | 6, 8 |
| AUTH-CFG-09 | Mails pro Stunde, **instanzweit** | lokal/CI-Zielwert 100; Staging/Prod **30** | **nur Env** | `auth.rate_limit.email_sent` (lokal ohne Wirkung) / `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` | Entscheidung F4. Die CLI setzt intern `360000` (Spike A3). Risiko R3 | 1, 6, 8 |
| AUTH-CFG-10 | Drosselung Passwort-Login und Token-Refresh | 150 je 5 min je IP, Burst 30 | **nur Env, nur mit AUTH-CFG-13** | `auth.rate_limit.token_refresh` / `GOTRUE_RATE_LIMIT_TOKEN_REFRESH=150` | Login Nr. 31 einer IP → 429 `over_request_rate_limit`. Keine Sperre je Konto, nur je IP. 429 ohne `Retry-After` | 7 |
| AUTH-CFG-11 | Drosselung Registrierung, Reset, erneut senden | 30 je 5 min je IP, Burst 30 | **nur Env, nur mit AUTH-CFG-13** | `auth.rate_limit.sign_in_sign_ups` / `GOTRUE_RATE_LIMIT_OTP=30` | Für Signup getestet; dass Recover und Resend denselben Limiter nutzen, ist nur aus dem Code abgeleitet | 1, 6, 8 |
| AUTH-CFG-12 | Drosselung Link-Prüfung | 30 je 5 min je IP | **nur Env, nur mit AUTH-CFG-13** | `auth.rate_limit.token_verifications` / `GOTRUE_RATE_LIMIT_VERIFY=30` | – | 8 |
| AUTH-CFG-13 | Client-IP hinter Proxy | `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` | **nur Env (Pflicht Staging/Prod)** | – / `GOTRUE_RATE_LIMIT_HEADER` | Ohne diese Einstellung gibt es **keine** IP-Drosselung; Requests ohne den Header werden auch mit ihr nicht gedrosselt. Der Proxy muss den Header überschreiben | 7 |
| AUTH-CFG-14 | JWT-Laufzeit | 3600 s | lokal + Env | `auth.jwt_expiry` / `GOTRUE_JWT_EXP=3600` | Entscheidung F1. Andere Geräte sind nach einem Passwort-Reset spätestens nach 60 min abgemeldet (Restrisiko R8) | 5, 9 |
| AUTH-CFG-15 | Refresh-Token-Rotation | an, Wiederverwendungsfenster 10 s | lokal + Env | `auth.enable_refresh_token_rotation`, `auth.refresh_token_reuse_interval` / `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=true`, `GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10` | Standard | 5, 9 |
| AUTH-CFG-16 | Sitzungsbegrenzung | **nicht gesetzt** | lokal + Env | `auth.sessions.*` (auskommentiert) / `GOTRUE_SESSIONS_TIMEBOX`, `GOTRUE_SESSIONS_INACTIVITY_TIMEOUT` ungesetzt | Angemeldet bis zum Abmelden | 5 |
| AUTH-CFG-17 | Mailvorlage Bestätigung | `supabase/templates/confirmation.html`, Betreff de/en | lokal + Env | `auth.email.template.confirmation` / `GOTRUE_MAILER_TEMPLATES_CONFIRMATION` (URL), `GOTRUE_MAILER_SUBJECTS_CONFIRMATION` | Sprache aus `user_metadata.locale`, sonst Englisch. Self-hosted lädt per URL (siehe 3) | 1, 11 |
| AUTH-CFG-18 | Mailvorlage Reset | `supabase/templates/recovery.html`, Betreff de/en | lokal + Env | `auth.email.template.recovery` / `GOTRUE_MAILER_TEMPLATES_RECOVERY`, `GOTRUE_MAILER_SUBJECTS_RECOVERY` | wie oben | 8, 11 |
| AUTH-CFG-19 | Benachrichtigungs-Mails | alle aus | lokal + Env | `auth.email.notification.<typ>.enabled = false` / `GOTRUE_MAILER_NOTIFICATIONS_<TYP>_ENABLED=false` | Nur Bestätigung und Reset (Krit. 11). `email_changed` schaltet KTO-11 ein (N2) | 11 |
| AUTH-CFG-20 | Magic Link / OTP-Login | `POST /auth/v1/otp` und `/auth/v1/magiclink` am Gateway sperren | **Gateway** | – (kein Schalter in GoTrue) | Sonst Mails außerhalb von Krit. 11 und Konten ohne Passwort (Spike A7). Der Trigger ersetzt die Sperre nicht. Lokal nicht gesperrt | 11, 3 |
| AUTH-CFG-21 | Leaked-Password-Prüfung (HIBP) | aus | **nur Env** | – / `GOTRUE_PASSWORD_HIBP_ENABLED=false` | Entscheidung F2: keine Übermittlung an Dritte | 3 |
| 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 |
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).
## 2. Env-Block für Staging und Produktion (GoTrue, ohne Secrets)
Gilt für das Image `supabase/gotrue:v2.197.0` (pinnen, nicht `latest`). Secrets (JWT-Secret, SMTP-Passwort, DB-URL) stehen nicht hier, sondern im Secret-Speicher des Hosting-Setups.
```env
# AUTH-CFG-01, -02, -03
GOTRUE_SITE_URL=https://shuffle.cshomelabs.work
GOTRUE_URI_ALLOW_LIST=https://shuffle.cshomelabs.work/auth/confirm
GOTRUE_DISABLE_SIGNUP=false
GOTRUE_EXTERNAL_EMAIL_ENABLED=true
# AUTH-CFG-04, -24 (beide Autoconfirm-Schalter müssen false sein; Supabase-Vorlage: ENABLE_PHONE_AUTOCONFIRM=false)
GOTRUE_MAILER_AUTOCONFIRM=false
GOTRUE_SMS_AUTOCONFIRM=false
# AUTH-CFG-05, -06, -21
GOTRUE_PASSWORD_MIN_LENGTH=10
GOTRUE_PASSWORD_REQUIRED_CHARACTERS=
GOTRUE_PASSWORD_HIBP_ENABLED=false
# AUTH-CFG-07, -08
GOTRUE_MAILER_OTP_EXP=3600
GOTRUE_SMTP_MAX_FREQUENCY=60s
# AUTH-CFG-09 bis -13 (GOTRUE_RATE_LIMIT_HEADER ist Pflicht, sonst keine IP-Drosselung)
GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For
GOTRUE_RATE_LIMIT_EMAIL_SENT=30
GOTRUE_RATE_LIMIT_TOKEN_REFRESH=150
GOTRUE_RATE_LIMIT_OTP=30
GOTRUE_RATE_LIMIT_VERIFY=30
# AUTH-CFG-14, -15 (AUTH-CFG-16: Sitzungs-Variablen NICHT setzen)
GOTRUE_JWT_EXP=3600
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=true
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10
# AUTH-CFG-17, -18 (Betreff je Sprache verzweigt, Text identisch zu config.toml; URLs siehe Abschnitt 3)
GOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://<vorlagen-host>/confirmation.html
GOTRUE_MAILER_TEMPLATES_RECOVERY=https://<vorlagen-host>/recovery.html
GOTRUE_MAILER_SUBJECTS_CONFIRMATION={{ if and .Data (eq (index .Data "locale") "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}
GOTRUE_MAILER_SUBJECTS_RECOVERY={{ if and .Data (eq (index .Data "locale") "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}
# AUTH-CFG-19 (Benachrichtigungen aus; KTO-11 schaltet email_changed ein)
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED=false
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED=false
# AUTH-CFG-22 (Anbieter offen, F7): GOTRUE_SMTP_HOST, _PORT, _USER, _PASS, _ADMIN_EMAIL, _SENDER_NAME
```
Hinweis zu den Benachrichtigungs-Variablen: Namen aus dem GoTrue-Schema für `v2.197.0` abgeleitet; beim ersten Aufsetzen mit einer Testmail (Passwortänderung) prüfen, dass keine Mail ausgeht (Checkliste).
## 3. Gateway- und Dashboard-Einstellungen
- **Proxy vor GoTrue (AUTH-CFG-13):** GoTrue ist nur über den Proxy erreichbar. Der Proxy **überschreibt** `X-Forwarded-For` mit der echten Client-IP (nicht anhängen), sonst fälschen Clients ihre IP und umgehen die Drosselung. Ein direkter Zugriff auf GoTrue umgeht die Drosselung ebenfalls.
- **Magic-Link-Sperre (AUTH-CFG-20):** Das Gateway lehnt `POST /auth/v1/otp` und `POST /auth/v1/magiclink` ab (z. B. 404/403). Beide Endpunkte lassen sich in GoTrue nicht abschalten; `/otp` legt mit gültigen Metadaten ein Konto an.
- **Vorlagen-Hosting (AUTH-CFG-17, -18):** Self-hosted GoTrue lädt Mailvorlagen **per URL**. Die Dateien aus `supabase/templates/` werden unter einer intern erreichbaren URL bereitgestellt (z. B. statisch am Gateway). Ist die URL nicht erreichbar, fällt GoTrue **still** auf die englische Standardvorlage zurück (Risiko R5). Die URL gehört in den Health-Check.
- **SPA-Rewrite:** Die Web-App muss `/auth/confirm` ausliefern (alle Pfade → `index.html`), sonst laufen die Mail-Links ins Leere.
- **Dashboard:** Im Self-Hosting gibt es keine Cloud-Einstellungen; Studio ändert keine Auth-Werte. Alle Werte stehen im Env-Block oben. Änderungen im Studio sind zu unterlassen, damit Repo und Betrieb übereinstimmen.
- **Image-Pinning:** `supabase/gotrue:v2.197.0`. Die Postgres-Hauptversion 17 muss mit `db.major_version` übereinstimmen.
## 4. Verifiziert (Spike B0, 2026-11-02)
Getestet gegen CLI 3.0.0 und GoTrue `v2.197.0` über Kong (wie die App). Die Drosselungs- und Mail-Limit-Tests liefen zusätzlich gegen einen zweiten GoTrue-Container desselben Images mit den Env-Variablen der CLI plus `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und `GOTRUE_RATE_LIMIT_EMAIL_SENT=5` (entspricht dem Self-Hosting-Pfad). Nicht aussagekräftig lokal: SMTP-Anbieter, Verhalten hinter dem Produktions-Gateway, Header-Behandlung des Proxys, echte Zustellung. Vollständige Nachweise: `docs/specs/1-auth-spike-ergebnisse.md`.
| Punkt | Ergebnis |
|---|---|
| (a) Betreff und Vorlage werten `.Data.locale` aus | Bestätigt. `de` → deutsch; `en` oder fehlend → englisch. `index .Data "locale"` ist sicher, wenn der Schlüssel fehlt. Nach Profiländerung (Sync-Trigger) kommt die Mail in der neuen Sprache. |
| (b) `type` für `token_hash` | Bestätigung `email` (auch `signup`), Reset `recovery`. Zweites Einlösen → 403 `otp_expired`. |
| (c) Limiter Passwort-Login | `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` (150 je 5 min, Burst 30); Signup/Recover/Resend `GOTRUE_RATE_LIMIT_OTP` (30 je 5 min). **Greifen nur mit `GOTRUE_RATE_LIMIT_HEADER` und Requests mit dem Header.** Die lokale CLI kann den Header nicht setzen, also lokal keine Drosselung. 429 ohne `Retry-After`. |
| (d) `email_sent` | Instanzweit. In der CLI wirkungslos (`360000`). Bei erschöpftem Limit antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Seitenkanal, akzeptiert F12). |
| (e) Mailfänger | Mailpit v1.31.3, `http://127.0.0.1:54324/api/v1/messages`, `/api/v1/message/{id}`. |
| (f) Trigger-Fehler bei `signUp` | HTTP 500, Header `X-Sb-Error-Code: unexpected_failure`, Body `{"code":"P0001","message":"<grund>"}`. Format nicht vertraglich zugesichert. |
| (g) `weak_password` | 422, `reasons = ["length"]`. |
| (h) Versionen | CLI 3.0.0 startet GoTrue `v2.197.0`, identisch zur `docker-compose.yml` des Supabase-Repos (Stand 2026-11-02). |
| (i) Linkablauf | Maßgeblich sind `auth.users.confirmation_sent_at` / `recovery_sent_at`, nicht `auth.one_time_tokens.created_at`. |
| (j) `amr` im Recovery-JWT | `[{"method":"otp"}]`, identisch zur Bestätigungssitzung; nicht unterscheidbar (R12). |
Abweichungen von der ursprünglichen Spec-Annahme:
| # | Abweichung | Konsequenz |
|---|---|---|
| A1 | Enumerationsschutz hängt an `GOTRUE_SMS_AUTOCONFIRM=false` (CLI-Standard und Self-Hosting-Vorlage setzen `true`). Sonst 422 `user_already_exists`. | AUTH-CFG-24 |
| A2 | Ohne `GOTRUE_RATE_LIMIT_HEADER` keine IP-Drosselung; Requests ohne Header werden auch mit Einstellung nicht gedrosselt. | AUTH-CFG-13 Pflicht; Drosselungstest gegen Extra-Container |
| A3 | `email_sent` wirkt in der CLI nicht. | AUTH-CFG-09 nur per Env |
| A4 | Trigger-Fehler kommen als HTTP 500 mit der Trigger-Meldung im Body. | Mapping über Status 500 |
| A5 | Recovery-JWT ist an `amr` nicht erkennbar. | Guard nur im laufenden Prozess |
| A6 | Linkablauf wird über `auth.users.*_sent_at` bestimmt. | E2E-Helfer setzt diese Spalten |
| A7 | `/otp` und `/magiclink` nicht abschaltbar, `/otp` kann Konten anlegen. | AUTH-CFG-20 am Gateway |
### Prüfung der Konfiguration und Mailvorlagen (B2, lokal)
Mit der committeten `config.toml` geprüft (Mailpit-API): Registrierung mit `locale=de` → Betreff „Bestätige deine E-Mail-Adresse für Shuffle“, `lang="de"`; `locale=en` und ohne `locale` → englischer Betreff und Text; Passwort-Reset analog („Passwort für Shuffle zurücksetzen“ / „Reset your Shuffle password“). Links: `http://localhost:3000/auth/confirm?token_hash=…&type=email` bzw. `…&type=recovery`; der Token aus der Mail lässt sich per `POST /verify` einlösen. Im GoTrue-Container sind `GOTRUE_MAILER_AUTOCONFIRM=false`, `GOTRUE_SMS_AUTOCONFIRM=false`, `GOTRUE_PASSWORD_MIN_LENGTH=10`, `GOTRUE_MAILER_OTP_EXP=3600` und `GOTRUE_URI_ALLOW_LIST=http://localhost:3000/auth/confirm` gesetzt. Signup mit 9 Zeichen → 422 `weak_password`; Signup mit bestätigter Adresse → 200 mit erfundenem Nutzer und leeren `identities`, keine Mail.
## 5. Release-Checkliste (vor jedem Release auf Staging/Produktion)
Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken:
- [ ] GoTrue-Image ist `supabase/gotrue:v2.197.0` (oder bewusst aktualisiert, dann Spike-Punkte (c), (d), (f) wiederholen).
- [ ] Env-Block aus Abschnitt 2 ist vollständig gesetzt; `GOTRUE_PASSWORD_HIBP_ENABLED=false` (AUTH-CFG-21).
- [ ] `GOTRUE_SMS_AUTOCONFIRM=false` bzw. `ENABLE_PHONE_AUTOCONFIRM=false`: Signup mit bestätigter Adresse → 200, `identities` leer (AUTH-CFG-24).
- [ ] Drosselung am echten Gateway: 31 Fehllogins einer IP → 429; ein vom Client gefälschter `X-Forwarded-For` wird überschrieben; GoTrue ist von außen nicht direkt erreichbar (AUTH-CFG-13, -10).
- [ ] `POST /auth/v1/otp` und `/auth/v1/magiclink` werden vom Gateway abgelehnt (AUTH-CFG-20).
- [ ] Mailvorlagen-URLs erreichbar; Testmail de und en (Bestätigung und Reset) auf Staging, Betreff und Text in der richtigen Sprache (AUTH-CFG-17, -18, Risiko R5).
- [ ] Mindestabstand 60 s zwischen Mails je Nutzer (AUTH-CFG-08) und Link-Gültigkeit 1 h (AUTH-CFG-07) auf Staging geprüft.
- [ ] `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` gesetzt und Monitoring auf 429 `over_email_send_rate_limit` aktiv (AUTH-CFG-09, R3).
- [ ] Nach Passwortänderung kommt keine Benachrichtigungs-Mail (AUTH-CFG-19).
- [ ] SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7).
- [ ] SPA-Rewrite: `/auth/confirm` wird von der Web-App ausgeliefert.
- [ ] Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15).
## 6. Bekannte Grenzen
- **Keine Drosselung lokal und in der CI-CLI** (A2). Der Drosselungstest läuft gegen einen Extra-Container (Spec D2/T3).
- **Keine Wartezeit-Angabe:** Die 429-Antwort trägt kein `Retry-After`; die App nennt keine Dauer.
- **Drosselung nur je IP**, nicht je Konto.
- **Mail-Limit instanzweit** (30/h): Angreifer können Mailausfälle auslösen. Während der Sperre ist die Existenz eines Kontos über 429 vs. 200 erkennbar (R3, akzeptiert F12).
- **Enumeration auf API-Ebene:** Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter einen erfundenen (R6). Die App zeigt in beiden Fällen denselben Bildschirm.
- **Abgemeldete Geräte nach Reset:** Access-Tokens gelten bis zu 60 min weiter (R8, F1).
- **Abgebrochene Recovery** bleibt nach einem Reload als normale Anmeldung bestehen (R12).
- **Häufige Passwörter** prüft nur der Client; der Server erzwingt nur die Länge 10 (R14).
- **Magic Link / OTP** sind nicht abschaltbar, nur am Gateway sperrbar (R4).
- **Vorlagen-Rückfall:** Unerreichbare Vorlagen-URL → stille englische Standardvorlage (R5).
- **Plain-Text-Teil der Mails:** GoTrue sendet nur HTML. Die Vorlagen zeigen den Link deshalb zusätzlich als Klartext; Mailpit erzeugt lokal einen Text-Teil.

View file

@ -5,6 +5,7 @@
|---|---|
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (17 Tabellen, 2 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
| `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. |
| `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. |
| `seed.sql` | Beispieldaten nur für die lokale Entwicklung. |
## Lokal starten und testen
@ -18,6 +19,8 @@ supabase test db # alle pgTAP-Tests ausführen
- Der Trigger `handle_new_user` erzwingt Altersbestätigung, Zustimmung, `terms_version` und Anzeigename (1–30 Zeichen). Fehlertexte: siehe `docs/datenmodell-r1.md`. Clients senden diese Werte als `user_metadata` bei `signUp`.
- pgTAP-Fixtures, die `auth.users` direkt füllen, brauchen daher `age_confirmed`, `terms_accepted`, `terms_version` und `display_name` in `raw_user_meta_data`.
- Ergebnisse der GoTrue-Prüfung: `docs/specs/1-auth-spike-ergebnisse.md`.
- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md).
- Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`).
## Regeln
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.

View file

@ -103,6 +103,9 @@ openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
# AUTH-CFG-22 (wirkt lokal): Mailfänger ist Mailpit (Container heißt historisch supabase_inbucket_*).
# Web-Oberfläche und API: http://127.0.0.1:54324 (API: /api/v1/messages, /api/v1/message/{id}).
# Staging/Prod: SMTP über GOTRUE_SMTP_* (Anbieter offen, siehe docs/auth-konfiguration.md).
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
@ -155,36 +158,44 @@ max_indexes = 5
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
# AUTH-CFG-01 (wirkt lokal): Site-URL, Basis der Mail-Links ({{ .SiteURL }}).
# Staging/Prod: GOTRUE_SITE_URL (Prod-Platzhalter https://shuffle.cshomelabs.work).
site_url = "http://localhost:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["http://localhost:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
# AUTH-CFG-02 (wirkt lokal): Redirect-Allow-List, nur die eigene Web-App-Seite für Mail-Links.
# Staging/Prod: GOTRUE_URI_ALLOW_LIST.
additional_redirect_urls = ["http://localhost:3000/auth/confirm"]
# AUTH-CFG-14 (wirkt lokal): JWT-Laufzeit 3600 s. Bestimmt die Verzögerung bei KTO-01 Kriterium 9
# (andere Geräte spätestens nach 60 min abgemeldet, Restrisiko R8). Staging/Prod: GOTRUE_JWT_EXP.
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
# AUTH-CFG-15 (wirkt lokal): Refresh-Token-Rotation an, Wiederverwendungsfenster 10 s.
# Staging/Prod: GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED, GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project. Existing users can still sign in.
# AUTH-CFG-03 (wirkt lokal): Registrierung erlaubt. Staging/Prod: GOTRUE_DISABLE_SIGNUP=false.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
# AUTH-CFG-05 (wirkt lokal): Passwort-Mindestlänge 10 (O10). Staging/Prod: GOTRUE_PASSWORD_MIN_LENGTH.
minimum_password_length = 10
# AUTH-CFG-06 (wirkt lokal): keine Zeichenklassen gefordert. Staging/Prod: GOTRUE_PASSWORD_REQUIRED_CHARACTERS (leer).
password_requirements = ""
# AUTH-CFG-21 (nur Env / nur Self-Hosting): Leaked-Password-Prüfung (HIBP) ist aus (Entscheidung F2).
# Die CLI kennt dafür keinen Schlüssel und führt keine Prüfung aus. Staging/Prod muss
# GOTRUE_PASSWORD_HIBP_ENABLED=false explizit setzen.
# AUTH-CFG-20 (nur Self-Hosting / Gateway): Magic Link und OTP-Login (/auth/v1/otp, /auth/v1/magiclink)
# lassen sich in GoTrue nicht abschalten (Spike A7). Staging/Prod: am Gateway sperren.
# Lokal bewusst nicht gesperrt.
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
@ -195,18 +206,27 @@ password_requirements = ""
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
# Rate Limits. ACHTUNG: Die lokale CLI kann GOTRUE_RATE_LIMIT_HEADER nicht setzen (AUTH-CFG-13).
# Ohne diesen Header gibt es keine IP-Drosselung; lokal und in der CI-CLI wird nichts gedrosselt.
# Die Werte unten sind daher Dokumentation der Zielwerte. Wirksam sind sie nur im Self-Hosting mit
# GOTRUE_RATE_LIMIT_HEADER und Requests, die den Header tragen (Spike B0, A2).
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# AUTH-CFG-09 (nur Env, lokal OHNE Wirkung): Mails pro Stunde, instanzweit. Die CLI setzt intern
# GOTRUE_RATE_LIMIT_EMAIL_SENT=360000 (Spike A3). Lokal/CI-Zielwert 100, Staging/Prod: 30 über
# GOTRUE_RATE_LIMIT_EMAIL_SENT=30.
email_sent = 100
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
# AUTH-CFG-10 (nur mit AUTH-CFG-13 wirksam, lokal ohne Wirkung): Passwort-Login und Token-Refresh,
# 150 je 5 min je IP. Env: GOTRUE_RATE_LIMIT_TOKEN_REFRESH.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
# AUTH-CFG-11 (nur mit AUTH-CFG-13 wirksam, lokal ohne Wirkung): Registrierung, Reset, erneut senden,
# 30 je 5 min je IP. Env: GOTRUE_RATE_LIMIT_OTP.
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
# AUTH-CFG-12 (nur mit AUTH-CFG-13 wirksam, lokal ohne Wirkung): Link-Prüfung, 30 je 5 min je IP.
# Env: GOTRUE_RATE_LIMIT_VERIFY.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
@ -217,22 +237,28 @@ web3 = 30
# provider = "hcaptcha"
# secret = ""
# AUTH-CFG-13 (nur Env / nur Self-Hosting): Client-IP hinter Proxy, GOTRUE_RATE_LIMIT_HEADER
# (z. B. X-Forwarded-For). Pflicht für Staging/Prod, in der CLI nicht setzbar.
[auth.email]
# Allow/disallow the email provider. When false, users can't sign up or sign in with email.
# To block new signups only, set enable_signup = false under [auth] instead.
# AUTH-CFG-03 (wirkt lokal): E-Mail-Anmeldung erlaubt. Staging/Prod: GOTRUE_EXTERNAL_EMAIL_ENABLED=true.
enabled = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
# AUTH-CFG-23 (wirkt lokal, in #1 NICHT festgelegt): CLI-Standard bleibt unverändert; Entscheidung in
# KTO-11 (Info-Mail an die alte Adresse, N2). Staging/Prod: GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
# 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.
# Recovery-Sitzungen sind ohnehin ausgenommen; die Entscheidung "aktuelles Passwort verlangen" fällt in KTO-11 (N13).
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
# AUTH-CFG-08 (wirkt lokal): Mindestabstand zwischen Mails je Nutzer. Lokal/CI 1 s (sonst scheitert der
# Resend-Test direkt nach dem Signup mit 429). Staging/Prod: 60 s über GOTRUE_SMTP_MAX_FREQUENCY=60s.
max_frequency = "1s"
# Number of characters used in the email OTP.
# Länge des E-Mail-OTP-Codes (nur der Vollständigkeit halber gesetzt; Shuffle nutzt token_hash-Links).
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
# AUTH-CFG-07 (wirkt lokal): Gültigkeit von Bestätigungs- und Reset-Link, 3600 s (1 h, Entscheidung F3).
# Staging/Prod: GOTRUE_MAILER_OTP_EXP=3600 explizit setzen (das Binary hätte 86400).
otp_expiry = 3600
# Use a production-ready SMTP server
@ -245,23 +271,45 @@ otp_expiry = 3600
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# AUTH-CFG-17 (wirkt lokal): Mailvorlage Bestätigung, de/en per user_metadata.locale (Else-Zweig Englisch).
# Betreff und Text verzweigen gleich (Spike B0 (a)). Staging/Prod: Vorlagen per URL
# (GOTRUE_MAILER_TEMPLATES_CONFIRMATION, GOTRUE_MAILER_SUBJECTS_CONFIRMATION).
[auth.email.template.confirmation]
subject = '{{ if and .Data (eq (index .Data "locale") "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}'
content_path = "./supabase/templates/confirmation.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./supabase/templates/password_changed_notification.html"
# AUTH-CFG-18 (wirkt lokal): Mailvorlage Passwort zurücksetzen, wie oben.
# Staging/Prod: GOTRUE_MAILER_TEMPLATES_RECOVERY, GOTRUE_MAILER_SUBJECTS_RECOVERY.
[auth.email.template.recovery]
subject = '{{ if and .Data (eq (index .Data "locale") "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}'
content_path = "./supabase/templates/recovery.html"
# AUTH-CFG-19 (wirkt lokal): Benachrichtigungs-Mails explizit aus (KTO-01 Kriterium 11, nur Bestätigung
# und Reset). email_changed schaltet KTO-11 ein (N2). Staging/Prod: GOTRUE_MAILER_NOTIFICATIONS_<TYP>_ENABLED=false.
[auth.email.notification.password_changed]
enabled = false
[auth.email.notification.email_changed]
enabled = false
[auth.email.notification.phone_changed]
enabled = false
[auth.email.notification.identity_linked]
enabled = false
[auth.email.notification.identity_unlinked]
enabled = false
[auth.email.notification.mfa_factor_enrolled]
enabled = false
[auth.email.notification.mfa_factor_unenrolled]
enabled = false
[auth.sms]
# Allow/disallow the phone provider. When false, users can't sign up or sign in with SMS.
# To block new signups only, set enable_signup = false under [auth] instead.
enabled = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# AUTH-CFG-24 (wirkt lokal): SMS ist nicht im Einsatz, diese Einstellung steuert aber den
# Enumerationsschutz beim E-Mail-Signup. Mit false (= GOTRUE_SMS_AUTOCONFIRM=true, CLI-Standard) liefert
# Signup mit bestätigter Adresse 422 user_already_exists (Spike A1). MUSS true bleiben.
# Staging/Prod: GOTRUE_SMS_AUTOCONFIRM=false bzw. ENABLE_PHONE_AUTOCONFIRM=false (Vorlage setzt true!).
enable_confirmations = true
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
@ -272,7 +320,8 @@ max_frequency = "5s"
# [auth.sms.test_otp]
# 14152127777 = "123456"
# Configure logged in session timeouts.
# AUTH-CFG-16 (wirkt lokal): Sitzungsbegrenzung bewusst NICHT gesetzt (KTO-01 Kriterium 5: angemeldet
# bis zum Abmelden). Nicht auskommentieren.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"

View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
{{- /*
AUTH-CFG-17: Bestätigungs-Mail (KTO-01 Kriterium 1, 11).
Sprache aus user_metadata.locale ("de"), sonst Englisch. Keine externen Ressourcen, keine Bilder.
Der Link führt auf die App-Seite /auth/confirm; erst der Klick dort löst die Prüfung aus (Spec E3).
Das Token (TokenHash) wird bewusst nicht als Klartext angezeigt, nur der Link.
*/ -}}
{{ if and .Data (eq (index .Data "locale") "de") }}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bestätige deine E-Mail-Adresse für Shuffle</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Bestätige deine E-Mail-Adresse</h1>
<p style="margin:0 0 16px 0;">Willkommen bei Shuffle! Bitte bestätige deine E-Mail-Adresse, damit dein Konto genutzt werden kann.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">E-Mail-Adresse bestätigen</a>
</p>
<p style="margin:0 0 8px 0;">Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.</p>
<p style="margin:0 0 16px 0;">Falls der Knopf nicht funktioniert, kopiere diese Adresse in die Adresszeile deines Browsers:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email</a></p>
<p style="margin:0;color:#4b5563;">Wenn du das nicht warst, ignoriere diese E-Mail. Es wird dann nichts geändert.</p>
</div>
</body>
</html>
{{ else }}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Confirm your email address for Shuffle</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Confirm your email address</h1>
<p style="margin:0 0 16px 0;">Welcome to Shuffle! Please confirm your email address so you can use your account.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">Confirm email address</a>
</p>
<p style="margin:0 0 8px 0;">The link is valid for 1 hour and can only be used once.</p>
<p style="margin:0 0 16px 0;">If the button does not work, copy this address into your browser's address bar:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=email</a></p>
<p style="margin:0;color:#4b5563;">If this wasn't you, you can ignore this email. Nothing will be changed.</p>
</div>
</body>
</html>
{{ end }}

View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
{{- /*
AUTH-CFG-18: Passwort-zurücksetzen-Mail (KTO-01 Kriterium 8, 11).
Sprache aus user_metadata.locale ("de"), sonst Englisch. Keine externen Ressourcen, keine Bilder.
Der Link führt auf die App-Seite /auth/confirm; erst der Klick dort löst die Prüfung aus (Spec E3).
*/ -}}
{{ if and .Data (eq (index .Data "locale") "de") }}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passwort für Shuffle zurücksetzen</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Passwort zurücksetzen</h1>
<p style="margin:0 0 16px 0;">Für dein Shuffle-Konto wurde ein neues Passwort angefordert. Mit dem folgenden Knopf legst du ein neues Passwort fest.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">Neues Passwort festlegen</a>
</p>
<p style="margin:0 0 8px 0;">Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.</p>
<p style="margin:0 0 16px 0;">Falls der Knopf nicht funktioniert, kopiere diese Adresse in die Adresszeile deines Browsers:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery</a></p>
<p style="margin:0;color:#4b5563;">Wenn du das nicht warst, ignoriere diese E-Mail. Dein Passwort bleibt dann unverändert.</p>
</div>
</body>
</html>
{{ else }}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reset your Shuffle password</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
<div style="max-width:560px;margin:0 auto;padding:24px;background-color:#ffffff;">
<h1 style="margin:0 0 16px 0;font-size:22px;line-height:1.3;color:#1a1a1a;">Reset your password</h1>
<p style="margin:0 0 16px 0;">A new password was requested for your Shuffle account. Use the button below to choose a new password.</p>
<p style="margin:0 0 24px 0;">
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="display:inline-block;padding:14px 24px;background-color:#1d4ed8;color:#ffffff;font-weight:bold;text-decoration:none;border-radius:6px;">Set a new password</a>
</p>
<p style="margin:0 0 8px 0;">The link is valid for 1 hour and can only be used once.</p>
<p style="margin:0 0 16px 0;">If the button does not work, copy this address into your browser's address bar:</p>
<p style="margin:0 0 24px 0;word-break:break-all;"><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery" style="color:#1d4ed8;">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery</a></p>
<p style="margin:0;color:#4b5563;">If this wasn't you, you can ignore this email. Your password will stay the same.</p>
</div>
</body>
</html>
{{ end }}