fix(auth): SMTP-Fehler wie Erfolg behandeln (Konten-Seitenkanal, F13) #47
22 changed files with 408 additions and 61 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':
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ enum WeakPasswordReason { length, characters, pwned, unknown }
|
||||||
|
|
||||||
/// Grund einer serverseitig abgelehnten Registrierung (Trigger-Meldung).
|
/// Grund einer serverseitig abgelehnten Registrierung (Trigger-Meldung).
|
||||||
///
|
///
|
||||||
/// Nur zum Loggen und für Zusatzhinweise: Das Format der Meldung ist nicht
|
/// Die Meldung entscheidet über Banner oder neutrale Seite (Spec #1 § 4.4,
|
||||||
/// zugesichert (Spec #1 § 4.4), unbekannte Meldungen ergeben [unknown].
|
/// F13): Nur ein HTTP 500 mit bekannter Trigger-Meldung wird zu
|
||||||
|
/// `registrationRejected` mit Grund, jedes andere 500 (z. B. SMTP-Ausfall)
|
||||||
|
/// zu `unexpected` und ist neutral. Das Format der Meldung ist nicht
|
||||||
|
/// zugesichert, unbekannte Meldungen ergeben [unknown].
|
||||||
enum RegistrationRejectionReason {
|
enum RegistrationRejectionReason {
|
||||||
displayName,
|
displayName,
|
||||||
ageConfirmation,
|
ageConfirmation,
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,42 @@ 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 auf dem
|
||||||
|
/// CheckEmailScreen neutral, weil es zwischen bekannter und unbekannter
|
||||||
|
/// Adresse unterscheiden kann. Im LoginScreen ist das Konto durch das richtige
|
||||||
|
/// Passwort bewiesen: `unexpected` bleibt sichtbar, `emailSendLimited` wird
|
||||||
|
/// zum 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(),
|
// Login: the password was right, the account is proven, so a server
|
||||||
|
// failure (e.g. SMTP outage) is shown instead of hidden (S4).
|
||||||
|
Unexpected() => origin == ResendOrigin.login ? failure : null,
|
||||||
|
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() ||
|
||||||
|
RateLimited() ||
|
||||||
|
NetworkFailure() => failure,
|
||||||
|
// Defense in depth: only with a known trigger reason (the account-dependent
|
||||||
|
// INSERT trigger); an unknown reason could be an SMTP failure.
|
||||||
|
RegistrationRejected(:final reason)
|
||||||
|
when reason != RegistrationRejectionReason.unknown =>
|
||||||
|
failure,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ abstract class ResendState with _$ResendState {
|
||||||
const factory ResendState({
|
const factory ResendState({
|
||||||
@Default(false) bool isSending,
|
@Default(false) bool isSending,
|
||||||
|
|
||||||
/// The last request counts as sent (also for `emailSendLimited` from the
|
/// The last request counts as sent (also for `emailSendLimited` and server
|
||||||
/// check-email screen, F12).
|
/// failures from the check-email screen, F12/F13).
|
||||||
@Default(false) bool sent,
|
@Default(false) bool sent,
|
||||||
AuthFailure? failure,
|
AuthFailure? failure,
|
||||||
|
|
||||||
|
|
@ -26,8 +26,9 @@ abstract class ResendState with _$ResendState {
|
||||||
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
||||||
///
|
///
|
||||||
/// The caller states where the request comes from: only there the controller
|
/// The caller states where the request comes from: only there the controller
|
||||||
/// decides whether `emailSendLimited` is shown (login) or treated as success
|
/// decides (`resendFailureFor`): `emailSendLimited` is shown at login and
|
||||||
/// (check-email screen).
|
/// treated as success on the check-email screen; `unexpected` (e.g. SMTP
|
||||||
|
/// outage) is shown at login (account proven) and neutral on check-email.
|
||||||
@riverpod
|
@riverpod
|
||||||
class ResendController extends _$ResendController {
|
class ResendController extends _$ResendController {
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ T _$identity<T>(T value) => value;
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$ResendState {
|
mixin _$ResendState {
|
||||||
|
|
||||||
bool get isSending;/// The last request counts as sent (also for `emailSendLimited` from the
|
bool get isSending;/// The last request counts as sent (also for `emailSendLimited` and server
|
||||||
/// check-email screen, F12).
|
/// failures from the check-email screen, F12/F13).
|
||||||
bool get sent; AuthFailure? get failure;/// Only for an address typed in by the user.
|
bool get sent; AuthFailure? get failure;/// Only for an address typed in by the user.
|
||||||
EmailError? get emailError;
|
EmailError? get emailError;
|
||||||
/// Create a copy of ResendState
|
/// Create a copy of ResendState
|
||||||
|
|
@ -234,8 +234,8 @@ class _ResendState implements ResendState {
|
||||||
|
|
||||||
|
|
||||||
@override@JsonKey() final bool isSending;
|
@override@JsonKey() final bool isSending;
|
||||||
/// The last request counts as sent (also for `emailSendLimited` from the
|
/// The last request counts as sent (also for `emailSendLimited` and server
|
||||||
/// check-email screen, F12).
|
/// failures from the check-email screen, F12/F13).
|
||||||
@override@JsonKey() final bool sent;
|
@override@JsonKey() final bool sent;
|
||||||
@override final AuthFailure? failure;
|
@override final AuthFailure? failure;
|
||||||
/// Only for an address typed in by the user.
|
/// Only for an address typed in by the user.
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ part of 'resend_controller.dart';
|
||||||
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
||||||
///
|
///
|
||||||
/// The caller states where the request comes from: only there the controller
|
/// The caller states where the request comes from: only there the controller
|
||||||
/// decides whether `emailSendLimited` is shown (login) or treated as success
|
/// decides (`resendFailureFor`): `emailSendLimited` is shown at login and
|
||||||
/// (check-email screen).
|
/// treated as success on the check-email screen; `unexpected` (e.g. SMTP
|
||||||
|
/// outage) is shown at login (account proven) and neutral on check-email.
|
||||||
|
|
||||||
@ProviderFor(ResendController)
|
@ProviderFor(ResendController)
|
||||||
final resendControllerProvider = ResendControllerProvider._();
|
final resendControllerProvider = ResendControllerProvider._();
|
||||||
|
|
@ -20,15 +21,17 @@ final resendControllerProvider = ResendControllerProvider._();
|
||||||
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
||||||
///
|
///
|
||||||
/// The caller states where the request comes from: only there the controller
|
/// The caller states where the request comes from: only there the controller
|
||||||
/// decides whether `emailSendLimited` is shown (login) or treated as success
|
/// decides (`resendFailureFor`): `emailSendLimited` is shown at login and
|
||||||
/// (check-email screen).
|
/// treated as success on the check-email screen; `unexpected` (e.g. SMTP
|
||||||
|
/// outage) is shown at login (account proven) and neutral on check-email.
|
||||||
final class ResendControllerProvider
|
final class ResendControllerProvider
|
||||||
extends $NotifierProvider<ResendController, ResendState> {
|
extends $NotifierProvider<ResendController, ResendState> {
|
||||||
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
||||||
///
|
///
|
||||||
/// The caller states where the request comes from: only there the controller
|
/// The caller states where the request comes from: only there the controller
|
||||||
/// decides whether `emailSendLimited` is shown (login) or treated as success
|
/// decides (`resendFailureFor`): `emailSendLimited` is shown at login and
|
||||||
/// (check-email screen).
|
/// treated as success on the check-email screen; `unexpected` (e.g. SMTP
|
||||||
|
/// outage) is shown at login (account proven) and neutral on check-email.
|
||||||
ResendControllerProvider._()
|
ResendControllerProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
|
|
@ -61,8 +64,9 @@ String _$resendControllerHash() => r'cab54661555bb641d3dc0fb1b06486b10f2e59e8';
|
||||||
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
/// "Resend email" for the confirmation (KTO-01 Kriterium 6, F12).
|
||||||
///
|
///
|
||||||
/// The caller states where the request comes from: only there the controller
|
/// The caller states where the request comes from: only there the controller
|
||||||
/// decides whether `emailSendLimited` is shown (login) or treated as success
|
/// decides (`resendFailureFor`): `emailSendLimited` is shown at login and
|
||||||
/// (check-email screen).
|
/// treated as success on the check-email screen; `unexpected` (e.g. SMTP
|
||||||
|
/// outage) is shown at login (account proven) and neutral on check-email.
|
||||||
|
|
||||||
abstract class _$ResendController extends $Notifier<ResendState> {
|
abstract class _$ResendController extends $Notifier<ResendState> {
|
||||||
ResendState build();
|
ResendState build();
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ part 'forgot_password_controller.g.dart';
|
||||||
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
||||||
///
|
///
|
||||||
/// The reaction is the same for known and unknown addresses: every 200
|
/// The reaction is the same for known and unknown addresses: every 200
|
||||||
/// response, and `emailSendLimited`, count as success.
|
/// response, `emailSendLimited` and server failures (`unexpected`, e.g. SMTP
|
||||||
|
/// outage, F13) count as success. Only input, transport and IP-throttling
|
||||||
|
/// failures are shown (`openEmailInputFailureFor`).
|
||||||
@riverpod
|
@riverpod
|
||||||
class ForgotPasswordController extends _$ForgotPasswordController {
|
class ForgotPasswordController extends _$ForgotPasswordController {
|
||||||
@override
|
@override
|
||||||
|
|
@ -27,7 +29,7 @@ class ForgotPasswordController extends _$ForgotPasswordController {
|
||||||
|
|
||||||
/// Validates and requests the reset mail. Returns `true` if the screen
|
/// Validates and requests the reset mail. Returns `true` if the screen
|
||||||
/// should continue to "check your inbox" (every 200 response, and
|
/// should continue to "check your inbox" (every 200 response, and
|
||||||
/// `emailSendLimited`, F12).
|
/// `emailSendLimited` or a server failure, F12/F13).
|
||||||
Future<bool> submit() async {
|
Future<bool> submit() async {
|
||||||
if (state.isSubmitting) {
|
if (state.isSubmitting) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ part of 'forgot_password_controller.dart';
|
||||||
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
||||||
///
|
///
|
||||||
/// The reaction is the same for known and unknown addresses: every 200
|
/// The reaction is the same for known and unknown addresses: every 200
|
||||||
/// response, and `emailSendLimited`, count as success.
|
/// response, `emailSendLimited` and server failures (`unexpected`, e.g. SMTP
|
||||||
|
/// outage, F13) count as success. Only input, transport and IP-throttling
|
||||||
|
/// failures are shown (`openEmailInputFailureFor`).
|
||||||
|
|
||||||
@ProviderFor(ForgotPasswordController)
|
@ProviderFor(ForgotPasswordController)
|
||||||
final forgotPasswordControllerProvider = ForgotPasswordControllerProvider._();
|
final forgotPasswordControllerProvider = ForgotPasswordControllerProvider._();
|
||||||
|
|
@ -19,13 +21,17 @@ final forgotPasswordControllerProvider = ForgotPasswordControllerProvider._();
|
||||||
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
||||||
///
|
///
|
||||||
/// The reaction is the same for known and unknown addresses: every 200
|
/// The reaction is the same for known and unknown addresses: every 200
|
||||||
/// response, and `emailSendLimited`, count as success.
|
/// response, `emailSendLimited` and server failures (`unexpected`, e.g. SMTP
|
||||||
|
/// outage, F13) count as success. Only input, transport and IP-throttling
|
||||||
|
/// failures are shown (`openEmailInputFailureFor`).
|
||||||
final class ForgotPasswordControllerProvider
|
final class ForgotPasswordControllerProvider
|
||||||
extends $NotifierProvider<ForgotPasswordController, ForgotPasswordState> {
|
extends $NotifierProvider<ForgotPasswordController, ForgotPasswordState> {
|
||||||
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
||||||
///
|
///
|
||||||
/// The reaction is the same for known and unknown addresses: every 200
|
/// The reaction is the same for known and unknown addresses: every 200
|
||||||
/// response, and `emailSendLimited`, count as success.
|
/// response, `emailSendLimited` and server failures (`unexpected`, e.g. SMTP
|
||||||
|
/// outage, F13) count as success. Only input, transport and IP-throttling
|
||||||
|
/// failures are shown (`openEmailInputFailureFor`).
|
||||||
ForgotPasswordControllerProvider._()
|
ForgotPasswordControllerProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
|
|
@ -59,7 +65,9 @@ String _$forgotPasswordControllerHash() =>
|
||||||
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12).
|
||||||
///
|
///
|
||||||
/// The reaction is the same for known and unknown addresses: every 200
|
/// The reaction is the same for known and unknown addresses: every 200
|
||||||
/// response, and `emailSendLimited`, count as success.
|
/// response, `emailSendLimited` and server failures (`unexpected`, e.g. SMTP
|
||||||
|
/// outage, F13) count as success. Only input, transport and IP-throttling
|
||||||
|
/// failures are shown (`openEmailInputFailureFor`).
|
||||||
|
|
||||||
abstract class _$ForgotPasswordController
|
abstract class _$ForgotPasswordController
|
||||||
extends $Notifier<ForgotPasswordState> {
|
extends $Notifier<ForgotPasswordState> {
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,8 @@ class RegisterController extends _$RegisterController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates and submits. Returns `true` if the screen should continue to
|
/// Validates and submits. Returns `true` if the screen should continue to
|
||||||
/// "check your inbox" (every 200 response, and `emailSendLimited`, F12).
|
/// "check your inbox" (every 200 response, and `emailSendLimited` or a
|
||||||
|
/// server failure such as an SMTP outage, F12/F13).
|
||||||
Future<bool> submit() async {
|
Future<bool> submit() async {
|
||||||
if (state.isSubmitting) {
|
if (state.isSubmitting) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,73 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('F13: SMTP outage (HTTP 500) through the repository', () {
|
||||||
|
const smtpBody =
|
||||||
|
'{"code":500,"error_code":"unexpected_failure",'
|
||||||
|
'"msg":"Error sending confirmation email"}';
|
||||||
|
final smtp = AuthRetryableFetchException(
|
||||||
|
message: smtpBody,
|
||||||
|
statusCode: '500',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('signUp -> unexpected', () async {
|
||||||
|
when(
|
||||||
|
() => auth.signUp(
|
||||||
|
email: any(named: 'email'),
|
||||||
|
password: any(named: 'password'),
|
||||||
|
data: any(named: 'data'),
|
||||||
|
),
|
||||||
|
).thenThrow(smtp);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await repository.signUp(_input)).errorOrNull,
|
||||||
|
const AuthFailure.unexpected(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resend -> unexpected', () async {
|
||||||
|
when(
|
||||||
|
() => auth.resend(
|
||||||
|
type: any(named: 'type'),
|
||||||
|
email: any(named: 'email'),
|
||||||
|
),
|
||||||
|
).thenThrow(smtp);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await repository.resendConfirmation('anna@example.com')).errorOrNull,
|
||||||
|
const AuthFailure.unexpected(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetPasswordForEmail -> unexpected', () async {
|
||||||
|
when(() => auth.resetPasswordForEmail(any())).thenThrow(smtp);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await repository.requestPasswordReset('anna@example.com')).errorOrNull,
|
||||||
|
const AuthFailure.unexpected(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signIn with 500/502/504 stays network', () async {
|
||||||
|
for (final status in ['500', '502', '504']) {
|
||||||
|
when(
|
||||||
|
() => auth.signInWithPassword(
|
||||||
|
email: any(named: 'email'),
|
||||||
|
password: any(named: 'password'),
|
||||||
|
),
|
||||||
|
).thenThrow(
|
||||||
|
AuthRetryableFetchException(message: 'x', statusCode: status),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await repository.signIn('anna@example.com', 'x')).errorOrNull,
|
||||||
|
const AuthFailure.network(),
|
||||||
|
reason: status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('other calls', () {
|
group('other calls', () {
|
||||||
test(
|
test(
|
||||||
'KTO-01 Kriterium 6: resend uses OtpType.signup and a trimmed email',
|
'KTO-01 Kriterium 6: resend uses OtpType.signup and a trimmed email',
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,39 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('other failures pass through unchanged', () {
|
test('F13: on the check-email screen unexpected (SMTP) is neutral', () {
|
||||||
|
expect(
|
||||||
|
resendFailureFor(
|
||||||
|
const AuthFailure.unexpected(),
|
||||||
|
ResendOrigin.checkEmail,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
for (final origin in ResendOrigin.values) {
|
||||||
|
expect(
|
||||||
|
resendFailureFor(const AuthFailure.emailNotConfirmed(), origin),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('S4: at login (account proven) unexpected stays visible', () {
|
||||||
|
expect(
|
||||||
|
resendFailureFor(const AuthFailure.unexpected(), ResendOrigin.login),
|
||||||
|
const AuthFailure.unexpected(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('input errors stay visible', () {
|
||||||
|
for (final origin in ResendOrigin.values) {
|
||||||
|
expect(
|
||||||
|
resendFailureFor(const AuthFailure.invalidEmail(), origin),
|
||||||
|
const AuthFailure.invalidEmail(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowlisted failures stay visible', () {
|
||||||
for (final origin in ResendOrigin.values) {
|
for (final origin in ResendOrigin.values) {
|
||||||
expect(
|
expect(
|
||||||
resendFailureFor(const AuthFailure.rateLimited(), origin),
|
resendFailureFor(const AuthFailure.rateLimited(), origin),
|
||||||
|
|
@ -40,6 +72,41 @@ 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('registrationRejected without a known reason is neutral', () {
|
||||||
|
expect(
|
||||||
|
openEmailInputFailureFor(const AuthFailure.registrationRejected()),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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,29 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('F13: SMTP outage (unexpected)', () {
|
||||||
|
test('from the check-email screen counts as sent', () async {
|
||||||
|
stub(failed(const AuthFailure.unexpected()));
|
||||||
|
|
||||||
|
await notifier().resend(
|
||||||
|
'anna@example.com',
|
||||||
|
origin: ResendOrigin.checkEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state().sent, isTrue);
|
||||||
|
expect(state().failure, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('from the login screen stays visible (account proven)', () async {
|
||||||
|
stub(failed(const AuthFailure.unexpected()));
|
||||||
|
|
||||||
|
await notifier().resend('anna@example.com', origin: ResendOrigin.login);
|
||||||
|
|
||||||
|
expect(state().sent, isFalse);
|
||||||
|
expect(state().failure, const AuthFailure.unexpected());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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,22 @@ void main() {
|
||||||
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
|
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('S4: a server failure on resend stays visible at login', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
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(find.text(_de.authErrorUnexpected), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('login_resent_banner')), 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,11 +374,24 @@ 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(),
|
reason: RegistrationRejectionReason.displayName,
|
||||||
|
),
|
||||||
]) {
|
]) {
|
||||||
test('${failure.runtimeType} becomes a banner', () async {
|
test('${failure.runtimeType} becomes a banner', () async {
|
||||||
when(() => repository.signUp(any()))
|
when(() => repository.signUp(any()))
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
@ -384,7 +398,11 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
when(() => repository.signUp(any())).thenAnswer(
|
when(() => repository.signUp(any())).thenAnswer(
|
||||||
(_) async => failed(const AuthFailure.registrationRejected()),
|
(_) async => failed(
|
||||||
|
const AuthFailure.registrationRejected(
|
||||||
|
reason: RegistrationRejectionReason.displayName,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await open(tester);
|
await open(tester);
|
||||||
await fillValid(tester);
|
await fillValid(tester);
|
||||||
|
|
|
||||||
|
|
@ -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, R17); 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).
|
||||||
|
|
@ -214,4 +216,4 @@ Messung gegen einen zusätzlichen GoTrue-Container, dessen SMTP-Port 1 die Verbi
|
||||||
| `POST /resend` (signup) | unbestätigt: **500** „Error sending confirmation email“ | **200** |
|
| `POST /resend` (signup) | unbestätigt: **500** „Error sending confirmation email“ | **200** |
|
||||||
| `POST /signup` | unbestätigt: **500**; bestätigt: **200** (erfundener Nutzer, kein Versand) | neu: **500** „Error sending confirmation email“, Nutzer wird **nicht** angelegt (Rollback) |
|
| `POST /signup` | unbestätigt: **500**; bestätigt: **200** (erfundener Nutzer, kein Versand) | neu: **500** „Error sending confirmation email“, Nutzer wird **nicht** angelegt (Rollback) |
|
||||||
|
|
||||||
Antwortzeiten liegen alle im einstelligen bis niedrigen zweistelligen Millisekundenbereich (Verbindung wird sofort abgelehnt), also kein Zeit-Seitenkanal. **Befund:** Bei nicht erreichbarem SMTP unterscheiden sich die Antworten von `/recover`, `/resend` und `/signup` je nach Existenz des Kontos (500 vs. 200), und zwar nicht nur bei erschöpftem Mail-Limit (R3, F12). Die rohe API verrät dann die Existenz; laut Mapping 4.4 (nicht getestet) würde die App bei Reset 500 als `unexpected`, bei Registrierung als `registrationRejected` anzeigen, also einen Fehler statt des neutralen Bildschirms. Ob die App bei HTTP 500 aus Sendefehlern neutral bleiben soll (wie bei `over_email_send_rate_limit`), ist offen (Issue #37). Nicht untersucht: ein SMTP-Server, der die Verbindung annimmt, aber nicht antwortet (Timeout), und GoTrue hinter dem Produktions-Gateway. Dafür den Container mit einer nicht routbaren SMTP-Adresse (z. B. `GOTRUE_SMTP_HOST=10.255.255.1`) starten und `GOTRUE_SMTP_PORT` bis zum Timeout beobachten.
|
Antwortzeiten liegen alle im einstelligen bis niedrigen zweistelligen Millisekundenbereich (Verbindung wird sofort abgelehnt), also kein Zeit-Seitenkanal. **Befund:** Bei nicht erreichbarem SMTP unterscheiden sich die Antworten von `/recover`, `/resend` und `/signup` je nach Existenz des Kontos (500 vs. 200), und zwar nicht nur bei erschöpftem Mail-Limit (R3, F12). Die rohe API verrät dann die Existenz; die App zeigt seit F13 (Issue #37, Spec R17) bei `signUp`, `recover` und `resend` auf CheckEmail dafür den neutralen Bildschirm (im LoginScreen bleibt der Resend-Fehler sichtbar, das Konto ist dort bewiesen). Die ursprüngliche Befürchtung, die App zeige einen Fehler statt des neutralen Bildschirms, ist damit behoben. Ob die App bei HTTP 500 aus Sendefehlern neutral bleiben soll (wie bei `over_email_send_rate_limit`), ist offen (Issue #37). Nicht untersucht: ein SMTP-Server, der die Verbindung annimmt, aber nicht antwortet (Timeout), und GoTrue hinter dem Produktions-Gateway. Dafür den Container mit einer nicht routbaren SMTP-Adresse (z. B. `GOTRUE_SMTP_HOST=10.255.255.1`) starten und `GOTRUE_SMTP_PORT` bis zum Timeout beobachten.
|
||||||
|
|
|
||||||
|
|
@ -165,9 +165,10 @@ Alle Werte stehen identisch in `supabase/config.toml` (lokal/CI) und als GoTrue-
|
||||||
| `same_password` | `AuthFailure.samePassword` | am Passwortfeld: „Wähle ein anderes als dein bisheriges Passwort.“ |
|
| `same_password` | `AuthFailure.samePassword` | am Passwortfeld: „Wähle ein anderes als dein bisheriges Passwort.“ |
|
||||||
| `otp_expired`, `invalid` / 403 bei `verifyOTP` | `AuthFailure.linkInvalidOrExpired` | „Der Link ist abgelaufen oder wurde schon benutzt.“ + Aktion „Neuen Link anfordern“ |
|
| `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 **mit bekannter Trigger-Meldung** 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 (F13):** maßgeblich sind der HTTP-Status 500 beim `signUp` **und eine bekannte Trigger-Meldung** (sie entscheidet über Banner oder neutral; ein 500 ohne bekannte Meldung, z. B. SMTP-Ausfall, ist `unexpected` und neutral), **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 wird (nur) zur Unterscheidung Eingabefehler/SMTP-Ausfall und fürs Log genutzt; das Format ist nicht zugesichert (T3 sichert es ab). A1 prüft, welche Felder der Client tatsächlich befüllt; T3 sichert das Body-Format mit einem API-Test ab. |
|
||||||
| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ |
|
| 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`. |
|
||||||
| sonst | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) |
|
| `AuthRetryableFetchException`, `SocketException`, `ClientException`, Timeout (**nicht** HTTP 5xx mit Status bei `signUp`/`resetPasswordForEmail`/`resend`, siehe F13-Zeile oben) | `AuthFailure.network` | Offline-Hinweis „Keine Verbindung. Für diesen Schritt brauchst du Internet.“ |
|
||||||
|
| sonst (**außer** `signUp`/`recover`/`resend` auf CheckEmail, Register und Forgot: dort neutral, F13) | `AuthFailure.unexpected` | allgemein + „Erneut versuchen“; Details nur ins Log (ohne E-Mail und Passwort) |
|
||||||
|
|
||||||
## 5. Frontend
|
## 5. Frontend
|
||||||
|
|
||||||
|
|
@ -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 R17. |
|
||||||
| 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). |
|
||||||
|
|
@ -441,6 +442,7 @@ Der Backend-PR (B1–B3) wird als erster PR zu #1 gemergt. Die Merge-Reihenfolge
|
||||||
| 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-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 |
|
||||||
|
| R17 | **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. Nebenwirkungen: Bei echtem SMTP-Ausfall erfahren Nutzer nichts und warten auf eine Mail. **Bewusst:** Auch Gateway-502/503/504 (Antwort mit Status) wirken bei `signUp`/`recover`/`resend` wie Erfolg, es gibt dort keinen Offline-Hinweis; nur Fehler ohne Status bleiben `network`. | **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**; zusätzlich ist `registrationRejected` kontoabhängig (der Trigger läuft nur beim INSERT), relevant nur, wenn Client- und Server-Regeln auseinanderlaufen (der Client verhindert die Fälle sonst vorab) (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. |
|
||||||
|
|
||||||
### 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“ auf dem CheckEmailScreen (im LoginScreen bleibt `unexpected` sichtbar, das Konto ist dort bewiesen). Das Logging bleibt über das zentrale Logger-Utility. Betriebshinweis: Monitoring auf SMTP-Fehler. | 4.4, R17, `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.
|
||||||
|
|
||||||
|
|
@ -485,7 +488,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
|
||||||
1. **Statuskopf und Quellen:** „freigegeben 2026-11-02, angepasst an Spike-Ergebnisse“; Spike-Dokument und Migration `20261102212208_auth_registration_rules.sql` als Quellen.
|
1. **Statuskopf und Quellen:** „freigegeben 2026-11-02, angepasst an Spike-Ergebnisse“; Spike-Dokument und Migration `20261102212208_auth_registration_rules.sql` als Quellen.
|
||||||
2. **Neue Konfiguration AUTH-CFG-24** (A1): `[auth.sms] enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`, im Self-Hosting `ENABLE_PHONE_AUTOCONFIRM=false` (Vorlage `docker/.env.example` setzt `true`). Sonst 422 `user_already_exists` statt Enumerationsschutz (Krit. 4). AUTH-CFG-04 verweist darauf.
|
2. **Neue Konfiguration AUTH-CFG-24** (A1): `[auth.sms] enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`, im Self-Hosting `ENABLE_PHONE_AUTOCONFIRM=false` (Vorlage `docker/.env.example` setzt `true`). Sonst 422 `user_already_exists` statt Enumerationsschutz (Krit. 4). AUTH-CFG-04 verweist darauf.
|
||||||
3. **4.1:** Einleitung nicht mehr „Entwurf“; AUTH-CFG-09 instanzweit und lokal wirkungslos; AUTH-CFG-10 Schlüssel `token_refresh` bestätigt, kein `Retry-After`; AUTH-CFG-11 nur für Signup bestätigt; AUTH-CFG-10 bis -12 nur mit AUTH-CFG-13 wirksam; AUTH-CFG-13 **Pflicht**; AUTH-CFG-20 um A7 ergänzt; AUTH-CFG-22 Mailpit; Mailvorlagen: kein zweisprachiger Rückfall-Betreff, `type`-Werte bestätigt. E5 entsprechend angepasst.
|
3. **4.1:** Einleitung nicht mehr „Entwurf“; AUTH-CFG-09 instanzweit und lokal wirkungslos; AUTH-CFG-10 Schlüssel `token_refresh` bestätigt, kein `Retry-After`; AUTH-CFG-11 nur für Signup bestätigt; AUTH-CFG-10 bis -12 nur mit AUTH-CFG-13 wirksam; AUTH-CFG-13 **Pflicht**; AUTH-CFG-20 um A7 ergänzt; AUTH-CFG-22 Mailpit; Mailvorlagen: kein zweisprachiger Rückfall-Betreff, `type`-Werte bestätigt. E5 entsprechend angepasst.
|
||||||
4. **Fehlerbehandlung (E6, 4.2, 4.4)** (A4): Trigger-Fehler kommen als HTTP 500 mit `{"code":"P0001","message":"<grund>"}` im Body (Header `X-Sb-Error-Code: unexpected_failure`). Mapping auf `registrationRejected` über HTTP 500 bei `signUp`, nicht über `P0001`; die Meldung nur für Log/Zusatzhinweise. 429 ohne Dauerangabe.
|
4. **Fehlerbehandlung (E6, 4.2, 4.4)** (A4): Trigger-Fehler kommen als HTTP 500 mit `{"code":"P0001","message":"<grund>"}` im Body (Header `X-Sb-Error-Code: unexpected_failure`). Mapping auf `registrationRejected` über HTTP 500 bei `signUp` **mit bekannter Meldung** (F13), nicht über `P0001`. 429 ohne Dauerangabe.
|
||||||
5. **4.3:** als verifiziert markiert; Krit. 4 mit beiden Autoconfirm-Voraussetzungen und dem Seitenkanal bei erschöpftem Mail-Limit; Krit. 7 Drosselung nur mit Header; Krit. 8 maßgebliche Spalte `auth.users.*_sent_at`.
|
5. **4.3:** als verifiziert markiert; Krit. 4 mit beiden Autoconfirm-Voraussetzungen und dem Seitenkanal bei erschöpftem Mail-Limit; Krit. 7 Drosselung nur mit Header; Krit. 8 maßgebliche Spalte `auth.users.*_sent_at`.
|
||||||
6. **5.4** (A5): Der `amr`-Weg entfällt (Recovery-JWT `amr = otp`, nicht unterscheidbar); Guard nur im laufenden Prozess, Ableitung aus `amr` unzulässig.
|
6. **5.4** (A5): Der `amr`-Weg entfällt (Recovery-JWT `amr = otp`, nicht unterscheidbar); Guard nur im laufenden Prozess, Ableitung aus `amr` unzulässig.
|
||||||
7. **7 / 7.1:** neues Playwright-Projekt `throttle` gegen Extra-Container; Krit. 2 und 10 erwarten 500 mit Body-Meldung; Krit. 4 prüft `identities` leer (Regressionstest AUTH-CFG-24) und nennt den akzeptierten Seitenkanal; Krit. 7 echter Test im Projekt `throttle` (Login #31 → 429, zweite IP frei), Web-Hinweis per gemocktem 429; Krit. 8 Spalten `recovery_sent_at` / `confirmation_sent_at`, zweites Einlösen 403 `otp_expired`; manuelle Checkliste erweitert (AUTH-CFG-09, -13, -24).
|
7. **7 / 7.1:** neues Playwright-Projekt `throttle` gegen Extra-Container; Krit. 2 und 10 erwarten 500 mit Body-Meldung; Krit. 4 prüft `identities` leer (Regressionstest AUTH-CFG-24) und nennt den akzeptierten Seitenkanal; Krit. 7 echter Test im Projekt `throttle` (Login #31 → 429, zweite IP frei), Web-Hinweis per gemocktem 429; Krit. 8 Spalten `recovery_sent_at` / `confirmation_sent_at`, zweites Einlösen 403 `otp_expired`; manuelle Checkliste erweitert (AUTH-CFG-09, -13, -24).
|
||||||
|
|
@ -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), Entscheidung Orchestrator (S4, Chris wird informiert):** Dort bleibt `unexpected` **sichtbar** (allgemeiner Fehler mit „Erneut versuchen“), weil das Konto durch das richtige Passwort bereits bewiesen ist und Neutralisieren nur einen echten SMTP-Ausfall verdecken würde. `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12). Neutral bleibt es auf CheckEmail-Resend, Forgot und Register. `registrationRejected` ist nur mit bekanntem Grund sichtbar.
|
||||||
|
3. **4.4, R17, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ test.describe('KTO-01 Kriterium 2: Altersbestaetigung (api)', () => {
|
||||||
const email = randomEmail();
|
const email = randomEmail();
|
||||||
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined }));
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined }));
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
|
// Body-Format (Spike B0 (f), Spec 4.4/F13): die App unterscheidet daran Eingabefehler von SMTP-Ausfall.
|
||||||
|
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.ageConfirmationRequired });
|
||||||
|
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
|
||||||
expect(await userByEmail(email)).toBeUndefined();
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -28,7 +30,9 @@ test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (
|
||||||
const email = randomEmail();
|
const email = randomEmail();
|
||||||
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined }));
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined }));
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.message).toBe(triggerErrors.termsAcceptanceRequired);
|
// Body-Format (Spike B0 (f), Spec 4.4/F13): die App unterscheidet daran Eingabefehler von SMTP-Ausfall.
|
||||||
|
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.termsAcceptanceRequired });
|
||||||
|
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
|
||||||
expect(await userByEmail(email)).toBeUndefined();
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,7 +40,9 @@ test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (
|
||||||
const email = randomEmail();
|
const email = randomEmail();
|
||||||
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined }));
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined }));
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.message).toBe(triggerErrors.termsVersionRequired);
|
// Body-Format (Spike B0 (f), Spec 4.4/F13): die App unterscheidet daran Eingabefehler von SMTP-Ausfall.
|
||||||
|
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.termsVersionRequired });
|
||||||
|
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
|
||||||
expect(await userByEmail(email)).toBeUndefined();
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue