From 6d7e58fc0f242f0d18fdfe31f8a4d5d7b050ab9d Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 14 May 2026 22:59:36 +0200 Subject: [PATCH] Phase B: Keycloak OIDC (PKCE) statt Cookie-Session-Login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-Code: - KeycloakOidcTokenProvider: PKCE-Login via flutter_appauth, Refresh via Refresh-Token aus flutter_secure_storage, Session-Restore beim App-Start, Logout. - AuthSessionEvent als Provider→Bloc-Brücke (LoggedIn/LoggedOut/ SessionExpired) auf einem Broadcast-Stream. - AuthBloc komplett umgebaut: nimmt jetzt den KeycloakOidcTokenProvider statt UserInfoService, mappt eingehende Provider-Events auf eigene Zustände. Authenticated.fromClaims() liest personalnummer + Name aus dem ID-Token-Payload. - LoginPage: kein Browser+Deep-Link mehr — Button feuert LoginRequested, der Provider übernimmt den restlichen Flow. - network_locator: produktiver KeycloakOidcTokenProvider, doppelt registriert (KeycloakOidcTokenProvider für AuthBloc, AuthTokenProvider für Interceptor). - Auth-State trägt zusätzlich personalnummer/displayName/email; das Legacy-User-Objekt + sessionId bleiben temporär drin, damit die alten ERPframe-Services (Phase D) noch kompilieren. Plattform-Setup: - Android: appAuthRedirectScheme=holzleitner in build.gradle.kts, NetworkSecurityConfig erlaubt HTTP zu localhost/10.0.2.2/127.0.0.1. - iOS: holzleitner als URL-Scheme im Info.plist, ATS-Ausnahme für localhost (HTTP-Keycloak im Dev-Setup). Out of scope: - Keine echte App-Run-Smoke — kommt mit dem User-Test. - iOS-pod-install läuft beim ersten 'flutter run ios' automatisch. - Old ERPframe-Services bleiben aktiv und werfen ab jetzt 401 (kein Cookie-Session-Token mehr) — wird in Phase D entfernt. --- android/app/build.gradle.kts | 6 + android/app/src/main/AndroidManifest.xml | 3 +- .../main/res/xml/network_security_config.xml | 17 ++ ios/Runner/Info.plist | 19 ++ lib/data/network/auth_session_event.dart | 26 ++ lib/data/network/backend_config.dart | 56 ++-- .../network/keycloak_oidc_token_provider.dart | 236 +++++++++++++++++ lib/data/network/network_locator.dart | 28 +- .../authentication/bloc/auth_bloc.dart | 155 ++++++++--- .../authentication/bloc/auth_event.dart | 55 +++- .../authentication/bloc/auth_state.dart | 84 +++++- .../presentation/login_page.dart | 244 +++++------------- lib/widget/app.dart | 18 +- pubspec.lock | 68 ++++- pubspec.yaml | 4 + 15 files changed, 738 insertions(+), 281 deletions(-) create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 lib/data/network/auth_session_event.dart create mode 100644 lib/data/network/keycloak_oidc_token_provider.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b2c6405..273e03f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -28,6 +28,12 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + // flutter_appauth liest diesen Placeholder und registriert + // dynamisch eine RedirectUriReceiverActivity, die den + // OIDC-Callback im Custom-Scheme `holzleitner://` abfängt. + // Muss mit der RedirectUri im Keycloak-Client matchen. + manifestPlaceholders["appAuthRedirectScheme"] = "holzleitner" } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ca93959..1701ed1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,7 +17,8 @@ + android:icon="@mipmap/ic_launcher" + android:networkSecurityConfig="@xml/network_security_config"> + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d022272..32a7b4d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -29,10 +29,29 @@ Editor CFBundleURLSchemes + myapp + + holzleitner + + NSAppTransportSecurity + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS diff --git a/lib/data/network/auth_session_event.dart b/lib/data/network/auth_session_event.dart new file mode 100644 index 0000000..9781904 --- /dev/null +++ b/lib/data/network/auth_session_event.dart @@ -0,0 +1,26 @@ +/// Events, die der `KeycloakOidcTokenProvider` über seinen +/// Broadcast-Stream auswirft. Der AuthBloc abonniert diesen Stream +/// und reagiert mit eigenen Zustands-Übergängen. +/// +/// Bewusst eigene Events (statt direkter Bloc-Aufrufe), damit der +/// Token-Provider keine Abhängigkeit auf die Bloc-Schicht braucht. +sealed class AuthSessionEvent { + const AuthSessionEvent(); +} + +/// Erfolgreicher Login (frisch oder restauriert). +final class AuthLoggedIn extends AuthSessionEvent { + const AuthLoggedIn(this.claims); + final Map claims; +} + +/// Sauberer Logout durch den Nutzer. +final class AuthLoggedOut extends AuthSessionEvent { + const AuthLoggedOut(); +} + +/// Refresh fehlgeschlagen oder Server lehnt Token ab — die App muss +/// zurück zur Login-Page. +final class AuthSessionExpired extends AuthSessionEvent { + const AuthSessionExpired(); +} diff --git a/lib/data/network/backend_config.dart b/lib/data/network/backend_config.dart index b43990a..f2932d5 100644 --- a/lib/data/network/backend_config.dart +++ b/lib/data/network/backend_config.dart @@ -1,48 +1,56 @@ /// Endpoint-Konfiguration für das Rust-Backend. /// -/// Dies ist eine Übergangs-Konfiguration für Phase A der -/// Backend-Migration. Sie wird in Phase D durch eine umfassendere -/// `LocalDocuFrameConfiguration`-Ablösung ersetzt (oder die bestehende -/// Konfiguration wird erweitert). +/// Diese Übergangs-Konfiguration für die Backend-Migration wird in +/// Phase D durch eine umfassendere Konfigurations-Ablösung verfeinert +/// (Build-Time-Flavor pro Stage etc.). /// /// **Werte für lokale Entwicklung:** -/// * iOS-Simulator + macOS-Host: `http://127.0.0.1:3000` -/// * Android-Emulator: `http://10.0.2.2:3000` -/// * Echtes Gerät im LAN: `http://:3000` +/// * iOS-Simulator + macOS-Host: `http://localhost:...` +/// * Android-Emulator: `http://10.0.2.2:...` +/// * Echtes Gerät im LAN: `http://:...` /// -/// Default ist iOS-Simulator-tauglich. Für Android-Build vor dem -/// Compile umstellen — eine Auto-Erkennung pro Platform kommt mit der -/// Phase-D-Config. +/// Default ist iOS-Simulator-tauglich; für Android-Build vor dem +/// Compile umstellen oder per Build-Flag injizieren. class BackendConfig { const BackendConfig({ required this.apiBaseUrl, - required this.keycloakTokenEndpoint, + required this.keycloakIssuerUrl, required this.keycloakClientId, + required this.keycloakRedirectUrl, }); /// Basis-URL der Rust-API (kein abschließender Slash). final String apiBaseUrl; - /// Vollständiger Token-Endpoint des Keycloak-Realms — Format: - /// `{issuer}/protocol/openid-connect/token`. - final String keycloakTokenEndpoint; + /// Realm-Issuer ohne `/.well-known/...`-Suffix — + /// `flutter_appauth` hängt das selbst an für die Discovery. + /// Beispiel: `http://localhost:8080/realms/holzleitner`. + /// + /// **Achtung:** Keycloak prägt das `iss`-Claim aus dem Hostnamen + /// dieser URL. Das Backend erwartet exakt diesen String als + /// `KEYCLOAK_ISSUER_URL`. Mismatch → 401 mit `invalid issuer`. + final String keycloakIssuerUrl; - /// Public-Client-Id, die das Backend als `audience` erwartet - /// (aktuell `holzleitner-app`). + /// Token-Endpoint des Realms — abgeleitet aus dem Issuer. + String get keycloakTokenEndpoint => + '$keycloakIssuerUrl/protocol/openid-connect/token'; + + /// Public-Client-Id (entspricht der `aud` im Backend-Token). final String keycloakClientId; + /// Custom-Scheme-Redirect, das in Keycloak als + /// `holzleitner://oauth2redirect` whitelisted ist. Muss mit dem + /// `appAuthRedirectScheme` in `android/app/build.gradle.kts` und + /// dem `CFBundleURLSchemes`-Eintrag in `ios/Runner/Info.plist` + /// matchen. + final String keycloakRedirectUrl; + /// Default-Konfiguration für lokale Entwicklung gegen das /// Docker-Compose-Setup (Postgres + Keycloak + Backend). - /// - /// **Achtung Hostname:** Keycloak prägt das `iss`-Claim des Tokens - /// aus dem Hostnamen der Token-Endpoint-URL. Das Backend erwartet - /// `iss = http://localhost:8080/realms/holzleitner`, deshalb hier - /// `localhost` statt `127.0.0.1`. Auf Android-Emulator entsprechend - /// `10.0.2.2` setzen. static const BackendConfig localDev = BackendConfig( apiBaseUrl: 'http://localhost:3000', - keycloakTokenEndpoint: - 'http://localhost:8080/realms/holzleitner/protocol/openid-connect/token', + keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner', keycloakClientId: 'holzleitner-app', + keycloakRedirectUrl: 'holzleitner://oauth2redirect', ); } diff --git a/lib/data/network/keycloak_oidc_token_provider.dart b/lib/data/network/keycloak_oidc_token_provider.dart new file mode 100644 index 0000000..f2e1869 --- /dev/null +++ b/lib/data/network/keycloak_oidc_token_provider.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'auth_session_event.dart'; +import 'auth_token_provider.dart'; +import 'backend_config.dart'; + +/// OIDC-Token-Provider gegen Keycloak (PKCE Authorization Code Flow). +/// +/// Verantwortlich für: +/// * **Login** über `flutter_appauth` (Browser-Tab → Keycloak → +/// RedirectURI `holzleitner://oauth2redirect`). +/// * **Token-Refresh** mit dem persistierten Refresh-Token. +/// * **Session-Restore** beim App-Start (Refresh-Token aus Secure +/// Storage einlesen + sofortigen Refresh anstoßen). +/// * **Logout**: lokale Token droppen + Secure Storage säubern. +/// +/// Access-Token und ID-Token-Claims liegen im Speicher (flüchtig), +/// der Refresh-Token wird in der Plattform-Secure-Storage abgelegt +/// (Keychain / EncryptedSharedPreferences). +/// +/// Events werden über [events] gebroadcastet — der AuthBloc abonniert +/// und reagiert. Diese Indirektion vermeidet eine Abhängigkeit auf die +/// Bloc-Schicht. +class KeycloakOidcTokenProvider implements AuthTokenProvider { + KeycloakOidcTokenProvider({ + required BackendConfig config, + required this.issuerUrl, + required this.redirectUrl, + FlutterAppAuth? appAuth, + FlutterSecureStorage? storage, + }) : _config = config, + _appAuth = appAuth ?? const FlutterAppAuth(), + _storage = storage ?? const FlutterSecureStorage(); + + final BackendConfig _config; + final FlutterAppAuth _appAuth; + final FlutterSecureStorage _storage; + + /// Vollständige Issuer-URL ohne `/.well-known/...`-Suffix. + /// Beispiel: `http://localhost:8080/realms/holzleitner`. + final String issuerUrl; + + /// Redirect-URI, die im Keycloak-Client erlaubt ist und auf das + /// Custom-Scheme der App matcht. Beispiel: + /// `holzleitner://oauth2redirect`. + final String redirectUrl; + + String? _accessToken; + DateTime? _expiresAt; + String? _refreshToken; + Map? _idTokenClaims; + + final StreamController _events = + StreamController.broadcast(); + + /// Stream der Auth-Events. Wird vom AuthBloc abonniert. + Stream get events => _events.stream; + + /// Discovery-URL für `flutter_appauth` — wird intern auch geprüft, + /// bevor PKCE losläuft. + String get _discoveryUrl => '$issuerUrl/.well-known/openid-configuration'; + + /// ID-Token-Claims der aktuellen Session, oder `null` wenn nicht + /// angemeldet. + Map? get idTokenClaims => _idTokenClaims; + + bool get isAuthenticated => _accessToken != null; + + /// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder + /// Keycloak einen Fehler liefert. + Future login() async { + final result = await _appAuth.authorizeAndExchangeCode( + AuthorizationTokenRequest( + _config.keycloakClientId, + redirectUrl, + discoveryUrl: _discoveryUrl, + scopes: const ['openid', 'profile'], + // Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser + // würde sonst abbrechen. In Produktion (HTTPS) ist das ein + // No-Op und kann bleiben. + allowInsecureConnections: true, + ), + ); + + _applyTokens( + accessToken: result.accessToken, + refreshToken: result.refreshToken, + idToken: result.idToken, + expiresAt: result.accessTokenExpirationDateTime, + ); + await _persistRefreshToken(); + + _events.add(AuthLoggedIn(_idTokenClaims ?? const {})); + } + + /// Versucht, eine vorhandene Session aus der Secure Storage zu + /// reaktivieren. Liefert `true`, wenn anschließend ein gültiger + /// Access-Token verfügbar ist. + Future restoreSession() async { + final stored = await _storage.read(key: _refreshTokenStorageKey); + if (stored == null || stored.isEmpty) return false; + _refreshToken = stored; + + final token = await currentAccessToken(); + if (token == null) return false; + + _events.add(AuthLoggedIn(_idTokenClaims ?? const {})); + return true; + } + + /// Bricht die Session ab — droppt alle Tokens lokal. Ein + /// serverseitiges `endSession` (Keycloak Single-Logout) machen wir + /// bewusst nicht: der Refresh-Token läuft beim Server normal aus, + /// und der ID-Token-Hint würde uns zwingen, den rohen ID-Token mit + /// in der Session zu halten. + Future logout() async { + _clearSession(); + await _storage.delete(key: _refreshTokenStorageKey); + _events.add(const AuthLoggedOut()); + } + + @override + Future currentAccessToken() async { + final cached = _accessToken; + final expiresAt = _expiresAt; + final now = DateTime.now().toUtc(); + + // Schwellwert 30 s: Pufferzeit gegen Clock-Drift und + // mid-flight-Expiry. + if (cached != null && + expiresAt != null && + expiresAt.isAfter(now.add(const Duration(seconds: 30)))) { + return cached; + } + + final rt = _refreshToken; + if (rt == null) return null; + + try { + final result = await _appAuth.token( + TokenRequest( + _config.keycloakClientId, + redirectUrl, + discoveryUrl: _discoveryUrl, + refreshToken: rt, + scopes: const ['openid', 'profile'], + allowInsecureConnections: true, + ), + ); + _applyTokens( + accessToken: result.accessToken, + refreshToken: result.refreshToken ?? rt, + idToken: result.idToken, + expiresAt: result.accessTokenExpirationDateTime, + ); + await _persistRefreshToken(); + return _accessToken; + } on Exception { + // Refresh hat nicht funktioniert — Session ist tot, nicht + // wiederherstellbar. Aufrufer kriegen null zurück, AuthBloc + // bekommt SessionExpired. + _clearSession(); + await _storage.delete(key: _refreshTokenStorageKey); + _events.add(const AuthSessionExpired()); + return null; + } + } + + void _applyTokens({ + required String? accessToken, + required String? refreshToken, + required String? idToken, + required DateTime? expiresAt, + }) { + _accessToken = accessToken; + _refreshToken = refreshToken; + _expiresAt = expiresAt?.toUtc(); + if (idToken != null) { + _idTokenClaims = _decodeJwtPayload(idToken); + } + } + + Future _persistRefreshToken() async { + final rt = _refreshToken; + if (rt == null) return; + await _storage.write(key: _refreshTokenStorageKey, value: rt); + } + + void _clearSession() { + _accessToken = null; + _expiresAt = null; + _refreshToken = null; + _idTokenClaims = null; + } + + /// Dispose-Hook für Tests / Hot-Restarts. + Future dispose() async { + await _events.close(); + } + + static const String _refreshTokenStorageKey = + 'holzleitner_keycloak_refresh_token'; + + /// Dekodiert das Payload-Segment eines JWT. Wirft bei strukturellen + /// Auffälligkeiten — Signatur wird **nicht** geprüft (das macht der + /// Server bei jedem Request neu). + static Map _decodeJwtPayload(String token) { + final parts = token.split('.'); + if (parts.length != 3) { + throw FormatException('Token-Format unerwartet: ${parts.length} Teile'); + } + String payload = parts[1]; + // base64url ohne Padding → wieder padden, sonst wirft base64Url.decode. + switch (payload.length % 4) { + case 0: + break; + case 2: + payload += '=='; + case 3: + payload += '='; + default: + throw const FormatException('Token-Payload hat ungültige Länge'); + } + final bytes = base64Url.decode(payload); + final json = utf8.decode(bytes); + final decoded = jsonDecode(json); + if (decoded is! Map) { + throw const FormatException('Token-Payload ist kein JSON-Objekt'); + } + return decoded; + } +} diff --git a/lib/data/network/network_locator.dart b/lib/data/network/network_locator.dart index ca2e6ed..4cf5edb 100644 --- a/lib/data/network/network_locator.dart +++ b/lib/data/network/network_locator.dart @@ -3,34 +3,34 @@ import 'package:holzleitner_api/holzleitner_api.dart'; import 'auth_token_provider.dart'; import 'backend_config.dart'; -import 'dev_password_grant_token_provider.dart'; import 'holzleitner_api_factory.dart'; +import 'keycloak_oidc_token_provider.dart'; /// Registriert das HTTP-/API-Subsystem im globalen GetIt-Locator. /// /// Aufruf bewusst nicht im AppBloc-Lifecycle, sondern in `main()` vor /// dem `runApp` — die API-Klassen sind über die gesamte App-Lebensdauer -/// stabil und brauchen keine Reaktion auf App-Events. +/// stabil. /// -/// Phase A nutzt die `DevPasswordGrantTokenProvider`-Implementation. -/// Phase B wird hier den OIDC-PKCE-Provider einhängen und die -/// Dev-Implementation komplett entfernen. +/// Phase B: produktiver `KeycloakOidcTokenProvider`. Die alte +/// `DevPasswordGrantTokenProvider`-Implementation bleibt im Code (für +/// das reine dart-Smoke-Tool, siehe `tool/smoke_test_api.dart`). void registerNetworking({ required GetIt locator, BackendConfig config = BackendConfig.localDev, - String testfahrerUsername = 'testfahrer', - String testfahrerPassword = 'test', }) { locator.registerSingleton(config); - locator.registerSingleton( - DevPasswordGrantTokenProvider( - tokenEndpoint: config.keycloakTokenEndpoint, - clientId: config.keycloakClientId, - username: testfahrerUsername, - password: testfahrerPassword, - ), + final provider = KeycloakOidcTokenProvider( + config: config, + issuerUrl: config.keycloakIssuerUrl, + redirectUrl: config.keycloakRedirectUrl, ); + // Doppelt registrieren: einmal unter der konkreten Klasse (für + // den AuthBloc, der Login/Logout/Restore aufruft) und einmal hinter + // dem Interface (für den HTTP-Interceptor). + locator.registerSingleton(provider); + locator.registerSingleton(provider); locator.registerSingleton( buildHolzleitnerApi( diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index bb2c02e..1c744e6 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -1,63 +1,148 @@ -import 'package:flutter/cupertino.dart'; +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:hl_lieferservice/data/network/auth_session_event.dart'; +import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; -import 'package:hl_lieferservice/feature/authentication/service/userinfo.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/main.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; +/// AuthBloc bridge between der UI und dem [KeycloakOidcTokenProvider]. +/// +/// Eingehende UI-Events triggern den Provider (Login/Logout/Restore). +/// Provider-Ereignisse kommen über den `events`-Stream rein, werden zu +/// `ProviderSessionChanged` übersetzt und vom Bloc in Zustände überführt. class AuthBloc extends Bloc { - UserInfoService service; - OperationBloc operationBloc; - - AuthBloc({required this.service, required this.operationBloc}) + AuthBloc({required this.tokenProvider, required this.operationBloc}) : super(Unauthenticated()) { - on(_auth); - on(_logout); - on(_sessionExpired); + on(_handleLogin); + on(_handleLogout); + on(_handleRestore); + on(_handleProviderEvent); + // Legacy: ERPframe-Repos feuern bei 401. + on((event, emit) async { + await tokenProvider.logout(); + if (locator.isRegistered()) { + locator.unregister(); + } + emit(Unauthenticated(sessionExpired: true)); + }); + + _subscription = tokenProvider.events.listen( + (event) => add(_toBlocEvent(event)), + ); } - Future _auth( - SetAuthenticatedEvent event, + final KeycloakOidcTokenProvider tokenProvider; + final OperationBloc operationBloc; + + late final StreamSubscription _subscription; + + Future _handleLogin( + LoginRequested event, Emitter emit, ) async { try { - debugPrint("Retrieve user information"); - emit(Authenticating()); - var response = await service.getUserinfo(event.sessionId); - var state = Authenticated(sessionId: event.sessionId, user: response); - locator.registerSingleton(state); - emit(state); + await tokenProvider.login(); + // Erfolg landet via Stream-Subscription als + // ProviderSessionChanged.loggedIn → _handleProviderEvent. } catch (err, st) { - debugPrint("Failed to retrieve user information"); - debugPrint(err.toString()); - debugPrint(st.toString()); - + debugPrint('Login fehlgeschlagen: $err\n$st'); emit(Unauthenticated()); operationBloc.add( FailOperation( - message: "Login war nicht erfolgreich. Probieren Sie es erneut.", + message: 'Login war nicht erfolgreich. Probieren Sie es erneut.', ), ); } } - Future _logout(Logout event, Emitter emit) async { - if (locator.isRegistered()) { - locator.unregister(); - } - emit(Unauthenticated()); - } - - Future _sessionExpired( - SessionExpiredEvent event, + Future _handleLogout( + LogoutRequested event, Emitter emit, ) async { - if (locator.isRegistered()) { - locator.unregister(); + await tokenProvider.logout(); + // Provider feuert AuthLoggedOut → _handleProviderEvent setzt den State. + } + + Future _handleRestore( + RestoreSessionRequested event, + Emitter emit, + ) async { + final restored = await tokenProvider.restoreSession(); + if (!restored) { + // Kein gültiger Token → bleibt bei Unauthenticated, kein Snackbar + // (das ist der normale Cold-Start-Pfad). } - emit(Unauthenticated(sessionExpired: true)); + } + + Future _handleProviderEvent( + ProviderSessionChanged event, + Emitter emit, + ) async { + switch (event.kind) { + case ProviderEventKind.loggedIn: + try { + final state = Authenticated.fromClaims( + claims: event.claims ?? const {}, + accessToken: event.accessToken ?? '', + ); + if (locator.isRegistered()) { + locator.unregister(); + } + locator.registerSingleton(state); + emit(state); + } catch (err, st) { + debugPrint('Konnte Claims nicht in Authenticated-State mappen: $err\n$st'); + emit(Unauthenticated()); + operationBloc.add( + FailOperation( + message: + 'Login-Antwort vom Server unvollständig. Bitte erneut versuchen.', + ), + ); + } + case ProviderEventKind.loggedOut: + if (locator.isRegistered()) { + locator.unregister(); + } + emit(Unauthenticated()); + case ProviderEventKind.sessionExpired: + if (locator.isRegistered()) { + locator.unregister(); + } + emit(Unauthenticated(sessionExpired: true)); + } + } + + ProviderSessionChanged _toBlocEvent(AuthSessionEvent event) { + return switch (event) { + AuthLoggedIn(:final claims) => ProviderSessionChanged( + ProviderEventKind.loggedIn, + claims: claims, + // Wir würden hier idealerweise den Access-Token mit übergeben. + // Da der Provider den Token nicht im Stream-Event mitliefert, + // bauen wir später eine Brücke. Für jetzt: leerer String — + // Authenticated.sessionId wird mit Phase D ohnehin abgeschafft. + accessToken: '', + ), + AuthLoggedOut() => const ProviderSessionChanged( + ProviderEventKind.loggedOut, + ), + AuthSessionExpired() => const ProviderSessionChanged( + ProviderEventKind.sessionExpired, + ), + }; + } + + @override + Future close() async { + await _subscription.cancel(); + return super.close(); } } diff --git a/lib/feature/authentication/bloc/auth_event.dart b/lib/feature/authentication/bloc/auth_event.dart index 8c20492..8c7ab30 100644 --- a/lib/feature/authentication/bloc/auth_event.dart +++ b/lib/feature/authentication/bloc/auth_event.dart @@ -1,15 +1,48 @@ -abstract class AuthEvent {} - -class SetAuthenticatedEvent extends AuthEvent { - String sessionId; - - SetAuthenticatedEvent({required this.sessionId}); +/// Events des AuthBloc. +/// +/// Eingehende Events (Trigger): `LoginRequested`, `LogoutRequested`, +/// `RestoreSessionRequested`. +/// +/// Stream-Events vom `KeycloakOidcTokenProvider` werden über +/// `ProviderSessionChanged` in den Bloc-Bus übersetzt — damit alles +/// in der `on<...>`-Maschinerie des Blocs verarbeitet werden kann. +sealed class AuthEvent { + const AuthEvent(); } -class Logout extends AuthEvent { - String username; - - Logout({required this.username}); +/// Vom UI ausgelöst — startet den PKCE-Login-Flow. +final class LoginRequested extends AuthEvent { + const LoginRequested(); +} + +/// Vom UI ausgelöst — beendet die Session. +final class LogoutRequested extends AuthEvent { + const LogoutRequested(); +} + +/// Vom App-Bootstrap ausgelöst — prüft, ob es einen persistierten +/// Refresh-Token gibt, und stellt die Session ggf. wieder her. +final class RestoreSessionRequested extends AuthEvent { + const RestoreSessionRequested(); +} + +/// Interner Event-Typ — Brücke vom Token-Provider-Stream in die +/// Bloc-Maschine. +final class ProviderSessionChanged extends AuthEvent { + const ProviderSessionChanged(this.kind, {this.claims, this.accessToken}); + + final ProviderEventKind kind; + final Map? claims; + final String? accessToken; +} + +enum ProviderEventKind { loggedIn, loggedOut, sessionExpired } + +/// Legacy-Event: Wird von den alten ERPframe-Repositories gefeuert, +/// wenn der Server mit 401 antwortet. Mit Phase D fliegt das raus, +/// weil 401 dann direkt vom `HolzleitnerAuthInterceptor` ausgewertet +/// und an den Provider gemeldet wird. +final class SessionExpiredEvent extends AuthEvent { + const SessionExpiredEvent(); } -class SessionExpiredEvent extends AuthEvent {} \ No newline at end of file diff --git a/lib/feature/authentication/bloc/auth_state.dart b/lib/feature/authentication/bloc/auth_state.dart index 9de5bbd..e79e4f7 100644 --- a/lib/feature/authentication/bloc/auth_state.dart +++ b/lib/feature/authentication/bloc/auth_state.dart @@ -7,13 +7,85 @@ class Unauthenticated extends AuthState { Unauthenticated({this.sessionExpired = false}); } -/// Transient state while [SetAuthenticatedEvent] is being processed and the -/// user info is being fetched from the server. +/// Transient state während dem PKCE-Flow (Browser-Tab offen, +/// Token-Tausch läuft). class Authenticating extends AuthState {} +/// Erfolgreiche Authentifizierung. Trägt sowohl die "neuen" +/// Token-/Claim-Felder als auch das Legacy-User-Objekt — Letzteres +/// wird aus den Claims befüllt und mit Phase D komplett entfernt. class Authenticated extends AuthState { - User user; - String sessionId; + Authenticated({ + required this.personalnummer, + required this.displayName, + required this.email, + required this.user, + required this.sessionId, + }); - Authenticated({required this.user, required this.sessionId}); -} \ No newline at end of file + /// Personalnummer aus dem `personalnummer`-Custom-Claim. Das + /// Backend nutzt diese als Account-Identifier. + final int personalnummer; + + /// Beste verfügbare Beschriftung für den Fahrer: + /// `name` > `given_name + family_name` > `preferred_username`. + final String displayName; + + /// E-Mail aus dem `email`-Claim, falls vorhanden. + final String? email; + + /// Legacy: das alte `User`-Domänenobjekt, aus den Claims befüllt. + /// Wird in Phase D entfernt, sobald keine Aufrufer mehr da sind. + final User user; + + /// Legacy: füllt für die alten `getSessionOrThrow()`-Aufrufer + /// einen Wert — die laufen sowieso gegen ERPframe und werden in + /// Phase D abgelöst. + final String sessionId; + + /// Konstruiert den State aus dem dekodierten ID-Token-Payload. + factory Authenticated.fromClaims({ + required Map claims, + required String accessToken, + }) { + final personalnummerRaw = claims['personalnummer']; + final personalnummer = switch (personalnummerRaw) { + int v => v, + String v => int.parse(v), + _ => throw FormatException( + 'personalnummer-Claim fehlt oder hat unerwarteten Typ ' + '(${personalnummerRaw.runtimeType})', + ), + }; + + final given = (claims['given_name'] as String?) ?? ''; + final family = (claims['family_name'] as String?) ?? ''; + final preferredUsername = + (claims['preferred_username'] as String?) ?? 'fahrer'; + final fullName = (claims['name'] as String?)?.trim(); + + final displayName = + fullName != null && fullName.isNotEmpty + ? fullName + : (given.isNotEmpty || family.isNotEmpty + ? '$given $family'.trim() + : preferredUsername); + + final email = claims['email'] as String?; + + final user = User( + number: personalnummer.toString(), + firstName: given, + lastName: family, + mail: email ?? '', + ); + + return Authenticated( + personalnummer: personalnummer, + displayName: displayName, + email: email, + user: user, + sessionId: accessToken, + ); + } +} diff --git a/lib/feature/authentication/presentation/login_page.dart b/lib/feature/authentication/presentation/login_page.dart index 354c4a2..f9b4d91 100644 --- a/lib/feature/authentication/presentation/login_page.dart +++ b/lib/feature/authentication/presentation/login_page.dart @@ -1,129 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:app_links/app_links.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:hl_lieferservice/util.dart'; -import 'dart:async'; - -class LoginPage extends StatefulWidget { - final bool sessionExpired; +/// Login-Page nach der Migration auf Keycloak OIDC (Phase B). +/// +/// Der eigentliche Flow läuft komplett im `AuthBloc` → +/// `KeycloakOidcTokenProvider.login()`: `flutter_appauth` öffnet einen +/// Browser-Tab, der RedirectURI `holzleitner://oauth2redirect` kommt +/// zurück, der Code wird gegen Tokens getauscht. +class LoginPage extends StatelessWidget { const LoginPage({super.key, this.sessionExpired = false}); - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - final _loginFormKey = GlobalKey(); - bool _isLoading = false; - late AppLinks _appLinks; - StreamSubscription? _linkSubscription; - - @override - void initState() { - super.initState(); - _appLinks = AppLinks(); - } - - @override - void dispose() { - _linkSubscription?.cancel(); - super.dispose(); - } - - void _onPressLogin() async { - setState(() => _isLoading = true); - - try { - debugPrint("🔵 Setting up deep link listener..."); - - final completer = Completer(); - - // Listen for deep links BEFORE opening browser - _linkSubscription = _appLinks.uriLinkStream.listen( - (Uri uri) { - debugPrint("🟢 Deep link received: $uri"); - if (uri.scheme == 'myapp' && !completer.isCompleted) { - completer.complete(uri); - } - }, - onError: (err) { - debugPrint("🔴 Deep link error: $err"); - if (!completer.isCompleted) { - completer.completeError(err); - } - }, - ); - - // Small delay to ensure listener is ready - await Future.delayed(const Duration(milliseconds: 500)); - - final loginUrl = Uri.parse('${getConfig().backendUrl}/login'); - final launched = await launchUrl( - loginUrl, - mode: LaunchMode.externalApplication, - ); - - if (!launched) { - throw Exception('Could not launch browser'); - } - - debugPrint("🔵 Browser opened. Waiting for callback..."); - - // Wait for the deep link callback - final callbackUri = await completer.future.timeout( - const Duration(minutes: 5), - onTimeout: () { - debugPrint("⏱️ Timeout - no callback received"); - throw TimeoutException('Login timeout'); - }, - ); - - final sessionId = callbackUri.queryParameters['session_id']!; - - debugPrint("✅ Success! Callback: $callbackUri"); - debugPrint("✅ Session ID: $sessionId"); - - await _linkSubscription?.cancel(); - _linkSubscription = null; - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Login erfolgreich!'), - backgroundColor: Colors.green, - ), - ); - - context.read().add(SetAuthenticatedEvent(sessionId: sessionId)); - } - - } on TimeoutException { - debugPrint("❌ Timeout"); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Login Timeout')), - ); - } - } catch (e) { - debugPrint("❌ Error: $e"); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Fehler: $e')), - ); - } - } finally { - await _linkSubscription?.cancel(); - _linkSubscription = null; - if (mounted) { - setState(() => _isLoading = false); - } - } - } + final bool sessionExpired; @override Widget build(BuildContext context) { @@ -131,87 +22,78 @@ class _LoginPageState extends State { appBar: AppBar(), body: Column( children: [ - if (widget.sessionExpired) + if (sessionExpired) MaterialBanner( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), content: const Text( - "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", + 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', style: TextStyle(color: Colors.white), ), backgroundColor: Colors.orange.shade800, - leading: const Icon(Icons.warning_amber_rounded, color: Colors.white), - actions: [const SizedBox.shrink()], + leading: const Icon( + Icons.warning_amber_rounded, + color: Colors.white, + ), + actions: const [SizedBox.shrink()], ), Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(50), + child: Center( child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ - Image.asset( - "assets/holzleitner_Logo_2017_RZ_transparent.png", + Padding( + padding: const EdgeInsets.all(50), + child: Column( + children: [ + Image.asset( + 'assets/holzleitner_Logo_2017_RZ_transparent.png', + ), + const Padding( + padding: EdgeInsets.only(top: 20), + child: Text( + 'Auslieferservice', + style: TextStyle( + fontWeight: FontWeight.w400, + fontSize: 20, + ), + ), + ), + ], + ), ), - const Padding( - padding: EdgeInsets.only(top: 20), - child: Text( - "Auslieferservice", - style: TextStyle( - fontWeight: FontWeight.w400, - fontSize: 20, + FractionallySizedBox( + widthFactor: 0.8, + child: Padding( + padding: const EdgeInsets.only(top: 15, bottom: 15), + child: BlocBuilder( + builder: (context, state) { + if (state is Authenticating) { + return Column( + children: const [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Anmeldung wird abgeschlossen…'), + ], + ); + } + return OutlinedButton( + onPressed: () => context.read().add( + const LoginRequested(), + ), + child: const Text( + 'Anmelden mit Holzleitner Login', + ), + ); + }, ), ), ), ], ), ), - Form( - key: _loginFormKey, - child: FractionallySizedBox( - widthFactor: 0.8, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(top: 15, bottom: 15), - child: BlocBuilder( - builder: (context, authState) { - final isBusy = - _isLoading || authState is Authenticating; - if (!isBusy) { - return OutlinedButton( - onPressed: _onPressLogin, - child: const Text( - "Anmelden mit Holzleitner Login", - ), - ); - } - return Column( - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - authState is Authenticating - ? 'Anmeldung wird abgeschlossen…' - : 'Warte auf Login...', - ), - ], - ); - }, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), + ), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/widget/app.dart b/lib/widget/app.dart index 3cb76be..19c3fb6 100644 --- a/lib/widget/app.dart +++ b/lib/widget/app.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/bloc/app_bloc.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_event.dart'; import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart'; -import 'package:hl_lieferservice/feature/authentication/service/userinfo.dart'; +import 'package:hl_lieferservice/main.dart' show locator; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart'; import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; @@ -36,7 +38,6 @@ class _DeliveryAppState extends State { return BlocBuilder( builder: (context, state) { if (state is AppConfigLoaded) { - final currentAppState = state; return MultiBlocProvider( providers: [ BlocProvider(create: (context) => NavigationBloc()), @@ -44,11 +45,14 @@ class _DeliveryAppState extends State { BlocProvider( create: (context) => AuthBloc( - service: UserInfoService( - url: currentAppState.config.backendUrl, - ), - operationBloc: context.read(), - ), + tokenProvider: + locator(), + operationBloc: context.read(), + ) + // Beim ersten Build: prüfen, ob ein + // Refresh-Token aus der Secure Storage da ist, + // und ggf. direkt einloggen. + ..add(const RestoreSessionRequested()), ), BlocProvider( create: diff --git a/pubspec.lock b/pubspec.lock index b5b1278..b2979ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -342,6 +342,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_appauth: + dependency: "direct main" + description: + name: flutter_appauth + sha256: b09fa8e3eaba12ec341c69ec45063e06eb565304e24cc35caaf105bbae2e955c + url: "https://pub.dev" + source: hosted + version: "9.0.1" + flutter_appauth_platform_interface: + dependency: transitive + description: + name: flutter_appauth_platform_interface + sha256: fd2920b853d09741aff2e1178e044ea2ade0c87799cd8e63f094ab35b00fdf70 + url: "https://pub.dev" + source: hosted + version: "9.0.0" flutter_barcode_listener: dependency: "direct main" description: @@ -374,6 +390,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.29" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_svg: dependency: transitive description: @@ -619,10 +683,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3ebabb9..a7fac39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,10 @@ dependencies: # HTTP-Client für das neue Rust-Backend (per OpenAPI-Generator # erzeugter Client setzt dio voraus). dio: ^5.7.0 + # OIDC / PKCE-Flow gegen Keycloak (Phase B der Backend-Migration). + flutter_appauth: ^9.0.0 + # Persistenter Refresh-Token-Speicher in Keychain / KeyStore. + flutter_secure_storage: ^9.2.4 # Generiertes Sub-Package: produziert durch # `tool/generate_api_client.sh` aus openapi/holzleitner.json. holzleitner_api: