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:
parent
f8774359ee
commit
7d094a6732
13 changed files with 248 additions and 25 deletions
|
|
@ -30,9 +30,20 @@ AuthFailure mapAuthError(Object error, AuthOperation operation) {
|
||||||
}
|
}
|
||||||
if (error is AuthRetryableFetchException) {
|
if (error is AuthRetryableFetchException) {
|
||||||
if (operation == AuthOperation.signUp && error.statusCode == '500') {
|
if (operation == AuthOperation.signUp && error.statusCode == '500') {
|
||||||
return AuthFailure.registrationRejected(
|
final reason = registrationRejectionReasonFrom(error.message);
|
||||||
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).
|
// Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall).
|
||||||
return const AuthFailure.network();
|
return const AuthFailure.network();
|
||||||
|
|
@ -49,6 +60,11 @@ AuthFailure mapAuthError(Object error, AuthOperation operation) {
|
||||||
return const AuthFailure.unexpected();
|
return const AuthFailure.unexpected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isEnumerationSensitive(AuthOperation operation) =>
|
||||||
|
operation == AuthOperation.signUp ||
|
||||||
|
operation == AuthOperation.resend ||
|
||||||
|
operation == AuthOperation.requestPasswordReset;
|
||||||
|
|
||||||
AuthFailure _mapApiError(AuthException error, AuthOperation operation) {
|
AuthFailure _mapApiError(AuthException error, AuthOperation operation) {
|
||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
case 'invalid_credentials':
|
case 'invalid_credentials':
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,34 @@ enum ResendOrigin {
|
||||||
login,
|
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
|
/// `null` heißt: wie Erfolg behandeln. Sichtbar bleiben nur Eingabe- und
|
||||||
/// `emailSendLimited` zum sichtbaren Drosselungshinweis ([RateLimited]).
|
/// 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) {
|
AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) {
|
||||||
if (failure is EmailSendLimited) {
|
return switch (failure) {
|
||||||
return switch (origin) {
|
EmailSendLimited() =>
|
||||||
ResendOrigin.checkEmail => null,
|
origin == ResendOrigin.login ? const AuthFailure.rateLimited() : null,
|
||||||
ResendOrigin.login => const AuthFailure.rateLimited(),
|
InvalidEmail() || RateLimited() || NetworkFailure() => failure,
|
||||||
};
|
_ => null,
|
||||||
}
|
};
|
||||||
return failure;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registrierung und Passwort-Reset (freie Adresseingabe): `emailSendLimited`
|
/// Registrierung und Passwort-Reset (freie Adresseingabe, F12/F13): Sichtbar
|
||||||
/// zählt wie Erfolg (F12, KTO-01 Kriterium 4). Liefert `null` für „wie
|
/// bleiben nur Eingabefehler (`invalidEmail`, `weakPassword`,
|
||||||
/// Erfolg“, sonst den Fehler.
|
/// `registrationRejected` mit bekanntem Grund), Transportfehler und die
|
||||||
AuthFailure? openEmailInputFailureFor(AuthFailure failure) =>
|
/// kontounabhängige IP-Drosselung (Allowlist). Alles andere, insbesondere
|
||||||
failure is EmailSendLimited ? null : failure;
|
/// `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,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
expect(
|
||||||
mapAuthError(
|
mapAuthError(
|
||||||
AuthRetryableFetchException(message: 'oops', statusCode: '500'),
|
AuthRetryableFetchException(
|
||||||
|
message: '{"code":"unexpected_failure","msg":"Error sending"}',
|
||||||
|
statusCode: '500',
|
||||||
|
),
|
||||||
AuthOperation.signUp,
|
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', () {
|
test('trigger messages map to reasons', () {
|
||||||
expect(
|
expect(
|
||||||
registrationRejectionReasonFrom('age_confirmation_required'),
|
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(
|
expect(
|
||||||
mapAuthError(
|
mapAuthError(
|
||||||
AuthRetryableFetchException(
|
AuthRetryableFetchException(
|
||||||
|
|
@ -212,7 +246,7 @@ void main() {
|
||||||
),
|
),
|
||||||
AuthOperation.signUp,
|
AuthOperation.signUp,
|
||||||
),
|
),
|
||||||
const AuthFailure.network(),
|
const AuthFailure.unexpected(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
test('other failures pass through unchanged', () {
|
||||||
for (final origin in ResendOrigin.values) {
|
for (final origin in ResendOrigin.values) {
|
||||||
expect(
|
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', () {
|
test('rateLimited stays visible', () {
|
||||||
expect(
|
expect(
|
||||||
openEmailInputFailureFor(const AuthFailure.rateLimited()),
|
openEmailInputFailureFor(const AuthFailure.rateLimited()),
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,25 @@ void main() {
|
||||||
expect(limited, contains(_de.authCheckEmailResent));
|
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 {
|
testWidgets('IP rate limit shows the rate-limit hint', (tester) async {
|
||||||
when(() => repository.resendConfirmation(any()))
|
when(() => repository.resendConfirmation(any()))
|
||||||
.thenAnswer((_) async => failed(const AuthFailure.rateLimited()));
|
.thenAnswer((_) async => failed(const AuthFailure.rateLimited()));
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
group('KTO-01 Kriterium 4, 6 (F12): emailSendLimited', () {
|
||||||
test('from the check-email screen counts as sent', () async {
|
test('from the check-email screen counts as sent', () async {
|
||||||
stub(failed(const AuthFailure.emailSendLimited()));
|
stub(failed(const AuthFailure.emailSendLimited()));
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,16 @@ void main() {
|
||||||
expect(state().shownEmailError, isFalse);
|
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 {
|
test('the IP rate limit is a visible failure', () async {
|
||||||
stubReset(failed(const AuthFailure.rateLimited()));
|
stubReset(failed(const AuthFailure.rateLimited()));
|
||||||
notifier().emailChanged('anna@example.com');
|
notifier().emailChanged('anna@example.com');
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,16 @@ void main() {
|
||||||
'bekannt@example.com',
|
'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(unknown, known);
|
||||||
expect(limited, known);
|
expect(limited, known);
|
||||||
|
expect(smtp, known);
|
||||||
|
expect(smtp, isNot(contains(_de.authErrorUnexpected)));
|
||||||
expect(known, contains(_de.authCheckEmailBodyReset));
|
expect(known, contains(_de.authCheckEmailBodyReset));
|
||||||
expect(known, contains(_de.authCheckEmailDelayHint));
|
expect(known, contains(_de.authCheckEmailDelayHint));
|
||||||
expect(known, isNot(contains(_de.authErrorRateLimited)));
|
expect(known, isNot(contains(_de.authErrorRateLimited)));
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,31 @@ void main() {
|
||||||
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
|
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 {
|
testWidgets('the resend action is locked while sending', (tester) async {
|
||||||
final semanticsHandle = tester.ensureSemantics();
|
final semanticsHandle = tester.ensureSemantics();
|
||||||
final completer = Completer<Result<void, AuthFailure>>();
|
final completer = Completer<Result<void, AuthFailure>>();
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
group('Zustände Kriterium 2, 3: failures keep the input', () {
|
||||||
for (final failure in [
|
for (final failure in [
|
||||||
const AuthFailure.network(),
|
const AuthFailure.network(),
|
||||||
const AuthFailure.unexpected(),
|
|
||||||
const AuthFailure.registrationRejected(),
|
const AuthFailure.registrationRejected(),
|
||||||
]) {
|
]) {
|
||||||
test('${failure.runtimeType} becomes a banner', () async {
|
test('${failure.runtimeType} becomes a banner', () async {
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,20 @@ void main() {
|
||||||
expect(limited, isNot(contains(_de.authErrorRateLimited)));
|
expect(limited, isNot(contains(_de.authErrorRateLimited)));
|
||||||
expect(limited, contains(_de.authCheckEmailDelayHint));
|
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', () {
|
group('Zustände Kriterium 2, 3: loading, errors, offline', () {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- [ ] 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).
|
- [ ] `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).
|
- [ ] 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).
|
- [ ] SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7).
|
||||||
- [ ] SPA-Rewrite: `/auth/confirm` wird von der Web-App ausgeliefert.
|
- [ ] SPA-Rewrite: `/auth/confirm` wird von der Web-App ausgeliefert.
|
||||||
- [ ] Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15).
|
- [ ] 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.
|
- **Keine Wartezeit-Angabe:** Die 429-Antwort trägt kein `Retry-After`; die App nennt keine Dauer.
|
||||||
- **Drosselung nur je IP**, nicht je Konto.
|
- **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).
|
- **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.
|
- **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).
|
- **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).
|
- **Abgebrochene Recovery** bleibt nach einem Reload als normale Anmeldung bestehen (R12).
|
||||||
|
|
|
||||||
|
|
@ -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“ |
|
| `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 |
|
| `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 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.“ |
|
| `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) |
|
| 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 |
|
| 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. |
|
| 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 |
|
| 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 |
|
| 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). |
|
| 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. |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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)
|
### 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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.
|
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.
|
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.
|
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`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue