fix(auth): treat SMTP failures like success on enumeration-sensitive calls

Refs #37

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 12:03:04 +01:00
parent f8774359ee
commit 7d094a6732
13 changed files with 248 additions and 25 deletions

View file

@ -30,9 +30,20 @@ AuthFailure mapAuthError(Object error, AuthOperation operation) {
}
if (error is AuthRetryableFetchException) {
if (operation == AuthOperation.signUp && error.statusCode == '500') {
return AuthFailure.registrationRejected(
reason: registrationRejectionReasonFrom(error.message),
);
final reason = registrationRejectionReasonFrom(error.message);
// A 500 without a known trigger message is not an input problem (SMTP
// outage, F13): it must not be told apart from success.
return reason == RegistrationRejectionReason.unknown
? const AuthFailure.unexpected()
: AuthFailure.registrationRejected(reason: reason);
}
// Enumeration-sensitive calls: a server-side failure (HTTP 5xx, e.g. SMTP
// outage, `unexpected_failure`) differs between known and unknown
// addresses. It must not look like "offline", the screens treat it like
// success (Spec #1 F13). Only answers with a status count: a transport
// error has none and says nothing about the account.
if (error.statusCode != null && _isEnumerationSensitive(operation)) {
return const AuthFailure.unexpected();
}
// Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall).
return const AuthFailure.network();
@ -49,6 +60,11 @@ AuthFailure mapAuthError(Object error, AuthOperation operation) {
return const AuthFailure.unexpected();
}
bool _isEnumerationSensitive(AuthOperation operation) =>
operation == AuthOperation.signUp ||
operation == AuthOperation.resend ||
operation == AuthOperation.requestPasswordReset;
AuthFailure _mapApiError(AuthException error, AuthOperation operation) {
switch (error.code) {
case 'invalid_credentials':

View file

@ -9,22 +9,34 @@ enum ResendOrigin {
login,
}
/// Wendet die Regel aus F12 für „E-Mail erneut senden“ an.
/// Wendet die Regel aus F12/F13 für „E-Mail erneut senden“ an.
///
/// `null` heißt: wie Erfolg behandeln. Aus dem LoginScreen wird
/// `emailSendLimited` zum sichtbaren Drosselungshinweis ([RateLimited]).
/// `null` heißt: wie Erfolg behandeln. Sichtbar bleiben nur Eingabe- und
/// Transportfehler sowie die kontounabhängige IP-Drosselung (Allowlist);
/// alles andere (auch `unexpected`, z. B. SMTP-Ausfall) ist neutral, weil es
/// zwischen bekannter und unbekannter Adresse unterscheiden kann. Aus dem
/// LoginScreen wird `emailSendLimited` zum sichtbaren Drosselungshinweis
/// ([RateLimited]).
AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) {
if (failure is EmailSendLimited) {
return switch (origin) {
ResendOrigin.checkEmail => null,
ResendOrigin.login => const AuthFailure.rateLimited(),
};
}
return failure;
return switch (failure) {
EmailSendLimited() =>
origin == ResendOrigin.login ? const AuthFailure.rateLimited() : null,
InvalidEmail() || RateLimited() || NetworkFailure() => failure,
_ => null,
};
}
/// Registrierung und Passwort-Reset (freie Adresseingabe): `emailSendLimited`
/// zählt wie Erfolg (F12, KTO-01 Kriterium 4). Liefert `null` für „wie
/// Erfolg“, sonst den Fehler.
AuthFailure? openEmailInputFailureFor(AuthFailure failure) =>
failure is EmailSendLimited ? null : failure;
/// Registrierung und Passwort-Reset (freie Adresseingabe, F12/F13): Sichtbar
/// bleiben nur Eingabefehler (`invalidEmail`, `weakPassword`,
/// `registrationRejected` mit bekanntem Grund), Transportfehler und die
/// kontounabhängige IP-Drosselung (Allowlist). Alles andere, insbesondere
/// `emailSendLimited` und `unexpected` (SMTP-Ausfall), zählt wie Erfolg
/// (KTO-01 Kriterium 4). Liefert `null` für „wie Erfolg“, sonst den Fehler.
AuthFailure? openEmailInputFailureFor(AuthFailure failure) => switch (failure) {
InvalidEmail() ||
WeakPassword() ||
RegistrationRejected() ||
RateLimited() ||
NetworkFailure() => failure,
_ => null,
};

View file

@ -168,16 +168,50 @@ void main() {
);
});
test('HTTP 500 on signUp with unknown body keeps registrationRejected', () {
test('F13: HTTP 500 on signUp with unknown body (SMTP) -> unexpected', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'oops', statusCode: '500'),
AuthRetryableFetchException(
message: '{"code":"unexpected_failure","msg":"Error sending"}',
statusCode: '500',
),
AuthOperation.signUp,
),
const AuthFailure.registrationRejected(),
const AuthFailure.unexpected(),
);
});
test('F13: HTTP 5xx on reset and resend -> unexpected, not network', () {
for (final op in [
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
for (final status in ['500', '502', '504']) {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'x', statusCode: status),
op,
),
const AuthFailure.unexpected(),
reason: '$op $status',
);
}
}
});
test('F13: a transport error without status stays network', () {
for (final op in [
AuthOperation.signUp,
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
expect(
mapAuthError(AuthRetryableFetchException(message: 'x'), op),
const AuthFailure.network(),
);
}
});
test('trigger messages map to reasons', () {
expect(
registrationRejectionReasonFrom('age_confirmation_required'),
@ -203,7 +237,7 @@ void main() {
);
});
test('other 5xx on signUp (gateway) -> network', () {
test('F13: other 5xx on signUp (gateway) -> unexpected (neutral)', () {
expect(
mapAuthError(
AuthRetryableFetchException(
@ -212,7 +246,7 @@ void main() {
),
AuthOperation.signUp,
),
const AuthFailure.network(),
const AuthFailure.unexpected(),
);
});

View file

@ -18,6 +18,28 @@ void main() {
);
});
test('F13: server-side failures (SMTP outage) count as success', () {
for (final origin in ResendOrigin.values) {
expect(
resendFailureFor(const AuthFailure.unexpected(), origin),
isNull,
);
expect(
resendFailureFor(const AuthFailure.emailNotConfirmed(), origin),
isNull,
);
}
});
test('input errors stay visible', () {
for (final origin in ResendOrigin.values) {
expect(
resendFailureFor(const AuthFailure.invalidEmail(), origin),
const AuthFailure.invalidEmail(),
);
}
});
test('other failures pass through unchanged', () {
for (final origin in ResendOrigin.values) {
expect(
@ -40,6 +62,34 @@ void main() {
);
});
test(
'F13: unexpected (SMTP outage) and unknown failures count as success',
() {
for (final failure in [
const AuthFailure.unexpected(),
const AuthFailure.emailNotConfirmed(),
const AuthFailure.invalidCredentials(),
const AuthFailure.linkInvalidOrExpired(),
]) {
expect(openEmailInputFailureFor(failure), isNull, reason: '$failure');
}
},
);
test('F13 allowlist: input, transport and IP throttling stay visible', () {
for (final failure in [
const AuthFailure.invalidEmail(),
const AuthFailure.weakPassword([WeakPasswordReason.length]),
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
const AuthFailure.network(),
const AuthFailure.rateLimited(),
]) {
expect(openEmailInputFailureFor(failure), failure);
}
});
test('rateLimited stays visible', () {
expect(
openEmailInputFailureFor(const AuthFailure.rateLimited()),

View file

@ -99,6 +99,25 @@ void main() {
expect(limited, contains(_de.authCheckEmailResent));
});
testWidgets('F13: an SMTP failure (unexpected) gives the same feedback', (
tester,
) async {
await open(tester);
await tapResend(tester);
final success = visibleTexts(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
await open(tester);
await tapResend(tester);
final smtp = visibleTexts(tester);
expect(smtp, success);
expect(smtp, isNot(contains(_de.authErrorUnexpected)));
});
testWidgets('IP rate limit shows the rate-limit hint', (tester) async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.rateLimited()));

View file

@ -74,6 +74,19 @@ void main() {
});
});
group('F13: SMTP outage (unexpected) counts as sent', () {
for (final origin in ResendOrigin.values) {
test('from $origin', () async {
stub(failed(const AuthFailure.unexpected()));
await notifier().resend('anna@example.com', origin: origin);
expect(state().sent, isTrue);
expect(state().failure, isNull);
});
}
});
group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () {
test('from the check-email screen counts as sent', () async {
stub(failed(const AuthFailure.emailSendLimited()));

View file

@ -89,6 +89,16 @@ void main() {
expect(state().shownEmailError, isFalse);
});
test('F13: unexpected (SMTP outage, HTTP 5xx) counts as success', () async {
stubReset(failed(const AuthFailure.unexpected()));
notifier().emailChanged('anna@example.com');
expect(await notifier().submit(), isTrue);
expect(state().bannerFailure, isNull);
expect(state().shownEmailError, isFalse);
});
test('the IP rate limit is a visible failure', () async {
stubReset(failed(const AuthFailure.rateLimited()));
notifier().emailChanged('anna@example.com');

View file

@ -173,8 +173,16 @@ void main() {
'bekannt@example.com',
);
// F13: SMTP outage for an existing account (HTTP 500).
final smtp = await pageFor(
failed(const AuthFailure.unexpected()),
'bekannt@example.com',
);
expect(unknown, known);
expect(limited, known);
expect(smtp, known);
expect(smtp, isNot(contains(_de.authErrorUnexpected)));
expect(known, contains(_de.authCheckEmailBodyReset));
expect(known, contains(_de.authCheckEmailDelayHint));
expect(known, isNot(contains(_de.authErrorRateLimited)));

View file

@ -388,6 +388,31 @@ void main() {
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
});
testWidgets('F13: an SMTP failure on resend shows the success feedback', (
tester,
) async {
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
final success = visibleTexts(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
expect(visibleTexts(tester), success);
expect(find.byKey(const Key('login_resent_banner')), findsOneWidget);
expect(find.text(_de.authErrorUnexpected), findsNothing);
});
testWidgets('the resend action is locked while sending', (tester) async {
final semanticsHandle = tester.ensureSemantics();
final completer = Completer<Result<void, AuthFailure>>();

View file

@ -374,10 +374,21 @@ void main() {
});
});
group('F13: SMTP outage looks like success', () {
test('unexpected (HTTP 5xx) continues like a 200', () async {
when(() => repository.signUp(any()))
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
final container = makeContainer();
fillValid(notifier(container));
expect(await notifier(container).submit(), isTrue);
expect(stateOf(container).bannerFailure, isNull);
});
});
group('Zustände Kriterium 2, 3: failures keep the input', () {
for (final failure in [
const AuthFailure.network(),
const AuthFailure.unexpected(),
const AuthFailure.registrationRejected(),
]) {
test('${failure.runtimeType} becomes a banner', () async {

View file

@ -322,6 +322,20 @@ void main() {
expect(limited, isNot(contains(_de.authErrorRateLimited)));
expect(limited, contains(_de.authCheckEmailDelayHint));
});
testWidgets('F13: an SMTP failure (unexpected) shows the same texts', (
tester,
) async {
final success = await textsAfterSubmit(tester, ok());
final smtp = await textsAfterSubmit(
tester,
failed(const AuthFailure.unexpected()),
);
expect(smtp, success);
expect(smtp, isNot(contains(_de.authErrorUnexpected)));
expect(smtp.join(' '), isNot(contains('example.com')));
});
});
group('Zustände Kriterium 2, 3: loading, errors, offline', () {

View file

@ -159,6 +159,7 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken:
- [ ] 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).
- [ ] **Monitoring/Alarm auf SMTP-Fehler** (Release-Blocker, D1, Issue #37/F13): GoTrue-Logs mit `unexpected_failure`/HTTP 500 bei `/recover`, `/resend` und `/signup` alarmieren. Die App zeigt bei einem SMTP-Ausfall bewusst die neutrale „Prüfe dein Postfach“-Seite (Konten-Schutz); Nutzer bekommen keinen Hinweis und keine Mail, nur der Betrieb sieht den Ausfall. Die App loggt zusätzlich Operation, Fehlerart, Status und Code (ohne E-Mail).
- [ ] 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).
@ -169,6 +170,7 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken:
- **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).
- **SMTP-Ausfall:** Auf API-Ebene unterscheiden sich bekannte und unbekannte Adressen (500 vs. 200, Spike #37). Die App zeigt neutral (F13, R16); Restrisiko rohe API akzeptiert.
- **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).

View file

@ -166,6 +166,7 @@ Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue-
| `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; Body `{"code":"P0001","message":"<grund>"}`, Header `X-Sb-Error-Code: unexpected_failure`) | `AuthFailure.registrationRejected` | allgemein: „Registrierung nicht möglich. Bitte prüfe deine Angaben.“ (Im Normalfall verhindert der Client das.) **Mapping-Regel:** 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. |
| HTTP 5xx **mit Status** bei `signUp` (ohne bekannte Trigger-Meldung), `resetPasswordForEmail`, `resend` (z. B. SMTP-Ausfall, `unexpected_failure`; **F13**) | `AuthFailure.unexpected` | **Kein Fehler sichtbar (F13):** Register-, ForgotPassword- und Resend-Controller behandeln ihn wie Erfolg (neutraler CheckEmailScreen bzw. Resend-Bestätigung, auch im LoginScreen). Das Repository loggt Operation, Fehlerart, Status und Code (ohne E-Mail). Sichtbar bleiben nur eine **Allowlist**: `invalidEmail`, `weakPassword`, `registrationRejected` (bekannter Grund), `network` (ohne Status) und `rateLimited`. |
| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `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) |
@ -427,7 +428,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge
|---|---|---|---|
| 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). |
| R3 | `email_sent` ist **instanzweit** (B0 (d) bestätigt, 30 Mails/h) und wirkt nur über das Env, in der lokalen CLI nicht | Ab der 31. Mail pro Stunde gehen keine Mails mehr raus; Angreifer können das billig auslösen. **Seitenkanal:** Während der Sperre antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Signup abgeleitet umgekehrt, siehe 4.3); über die rohe API wird die Existenz eines Kontos erkennbar. Nebenwirkung der Gegenmaßnahme: Nutzer erfahren in der App nicht, dass ihre Mail wegen der Sperre verzögert ist oder ausbleibt. | 30/h reicht laut Chris zum Start (F4). **F12 (Chris, 2026-11-02):** In der App verdeckt: `over_email_send_rate_limit` bei Registrierung und Reset führt zum selben neutralen CheckEmailScreen wie 200, „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt ebenso neutral (4.4); nur nach Login mit richtigem Passwort (Krit. 6) erscheint der Drosselungshinweis. Krit. 4 bleibt in der App erfüllt; der neutrale Zusatz „kann einige Minuten dauern“ steht immer da. Auf API-Ebene akzeptiert. Der Angriff erzeugt vor allem Ausfälle. Monitoring auf 429 `over_email_send_rate_limit`, Wert bei Bedarf erhöhen; Proxy-Lösung erst bei Bedarf. Nicht automatisiert getestet (lokal wirkungslos). **F13 (SMTP-Ausfall):** gleiches Muster, siehe R16. |
| R4 | GoTrue hat keinen Schalter gegen Magic Link/OTP (`/otp`) | Mails außerhalb von Krit. 11, Konten ohne Passwort | Gateway-Sperre (AUTH-CFG-20, D1). B0 (A7): `/otp` legt mit gültigen Metadaten Konten an, der Trigger schützt also nicht. Lokal nicht absicherbar, deshalb nur dokumentiert |
| R5 | Self-hosted GoTrue lädt Mailvorlagen per URL; bei Fehlern still auf die englische Standardvorlage | Krit. 11 verletzt, ohne dass es auffällt | Vorlagen-URL im Health-Check, Release-Checkliste: Testmail de/en auf Staging |
| R6 | Enumeration auf API-Ebene: (a) Signup mit unbestätigter Adresse gibt den echten Nutzer zurück, mit bestätigter Adresse einen erfundenen (B0 bestätigt). (b) **Fehlkonfiguration:** Mit `GOTRUE_SMS_AUTOCONFIRM=true` (CLI-Standard, Self-Hosting-Vorlage `ENABLE_PHONE_AUTOCONFIRM=true`) liefert Signup mit bestätigter Adresse 422 `user_already_exists` (B0, A1). (c) Seitenkanal bei erschöpftem Mail-Limit, siehe R3. | (a) Mit Skripten lässt sich die Existenz unbestätigter Konten erkennen. (b) Existenz bestätigter Konten offen lesbar, Krit. 4 auf API-Ebene gebrochen. | (a) Supabase-Standardverhalten; die App verrät nichts (Krit. 4 bezieht sich auf das Erleben in der App). Bei Bedarf später Captcha oder eigener Proxy. (b) AUTH-CFG-24 in `config.toml` und Env; API-Regressionstest (7.1 Krit. 4); Release-Checkliste. (c) in der App verdeckt (neutraler Screen bei Registrierung, Reset und Resend auf dem CheckEmailScreen, 4.4), auf API-Ebene akzeptiert (F12, R3). |
@ -440,6 +441,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge
| 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-Ausfall verrät Konten auf API-Ebene** (Spike #37/#38, GoTrue v2.197.0): `/recover` bekannt 500 `unexpected_failure` / unbekannt 200; `/resend` unbestätigt 500 / unbekannt 200; `/signup` neu 500, bestätigt 200. Kein Zeit-Seitenkanal (< 60 ms). Offen: SMTP, der die Verbindung annimmt, aber nicht antwortet (Timeout). | Konten-Enumeration über die rohe API; in der App durch F13 verdeckt. Nebenwirkung: Bei echtem SMTP-Ausfall erfahren Nutzer nichts und warten auf eine Mail. | **F13 (Chris, 2026-11-03, Option 1):** Die App behandelt jeden Nicht-Eingabe-, Nicht-Transport-Fehler dieser Aufrufe wie Erfolg (4.4). **Restrisiko rohe API bleibt akzeptiert** (wie R3, R6). **Betrieb:** Alarm/Monitoring auf GoTrue-Logs `unexpected_failure`/500 bei recover/resend/signup (`docs/auth-konfiguration.md` § 5, Release-Blocker). Der neutrale Zusatz „kann einige Minuten dauern“ bleibt. |
| 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)
@ -458,6 +460,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge
| F10 | Fundament als eigenes Issue? | entschieden, umgesetzt | Ja: Issue #31 angelegt, #1 hängt davon ab | E1, 5.1, 8.1, 8.4 |
| F11 | (Spike Q1) Krit. 7: Wo läuft der Drosselungstest, da die CLI keine IP-Drosselung kann? | entschieden (2026-11-02) | Im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem `GOTRUE_RATE_LIMIT_HEADER`; zusätzlich manuelle Prüfung am echten Gateway vor dem Release | 7, 7.1 Krit. 7, D2, T3, R2, R13 |
| F12 | (Spike Q2) Seitenkanal bei erschöpftem Mail-Limit (`/recover`: bestehende Adresse 429, unbekannte 200) | entschieden (2026-11-02) | In der App verdeckt: Bei `over_email_send_rate_limit` zeigen Registrierung und Passwort-Reset denselben neutralen Screen „Prüfe dein Postfach … falls …“ (Krit. 4 bleibt in der App erfüllt), mit neutralem Zusatz „kann einige Minuten dauern“. Ergänzt (Chris, 2026-11-02): Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt neutral; nach Login mit richtigem Passwort (Krit. 6) bleibt der Drosselungshinweis. Über die rohe API bleibt der Kanal sichtbar: akzeptiert und als Risiko dokumentiert (Limit bleibt bei 30/h laut F4). | 4.2, 4.3, 4.4, 5.4, 7.1 Krit. 4, R3, R6 |
| F13 | (Issue #37, Spike #38) SMTP-Ausfall verrät, ob ein Konto existiert (500 vs. 200 bei `/recover`, `/resend`, `/signup`) | entschieden (2026-11-03, Chris: Option 1) | Neutrale Seite im Client: Jeder Fehler dieser Aufrufe, der nicht von der Eingabe oder vom Transport kommt und nicht die IP-Drosselung ist, zählt wie Erfolg (analog F12), auch „E-Mail erneut senden“ im LoginScreen. Das Logging bleibt über das zentrale Logger-Utility. Betriebshinweis: Monitoring auf SMTP-Fehler. | 4.4, R16, `docs/auth-konfiguration.md` § 5 |
Es gibt keine offenen fachlichen Fragen mehr, die die Umsetzung von #1 blockieren. F7 blockiert nur das Release.
@ -520,3 +523,9 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
2. **Neue Route `/forgot-password/check-email` (5.3):** Der Modus (Bestätigung/Reset) steht im Pfad statt im `extra`, damit er einen Web-Reload übersteht. Die Adresse wird im Modus Reset nicht übergeben.
3. **Kein Resend im Modus Reset** (wie 5.4): „Andere E-Mail-Adresse eingeben“ statt „E-Mail erneut senden“. `ResendOrigin.checkEmail` gilt weiter nur für den Modus Bestätigung.
4. **Erfolgstext Krit. 9** unverändert (kein „sofort“, ehrlich zu F1), dazu der Knopf „Weiter“; `AppBanner` um `actionEnabled` erweitert (Resend im LoginScreen bleibt beim Senden sichtbar, aber deaktiviert); `from` wird zwischen Login, Registrierung und „Passwort vergessen“ weitergegeben.
### 2026-11-03 – F13 SMTP-Ausfall neutral behandeln (Issue #37, Branch `fix/37-smtp-neutral`)
1. **Entscheidung Chris (Option 1):** Bei `recover`, `resend` und `signUp` zeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx / `unexpected_failure`), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist in `openEmailInputFailureFor` und `resendFailureFor`; der Mapper liefert 5xx mit Status bei diesen Aufrufen als `unexpected` (statt `network`), ein `signUp`-500 ohne bekannte Trigger-Meldung ebenfalls (statt `registrationRejected`).
2. **Login-Resend (Krit. 6):** Auch dort ist ein SMTP-Fehler neutral (Spike: `/resend` bekannt 500 / unbekannt 200). `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12).
3. **4.4, R16, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.