fix(auth): Session ueberlebt Netz-Aussetzer; Zahlungsmethoden laden nach Re-Login neu
Kunde meldete haeufig 'Sitzung abgelaufen' und danach dauerhaft rotes 'Sitzung abgelaufen' bei den Zahlungsmethoden trotz erfolgreichem Login. Token-Refresh: - _performRefresh wertete JEDEN Fehler als tote Session (Logout + Refresh- Token geloescht) - auch Mobilfunk-/VPN-Aussetzer. Jetzt gilt nur noch eine echte Ablehnung durch Keycloak (OAuth invalid_grant o. ae.) als abgelaufen; voruebergehende Fehler behalten die Session, nutzen den noch gueltigen Access-Token weiter oder werfen AuthTemporarilyUnavailableException - Interceptor schickt Requests nicht mehr tokenlos weiter (ergab 401 -> irrefuehrendes 'Sitzung abgelaufen'), sondern bricht als Verbindungsfehler ab - restoreSession verwirft den Refresh-Token bei fehlendem Netz nicht mehr Zahlungsmethoden: - Cubit lud nur einmal beim App-Start (vor dem Login -> 401) und blieb danach fuer immer im Fehlerzustand. Jetzt Reload bei jedem Wechsel auf Authenticated - Fehlerkarte in der Uebersicht hat einen 'Erneut laden'-Button Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -5,5 +5,25 @@
|
|||||||
abstract interface class AuthTokenProvider {
|
abstract interface class AuthTokenProvider {
|
||||||
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
|
/// Liefert einen aktuell gültigen Access-Token oder `null`, wenn
|
||||||
/// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen.
|
/// keine Session aktiv ist. Darf bei Bedarf einen Refresh anstoßen.
|
||||||
|
///
|
||||||
|
/// Wirft [AuthTemporarilyUnavailableException], wenn zwar eine Session
|
||||||
|
/// existiert, der Token aber wegen eines **vorübergehenden** Problems
|
||||||
|
/// (Netz/VPN/Timeout) gerade nicht erneuert werden konnte.
|
||||||
Future<String?> currentAccessToken();
|
Future<String?> currentAccessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Access-Token konnte gerade nicht erneuert werden, obwohl die Session
|
||||||
|
/// (Refresh-Token) weiterhin gültig sein dürfte — typisch: Mobilfunk-/VPN-
|
||||||
|
/// Aussetzer, Timeout, Login-Server kurz nicht erreichbar.
|
||||||
|
///
|
||||||
|
/// Bewusst **kein** „Session abgelaufen": der Aufrufer soll den Request als
|
||||||
|
/// Verbindungsfehler behandeln und später erneut versuchen, statt den Fahrer
|
||||||
|
/// auszuloggen.
|
||||||
|
class AuthTemporarilyUnavailableException implements Exception {
|
||||||
|
const AuthTemporarilyUnavailableException(this.cause);
|
||||||
|
|
||||||
|
final Object cause;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthTemporarilyUnavailableException($cause)';
|
||||||
|
}
|
||||||
|
|||||||
@ -22,6 +22,20 @@ class HolzleitnerAuthInterceptor extends Interceptor {
|
|||||||
if (token != null) {
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
} on AuthTemporarilyUnavailableException catch (e) {
|
||||||
|
// Token ließ sich wegen eines Netz-/VPN-Problems nicht erneuern. NICHT
|
||||||
|
// tokenlos weiterschicken — das Backend würde mit 401 antworten und die
|
||||||
|
// UI fälschlich „Sitzung abgelaufen" zeigen. Stattdessen sauber als
|
||||||
|
// Verbindungsfehler abbrechen; der nächste Request versucht es erneut.
|
||||||
|
return handler.reject(
|
||||||
|
DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.connectionError,
|
||||||
|
error: e,
|
||||||
|
message:
|
||||||
|
'Anmeldung konnte nicht erneuert werden (keine Verbindung zum Login-Server)',
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
// TODO Phase B: hier ein strukturiertes Logging-Framework
|
// TODO Phase B: hier ein strukturiertes Logging-Framework
|
||||||
// einhängen statt print.
|
// einhängen statt print.
|
||||||
|
|||||||
@ -194,7 +194,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
if (stored == null || stored.isEmpty) return false;
|
if (stored == null || stored.isEmpty) return false;
|
||||||
_refreshToken = stored;
|
_refreshToken = stored;
|
||||||
|
|
||||||
final token = await currentAccessToken();
|
final String? token;
|
||||||
|
try {
|
||||||
|
token = await currentAccessToken();
|
||||||
|
} on AuthTemporarilyUnavailableException catch (e) {
|
||||||
|
// Beim App-Start gerade kein Netz/VPN: Refresh-Token NICHT verwerfen —
|
||||||
|
// der nächste Start (oder Login) kann die Session wieder aufnehmen.
|
||||||
|
debugPrint('restoreSession: Refresh vorübergehend nicht möglich: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (token == null) return false;
|
if (token == null) return false;
|
||||||
|
|
||||||
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
_events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
|
||||||
@ -243,8 +251,15 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
|
|
||||||
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
|
/// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
|
||||||
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
|
/// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
|
||||||
/// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired`
|
///
|
||||||
/// emittieren, `null` zurück.
|
/// Fehler werden unterschieden:
|
||||||
|
/// * **Abgelehnt** (OAuth `invalid_grant` o. ä.: Refresh-Token abgelaufen/
|
||||||
|
/// widerrufen, SSO-Session beendet) → Session ist tot: lokal aufräumen,
|
||||||
|
/// `AuthSessionExpired` emittieren, `null` zurück.
|
||||||
|
/// * **Vorübergehend** (Netz/VPN/Timeout/Server kurz weg) → Session
|
||||||
|
/// BEHALTEN; noch gültigen Access-Token weiterverwenden, sonst
|
||||||
|
/// [AuthTemporarilyUnavailableException] werfen. Früher beendete jeder
|
||||||
|
/// Netz-Hänger die Session und löschte den Refresh-Token.
|
||||||
Future<String?> _performRefresh(String rt) async {
|
Future<String?> _performRefresh(String rt) async {
|
||||||
try {
|
try {
|
||||||
final result = await _appAuth.token(
|
final result = await _appAuth.token(
|
||||||
@ -265,8 +280,24 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
);
|
);
|
||||||
await _persistRefreshToken();
|
await _persistRefreshToken();
|
||||||
return _accessToken;
|
return _accessToken;
|
||||||
} on Exception {
|
} on Exception catch (e) {
|
||||||
// Refresh hat nicht funktioniert — Session ist tot, nicht
|
if (!_isSessionRejected(e)) {
|
||||||
|
// Vorübergehender Fehler (Mobilfunk-/VPN-Aussetzer, Timeout, Keycloak
|
||||||
|
// kurz nicht erreichbar): die Session ist NICHT tot. Refresh-Token
|
||||||
|
// behalten und beim nächsten Request erneut versuchen — sonst zwingt
|
||||||
|
// jeder Netz-Hänger den Fahrer zum Neu-Login.
|
||||||
|
debugPrint('Token-Refresh vorübergehend fehlgeschlagen: $e');
|
||||||
|
final cached = _accessToken;
|
||||||
|
final expiresAt = _expiresAt;
|
||||||
|
if (cached != null &&
|
||||||
|
expiresAt != null &&
|
||||||
|
expiresAt.isAfter(DateTime.now().toUtc())) {
|
||||||
|
// Bisheriger Access-Token ist noch (kurz) gültig → weiterverwenden.
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
throw AuthTemporarilyUnavailableException(e);
|
||||||
|
}
|
||||||
|
// Keycloak hat den Refresh-Token ABGELEHNT — Session ist tot, nicht
|
||||||
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
|
// wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
|
||||||
// feuern, DANN best-effort den Storage löschen — so kann ein
|
// feuern, DANN best-effort den Storage löschen — so kann ein
|
||||||
// werfendes `delete` weder das Event verschlucken noch eine Exception
|
// werfendes `delete` weder das Event verschlucken noch eine Exception
|
||||||
@ -282,6 +313,30 @@ class KeycloakOidcTokenProvider implements AuthTokenProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OAuth-Fehler, bei denen ein erneuter Versuch sinnlos ist: der
|
||||||
|
/// Authorization-Server hat den Refresh endgültig abgelehnt.
|
||||||
|
static const Set<String> _fatalOAuthErrors = {
|
||||||
|
FlutterAppAuthOAuthError.invalidGrant,
|
||||||
|
FlutterAppAuthOAuthError.invalidClient,
|
||||||
|
FlutterAppAuthOAuthError.unauthorizedClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `true`, wenn der Fehler eine echte **Ablehnung** durch Keycloak ist
|
||||||
|
/// (Session wirklich beendet). Alles andere — Netzwerk, Timeout, 5xx —
|
||||||
|
/// gilt als vorübergehend. Primär über den strukturierten OAuth-Fehlercode
|
||||||
|
/// von `flutter_appauth`; als Fallback die Textsuche nach `invalid_grant`.
|
||||||
|
static bool _isSessionRejected(Object e) {
|
||||||
|
if (e is FlutterAppAuthPlatformException) {
|
||||||
|
final oauthError = e.platformErrorDetails.error;
|
||||||
|
if (oauthError != null) return _fatalOAuthErrors.contains(oauthError);
|
||||||
|
}
|
||||||
|
if (e is PlatformException) {
|
||||||
|
final haystack = '${e.code} ${e.message} ${e.details}'.toLowerCase();
|
||||||
|
return haystack.contains('invalid_grant');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void _applyTokens({
|
void _applyTokens({
|
||||||
required String? accessToken,
|
required String? accessToken,
|
||||||
required String? refreshToken,
|
required String? refreshToken,
|
||||||
|
|||||||
@ -390,16 +390,32 @@ class _PaymentMethodPicker extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is PaymentMethodsFailed) {
|
if (state is PaymentMethodsFailed) {
|
||||||
|
final onError = Theme.of(context).colorScheme.onErrorContainer;
|
||||||
return Card(
|
return Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
color: Theme.of(context).colorScheme.errorContainer,
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
child: Text(
|
child: Row(
|
||||||
state.message,
|
children: [
|
||||||
style: TextStyle(
|
Expanded(
|
||||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
child: Text(
|
||||||
),
|
state.message,
|
||||||
|
style: TextStyle(color: onError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Fehler ist kein Endzustand: der Fahrer kann das Laden
|
||||||
|
// direkt hier erneut anstoßen (z. B. nach Netz-Aussetzer).
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<PaymentMethodsCubit>().load(),
|
||||||
|
icon: Icon(Icons.refresh, color: onError),
|
||||||
|
label: Text(
|
||||||
|
'Erneut laden',
|
||||||
|
style: TextStyle(color: onError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:hl_lieferservice/bloc/app_bloc.dart';
|
|||||||
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
|
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
|
||||||
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
|
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
|
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
|
||||||
|
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
|
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
|
||||||
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
|
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
|
||||||
import 'package:hl_lieferservice/main.dart' show locator;
|
import 'package:hl_lieferservice/main.dart' show locator;
|
||||||
@ -142,8 +143,18 @@ class _DeliveryAppState extends State<DeliveryApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state is AppConfigLoaded) {
|
if (state is AppConfigLoaded) {
|
||||||
return LoginEnforcer(
|
// Stammdaten nach JEDEM erfolgreichen (Re-)Login neu laden.
|
||||||
child: CarSelectionEnforcer(child: Home()),
|
// Der Cubit lädt zwar beim App-Start — aber ohne Session
|
||||||
|
// endet das in 401 („Sitzung abgelaufen") und blieb bisher
|
||||||
|
// für immer im Fehlerzustand hängen, auch nach dem Login.
|
||||||
|
return BlocListener<AuthBloc, AuthState>(
|
||||||
|
listenWhen: (prev, curr) =>
|
||||||
|
curr is Authenticated && prev is! Authenticated,
|
||||||
|
listener: (context, _) =>
|
||||||
|
context.read<PaymentMethodsCubit>().load(),
|
||||||
|
child: LoginEnforcer(
|
||||||
|
child: CarSelectionEnforcer(child: Home()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user