From 1315b95a7260009d049f5b60bbc4fcc0b1ed4092 Mon Sep 17 00:00:00 2001 From: oskar Date: Tue, 13 Jan 2026 14:35:57 +0100 Subject: [PATCH] implement offline lock with PIN and biometric authentication - Introduced `AppGateController` to manage initial routing (login, unlock, or home) based on session and connectivity state. - Added `OfflineLockRepository` and `UnlockController` with support for PIN (PBKDF2 hashing) and biometric authentication. - Created `UnlockScreen` for PIN entry and biometric prompts. - Added `ConnectivityService` to detect online/offline status using `connectivity_plus`. - Enhanced `AuthLocalDataSource` and `SessionRepository` to manage session markers and user IDs. - Updated `AuthController` to handle session markers and navigation transitions. - Modified `GoRouter` to use `AppGateState` for app-wide redirection logic. - Integrated `local_auth` and `cryptography` packages. - Added comprehensive unit tests for `AppGateController` and `UnlockController`. --- front001/mosenioring/README.md | 12 ++ .../android/app/src/main/AndroidManifest.xml | 2 + front001/mosenioring/lib/src/app/router.dart | 23 ++- .../network/connectivity_plus_service.dart | 18 +++ .../core/network/connectivity_service.dart | 3 + .../lib/src/core/security/jwt_utils.dart | 23 +++ .../mosenioring/lib/src/di/providers.dart | 61 +++++++ .../presentation/app_gate_controller.dart | 98 ++++++++++++ .../app_gate/presentation/app_gate_state.dart | 29 ++++ .../auth/data/auth_local_data_source.dart | 35 +++- .../auth/data/auth_repository_impl.dart | 7 + .../features/auth/domain/auth_repository.dart | 2 + .../auth/presentation/auth_controller.dart | 19 +++ .../features/home/presentation/home_page.dart | 41 +++++ .../data/biometric_authenticator.dart | 4 + .../data/flutter_secure_storage_adapter.dart | 18 +++ .../offline_lock/data/key_value_storage.dart | 5 + .../local_auth_biometric_authenticator.dart | 33 ++++ .../data/offline_lock_repository_impl.dart | 73 +++++++++ .../offline_lock/data/pbkdf2_pin_hasher.dart | 62 ++++++++ .../domain/offline_lock_repository.dart | 8 + .../offline_lock/domain/pin_hash.dart | 11 ++ .../offline_lock/domain/pin_hasher.dart | 9 ++ .../presentation/unlock_controller.dart | 78 +++++++++ .../presentation/unlock_screen.dart | 114 ++++++++++++++ .../presentation/unlock_state.dart | 35 ++++ .../session/data/session_repository_impl.dart | 27 ++++ .../session/domain/models/session_marker.dart | 9 ++ .../session/domain/session_repository.dart | 11 ++ .../Flutter/GeneratedPluginRegistrant.swift | 4 + front001/mosenioring/pubspec.yaml | 5 +- .../app_gate/app_gate_controller_test.dart | 149 ++++++++++++++++++ .../features/auth/auth_controller_test.dart | 59 +++++++ .../offline_lock_repository_test.dart | 66 ++++++++ .../offline_lock/unlock_controller_test.dart | 90 +++++++++++ front001/mosenioring/test/widget_test.dart | 76 ++++++++- .../flutter/generated_plugin_registrant.cc | 6 + .../windows/flutter/generated_plugins.cmake | 2 + 38 files changed, 1319 insertions(+), 8 deletions(-) create mode 100644 front001/mosenioring/lib/src/core/network/connectivity_plus_service.dart create mode 100644 front001/mosenioring/lib/src/core/network/connectivity_service.dart create mode 100644 front001/mosenioring/lib/src/core/security/jwt_utils.dart create mode 100644 front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_controller.dart create mode 100644 front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_state.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/data/biometric_authenticator.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/data/flutter_secure_storage_adapter.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/data/key_value_storage.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/data/local_auth_biometric_authenticator.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/data/offline_lock_repository_impl.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/data/pbkdf2_pin_hasher.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/domain/offline_lock_repository.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/domain/pin_hash.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/domain/pin_hasher.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_controller.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_screen.dart create mode 100644 front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_state.dart create mode 100644 front001/mosenioring/lib/src/features/session/data/session_repository_impl.dart create mode 100644 front001/mosenioring/lib/src/features/session/domain/models/session_marker.dart create mode 100644 front001/mosenioring/lib/src/features/session/domain/session_repository.dart create mode 100644 front001/mosenioring/test/features/app_gate/app_gate_controller_test.dart create mode 100644 front001/mosenioring/test/features/offline_lock/offline_lock_repository_test.dart create mode 100644 front001/mosenioring/test/features/offline_lock/unlock_controller_test.dart diff --git a/front001/mosenioring/README.md b/front001/mosenioring/README.md index 95641fa..df0f2d1 100644 --- a/front001/mosenioring/README.md +++ b/front001/mosenioring/README.md @@ -43,6 +43,18 @@ When the spec is ready, generate a client and replace `ApiClient` usage: 2. Generate a Dart client (OpenAPI Generator or Swagger Codegen). 3. Swap `AuthRemoteDataSource` or `TelemetryRemoteDataSource` to call the generated client. +## Clean and test +```sh +flutter clean +flutter test +``` + +## Clean and build +```sh +flutter clean +flutter build apk +``` + ## Running ```sh diff --git a/front001/mosenioring/android/app/src/main/AndroidManifest.xml b/front001/mosenioring/android/app/src/main/AndroidManifest.xml index 21e71dc..68c2b79 100644 --- a/front001/mosenioring/android/app/src/main/AndroidManifest.xml +++ b/front001/mosenioring/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + ((ref) { - final authState = ref.watch(authControllerProvider); + final appGateState = ref.watch(appGateControllerProvider); return GoRouter( initialLocation: '/login', @@ -19,15 +21,26 @@ final appRouterProvider = Provider((ref) { path: '/', builder: (context, state) => const HomePage(), ), + GoRoute( + path: '/unlock', + builder: (context, state) => const UnlockScreen(), + ), ], redirect: (context, state) { - final isLoggedIn = authState.isAuthenticated; - final isLoggingIn = state.matchedLocation == '/login'; + if (appGateState.isLoading) { + return null; + } + final destination = appGateState.destination; + final location = state.matchedLocation; - if (!isLoggedIn && !isLoggingIn) { + if (destination == AppGateDestination.login && location != '/login') { return '/login'; } - if (isLoggedIn && isLoggingIn) { + if (destination == AppGateDestination.unlock && location != '/unlock') { + return '/unlock'; + } + if (destination == AppGateDestination.home && + (location == '/login' || location == '/unlock')) { return '/'; } return null; diff --git a/front001/mosenioring/lib/src/core/network/connectivity_plus_service.dart b/front001/mosenioring/lib/src/core/network/connectivity_plus_service.dart new file mode 100644 index 0000000..6d69b4a --- /dev/null +++ b/front001/mosenioring/lib/src/core/network/connectivity_plus_service.dart @@ -0,0 +1,18 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'connectivity_service.dart'; + +class ConnectivityPlusService implements ConnectivityService { + ConnectivityPlusService(this._connectivity); + + final Connectivity _connectivity; + + @override + Future isOnline() async { + final result = await _connectivity.checkConnectivity(); + if (result.isEmpty) { + return false; + } + return !result.contains(ConnectivityResult.none); + } +} diff --git a/front001/mosenioring/lib/src/core/network/connectivity_service.dart b/front001/mosenioring/lib/src/core/network/connectivity_service.dart new file mode 100644 index 0000000..aa34e5e --- /dev/null +++ b/front001/mosenioring/lib/src/core/network/connectivity_service.dart @@ -0,0 +1,3 @@ +abstract class ConnectivityService { + Future isOnline(); +} diff --git a/front001/mosenioring/lib/src/core/security/jwt_utils.dart b/front001/mosenioring/lib/src/core/security/jwt_utils.dart new file mode 100644 index 0000000..43e44c9 --- /dev/null +++ b/front001/mosenioring/lib/src/core/security/jwt_utils.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +String? extractSubjectFromJwt(String token) { + final parts = token.split('.'); + if (parts.length < 2) { + return null; + } + final payload = parts[1]; + try { + final normalized = base64Url.normalize(payload); + final decoded = utf8.decode(base64Url.decode(normalized)); + final data = jsonDecode(decoded); + if (data is Map) { + final sub = data['sub']; + if (sub is String && sub.isNotEmpty) { + return sub; + } + } + } catch (_) { + return null; + } + return null; +} diff --git a/front001/mosenioring/lib/src/di/providers.dart b/front001/mosenioring/lib/src/di/providers.dart index bfe37e7..f7db814 100644 --- a/front001/mosenioring/lib/src/di/providers.dart +++ b/front001/mosenioring/lib/src/di/providers.dart @@ -1,17 +1,34 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:local_auth/local_auth.dart'; import '../core/config/app_config.dart'; import '../core/network/api_client.dart'; +import '../core/network/connectivity_plus_service.dart'; +import '../core/network/connectivity_service.dart'; import '../core/network/http_client.dart'; +import '../features/app_gate/presentation/app_gate_controller.dart'; +import '../features/app_gate/presentation/app_gate_state.dart'; import '../features/auth/data/auth_local_data_source.dart'; import '../features/auth/data/auth_remote_data_source.dart'; import '../features/auth/data/auth_repository_impl.dart'; import '../features/auth/domain/auth_repository.dart'; import '../features/auth/presentation/auth_controller.dart'; import '../features/auth/presentation/auth_state.dart'; +import '../features/offline_lock/data/biometric_authenticator.dart'; +import '../features/offline_lock/data/flutter_secure_storage_adapter.dart'; +import '../features/offline_lock/data/local_auth_biometric_authenticator.dart'; +import '../features/offline_lock/data/offline_lock_repository_impl.dart'; +import '../features/offline_lock/data/pbkdf2_pin_hasher.dart'; +import '../features/offline_lock/domain/offline_lock_repository.dart'; +import '../features/offline_lock/domain/pin_hasher.dart'; +import '../features/offline_lock/presentation/unlock_controller.dart'; +import '../features/offline_lock/presentation/unlock_state.dart'; +import '../features/session/data/session_repository_impl.dart'; +import '../features/session/domain/session_repository.dart'; import '../features/telemetry/data/auth_token_provider.dart'; import '../features/telemetry/data/telemetry_remote_data_source.dart'; import '../features/telemetry/data/telemetry_service_impl.dart'; @@ -28,6 +45,18 @@ final secureStorageProvider = Provider((ref) { return const FlutterSecureStorage(); }); +final connectivityProvider = Provider((ref) { + return Connectivity(); +}); + +final connectivityServiceProvider = Provider((ref) { + return ConnectivityPlusService(ref.watch(connectivityProvider)); +}); + +final localAuthProvider = Provider((ref) { + return LocalAuthentication(); +}); + final authLocalDataSourceProvider = Provider((ref) { return AuthLocalDataSource(ref.watch(secureStorageProvider)); }); @@ -118,10 +147,42 @@ final authRepositoryProvider = Provider((ref) { ); }); +final sessionRepositoryProvider = Provider((ref) { + return SessionRepositoryImpl(ref.watch(authLocalDataSourceProvider)); +}); + final authControllerProvider = NotifierProvider(() { return AuthController(); }); +final appGateControllerProvider = NotifierProvider(() { + return AppGateController(); +}); + +final offlineLockStorageProvider = Provider((ref) { + return FlutterSecureStorageAdapter(ref.watch(secureStorageProvider)); +}); + +final pinHasherProvider = Provider((ref) { + return Pbkdf2PinHasher(); +}); + +final biometricAuthenticatorProvider = Provider((ref) { + return LocalAuthBiometricAuthenticator(ref.watch(localAuthProvider)); +}); + +final offlineLockRepositoryProvider = Provider((ref) { + return OfflineLockRepositoryImpl( + storage: ref.watch(offlineLockStorageProvider), + hasher: ref.watch(pinHasherProvider), + biometricAuthenticator: ref.watch(biometricAuthenticatorProvider), + ); +}); + +final unlockControllerProvider = NotifierProvider(() { + return UnlockController(); +}); + final httpClientProvider = Provider((ref) { return ApiHttpClient(ref.watch(apiClientProvider)); }); diff --git a/front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_controller.dart b/front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_controller.dart new file mode 100644 index 0000000..26c8517 --- /dev/null +++ b/front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_controller.dart @@ -0,0 +1,98 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../di/providers.dart'; +import '../../../core/security/jwt_utils.dart'; +import '../../session/domain/models/session_marker.dart'; +import 'app_gate_state.dart'; + +class AppGateController extends Notifier { + @override + AppGateState build() { + Future.microtask(_bootstrap); + return const AppGateState(isLoading: true); + } + + Future _bootstrap() async { + state = state.copyWith(isLoading: true, errorMessage: null); + final sessionRepository = ref.read(sessionRepositoryProvider); + final connectivityService = ref.read(connectivityServiceProvider); + final authRepository = ref.read(authRepositoryProvider); + + final token = await sessionRepository.readToken(); + var marker = await sessionRepository.readSessionMarker(); + final hasSession = marker != null || token != null; + + if (!hasSession) { + state = const AppGateState(isLoading: false, destination: AppGateDestination.login); + return; + } + + if (marker == null && token != null) { + final userId = extractSubjectFromJwt(token.accessToken) ?? 'unknown'; + marker = SessionMarker(userId: userId, lastOnlineAt: DateTime.now()); + await sessionRepository.saveSessionMarker(marker); + } + + final isOnline = await connectivityService.isOnline(); + if (!isOnline) { + state = const AppGateState( + isLoading: false, + destination: AppGateDestination.unlock, + isOffline: true, + ); + return; + } + + final refreshToken = token?.refreshToken; + if (refreshToken != null && refreshToken.isNotEmpty) { + try { + final refreshed = await authRepository.refreshToken(refreshToken: refreshToken); + final userId = + extractSubjectFromJwt(refreshed.accessToken) ?? marker?.userId ?? 'unknown'; + await sessionRepository.saveSessionMarker( + SessionMarker(userId: userId, lastOnlineAt: DateTime.now()), + ); + } catch (error) { + state = AppGateState( + isLoading: false, + destination: AppGateDestination.login, + errorMessage: error.toString(), + ); + return; + } + } + + state = const AppGateState( + isLoading: false, + destination: AppGateDestination.home, + isOffline: false, + ); + } + + void setOfflineUnlocked() { + state = state.copyWith( + isLoading: false, + destination: AppGateDestination.home, + isOffline: true, + errorMessage: null, + ); + } + + void setOnlineAuthenticated() { + state = state.copyWith( + isLoading: false, + destination: AppGateDestination.home, + isOffline: false, + errorMessage: null, + ); + } + + void resetToLogin() { + state = state.copyWith( + isLoading: false, + destination: AppGateDestination.login, + isOffline: false, + errorMessage: null, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_state.dart b/front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_state.dart new file mode 100644 index 0000000..291e33e --- /dev/null +++ b/front001/mosenioring/lib/src/features/app_gate/presentation/app_gate_state.dart @@ -0,0 +1,29 @@ +enum AppGateDestination { login, unlock, home } + +class AppGateState { + const AppGateState({ + this.isLoading = false, + this.destination = AppGateDestination.login, + this.isOffline = false, + this.errorMessage, + }); + + final bool isLoading; + final AppGateDestination destination; + final bool isOffline; + final String? errorMessage; + + AppGateState copyWith({ + bool? isLoading, + AppGateDestination? destination, + bool? isOffline, + String? errorMessage, + }) { + return AppGateState( + isLoading: isLoading ?? this.isLoading, + destination: destination ?? this.destination, + isOffline: isOffline ?? this.isOffline, + errorMessage: errorMessage, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart b/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart index 0903729..0b58749 100644 --- a/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart +++ b/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart @@ -1,6 +1,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../domain/models/auth_token.dart'; +import '../../session/domain/models/session_marker.dart'; class AuthLocalDataSource { AuthLocalDataSource(this._storage); @@ -12,6 +13,8 @@ class AuthLocalDataSource { static const _localEmailKey = 'local_email'; static const _localRolesKey = 'local_roles'; static const _localTenantKey = 'local_tenant'; + static const _sessionUserIdKey = 'session_user_id'; + static const _sessionLastOnlineKey = 'session_last_online_at'; Future readToken() async { final accessToken = await _storage.read(key: _accessTokenKey); @@ -24,8 +27,11 @@ class AuthLocalDataSource { Future saveToken(AuthToken token) async { await _storage.write(key: _accessTokenKey, value: token.accessToken); - if (token.refreshToken != null) { + final refreshToken = token.refreshToken; + if (refreshToken != null && refreshToken.isNotEmpty) { await _storage.write(key: _refreshTokenKey, value: token.refreshToken); + } else { + await _storage.delete(key: _refreshTokenKey); } } @@ -66,8 +72,35 @@ class AuthLocalDataSource { _storage.delete(key: _localEmailKey), _storage.delete(key: _localRolesKey), _storage.delete(key: _localTenantKey), + _storage.delete(key: _sessionUserIdKey), + _storage.delete(key: _sessionLastOnlineKey), ]); } + + Future saveSessionMarker(SessionMarker marker) async { + await _storage.write(key: _sessionUserIdKey, value: marker.userId); + await _storage.write( + key: _sessionLastOnlineKey, + value: marker.lastOnlineAt.toIso8601String(), + ); + } + + Future readSessionMarker() async { + final results = await Future.wait([ + _storage.read(key: _sessionUserIdKey), + _storage.read(key: _sessionLastOnlineKey), + ]); + final userId = results[0]; + final lastOnlineRaw = results[1]; + if (userId == null || userId.isEmpty || lastOnlineRaw == null) { + return null; + } + final parsed = DateTime.tryParse(lastOnlineRaw); + if (parsed == null) { + return null; + } + return SessionMarker(userId: userId, lastOnlineAt: parsed); + } } class LocalAuthSession { diff --git a/front001/mosenioring/lib/src/features/auth/data/auth_repository_impl.dart b/front001/mosenioring/lib/src/features/auth/data/auth_repository_impl.dart index 971a083..a6a605f 100644 --- a/front001/mosenioring/lib/src/features/auth/data/auth_repository_impl.dart +++ b/front001/mosenioring/lib/src/features/auth/data/auth_repository_impl.dart @@ -19,6 +19,13 @@ class AuthRepositoryImpl implements AuthRepository { return token; } + @override + Future refreshToken({required String refreshToken}) async { + final token = await _remote.refreshToken(refreshToken: refreshToken); + await _local.saveToken(token); + return token; + } + @override Future getSavedToken() { return _local.readToken(); diff --git a/front001/mosenioring/lib/src/features/auth/domain/auth_repository.dart b/front001/mosenioring/lib/src/features/auth/domain/auth_repository.dart index 17ce54d..f60ebee 100644 --- a/front001/mosenioring/lib/src/features/auth/domain/auth_repository.dart +++ b/front001/mosenioring/lib/src/features/auth/domain/auth_repository.dart @@ -6,6 +6,8 @@ abstract class AuthRepository { required String password, }); + Future refreshToken({required String refreshToken}); + Future getSavedToken(); Future persistToken(AuthToken token); diff --git a/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart b/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart index e68535c..69aceb8 100644 --- a/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart +++ b/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart @@ -1,9 +1,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../di/providers.dart'; +import '../../../core/security/jwt_utils.dart'; import '../data/auth_local_data_source.dart'; import '../domain/auth_repository.dart'; import '../domain/models/auth_token.dart'; +import '../../session/domain/models/session_marker.dart'; +import '../../session/domain/session_repository.dart'; +import '../../app_gate/presentation/app_gate_controller.dart'; import 'auth_state.dart'; class AuthController extends Notifier { @@ -15,6 +19,9 @@ class AuthController extends Notifier { AuthRepository get _repository => ref.watch(authRepositoryProvider); AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider); + SessionRepository get _sessionRepository => ref.watch(sessionRepositoryProvider); + AppGateController get _appGateController => + ref.read(appGateControllerProvider.notifier); Future _bootstrap() async { state = state.copyWith(isLoading: true, errorMessage: null); @@ -59,11 +66,22 @@ class AuthController extends Notifier { ); const token = AuthToken(accessToken: 'local'); await _repository.persistToken(token); + await _sessionRepository.saveSessionMarker( + SessionMarker(userId: email, lastOnlineAt: DateTime.now()), + ); state = const AuthState(isLoading: false, token: token); + _appGateController.setOnlineAuthenticated(); return; } final token = await _repository.login(email: email, password: password); + await _sessionRepository.saveSessionMarker( + SessionMarker( + userId: extractSubjectFromJwt(token.accessToken) ?? email, + lastOnlineAt: DateTime.now(), + ), + ); state = AuthState(isLoading: false, token: token); + _appGateController.setOnlineAuthenticated(); } catch (error) { state = state.copyWith( isLoading: false, @@ -75,6 +93,7 @@ class AuthController extends Notifier { Future logout() async { await _repository.logout(); state = const AuthState(); + _appGateController.resetToLogin(); } String _friendlyError(Object error) { diff --git a/front001/mosenioring/lib/src/features/home/presentation/home_page.dart b/front001/mosenioring/lib/src/features/home/presentation/home_page.dart index 72551fd..822ffb0 100644 --- a/front001/mosenioring/lib/src/features/home/presentation/home_page.dart +++ b/front001/mosenioring/lib/src/features/home/presentation/home_page.dart @@ -36,6 +36,7 @@ class _HomePageState extends ConsumerState { final l10n = AppLocalizations.of(context)!; final telemetryState = ref.watch(telemetryControllerProvider); + final appGateState = ref.watch(appGateControllerProvider); return Scaffold( appBar: AppBar( @@ -57,6 +58,46 @@ class _HomePageState extends ConsumerState { l10n.signedInMessage, style: Theme.of(context).textTheme.titleMedium, ), + if (appGateState.isOffline) ...[ + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Offline mode', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + const Text( + 'You can continue working. Sync when you are back online.', + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () async { + final isOnline = + await ref.read(connectivityServiceProvider).isOnline(); + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text( + isOnline + ? 'Sync is not implemented yet.' + : 'Still offline. Try again later.', + ), + ), + ); + }, + child: const Text('Sync now'), + ), + ], + ), + ), + ), + ], const SizedBox(height: 24), Text( 'Developer tools', diff --git a/front001/mosenioring/lib/src/features/offline_lock/data/biometric_authenticator.dart b/front001/mosenioring/lib/src/features/offline_lock/data/biometric_authenticator.dart new file mode 100644 index 0000000..1b70928 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/data/biometric_authenticator.dart @@ -0,0 +1,4 @@ +abstract class BiometricAuthenticator { + Future isAvailable(); + Future authenticate({required String reason}); +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/data/flutter_secure_storage_adapter.dart b/front001/mosenioring/lib/src/features/offline_lock/data/flutter_secure_storage_adapter.dart new file mode 100644 index 0000000..6d9b257 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/data/flutter_secure_storage_adapter.dart @@ -0,0 +1,18 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'key_value_storage.dart'; + +class FlutterSecureStorageAdapter implements KeyValueStorage { + FlutterSecureStorageAdapter(this._storage); + + final FlutterSecureStorage _storage; + + @override + Future delete(String key) => _storage.delete(key: key); + + @override + Future read(String key) => _storage.read(key: key); + + @override + Future write(String key, String value) => _storage.write(key: key, value: value); +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/data/key_value_storage.dart b/front001/mosenioring/lib/src/features/offline_lock/data/key_value_storage.dart new file mode 100644 index 0000000..2024d78 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/data/key_value_storage.dart @@ -0,0 +1,5 @@ +abstract class KeyValueStorage { + Future read(String key); + Future write(String key, String value); + Future delete(String key); +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/data/local_auth_biometric_authenticator.dart b/front001/mosenioring/lib/src/features/offline_lock/data/local_auth_biometric_authenticator.dart new file mode 100644 index 0000000..2ff98d5 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/data/local_auth_biometric_authenticator.dart @@ -0,0 +1,33 @@ +import 'package:local_auth/local_auth.dart'; + +import 'biometric_authenticator.dart'; + +class LocalAuthBiometricAuthenticator implements BiometricAuthenticator { + LocalAuthBiometricAuthenticator(this._localAuth); + + final LocalAuthentication _localAuth; + + @override + Future authenticate({required String reason}) async { + try { + return _localAuth.authenticate( + localizedReason: reason, + biometricOnly: true, + persistAcrossBackgrounding: true, + ); + } catch (_) { + return false; + } + } + + @override + Future isAvailable() async { + try { + final supported = await _localAuth.isDeviceSupported(); + if (!supported) return false; + return _localAuth.canCheckBiometrics; + } catch (_) { + return false; + } + } +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/data/offline_lock_repository_impl.dart b/front001/mosenioring/lib/src/features/offline_lock/data/offline_lock_repository_impl.dart new file mode 100644 index 0000000..de2b09c --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/data/offline_lock_repository_impl.dart @@ -0,0 +1,73 @@ +import '../domain/offline_lock_repository.dart'; +import '../domain/pin_hash.dart'; +import '../domain/pin_hasher.dart'; +import 'biometric_authenticator.dart'; +import 'key_value_storage.dart'; + +class OfflineLockRepositoryImpl implements OfflineLockRepository { + OfflineLockRepositoryImpl({ + required KeyValueStorage storage, + required PinHasher hasher, + required BiometricAuthenticator biometricAuthenticator, + }) : _storage = storage, + _hasher = hasher, + _biometricAuthenticator = biometricAuthenticator; + + final KeyValueStorage _storage; + final PinHasher _hasher; + final BiometricAuthenticator _biometricAuthenticator; + + static const _pinHashKey = 'offline_pin_hash'; + static const _pinSaltKey = 'offline_pin_salt'; + static const _pinIterationsKey = 'offline_pin_iterations'; + + @override + Future canUseBiometrics() => _biometricAuthenticator.isAvailable(); + + @override + Future biometricUnlock() { + return _biometricAuthenticator.authenticate(reason: 'Unlock to continue'); + } + + @override + Future hasPin() async { + final hash = await _storage.read(_pinHashKey); + final salt = await _storage.read(_pinSaltKey); + return hash != null && salt != null && hash.isNotEmpty && salt.isNotEmpty; + } + + @override + Future setPin(String pin) async { + final hashed = await _hasher.hash(pin); + await _storage.write(_pinHashKey, hashed.hashBase64); + await _storage.write(_pinSaltKey, hashed.saltBase64); + await _storage.write(_pinIterationsKey, hashed.iterations.toString()); + } + + @override + Future verifyPin(String pin) async { + final stored = await _readPinHash(); + if (stored == null) { + return false; + } + return _hasher.verify(pin: pin, stored: stored); + } + + Future _readPinHash() async { + final hash = await _storage.read(_pinHashKey); + final salt = await _storage.read(_pinSaltKey); + final iterationsRaw = await _storage.read(_pinIterationsKey); + if (hash == null || salt == null || iterationsRaw == null) { + return null; + } + final iterations = int.tryParse(iterationsRaw); + if (iterations == null) { + return null; + } + return PinHash( + saltBase64: salt, + hashBase64: hash, + iterations: iterations, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/data/pbkdf2_pin_hasher.dart b/front001/mosenioring/lib/src/features/offline_lock/data/pbkdf2_pin_hasher.dart new file mode 100644 index 0000000..377fbe9 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/data/pbkdf2_pin_hasher.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; + +import '../domain/pin_hash.dart'; +import '../domain/pin_hasher.dart'; + +class Pbkdf2PinHasher implements PinHasher { + Pbkdf2PinHasher({ + this.iterations = 100000, + this.saltLength = 16, + }); + + final int iterations; + final int saltLength; + final Random _random = Random.secure(); + + @override + Future hash(String pin) async { + final salt = List.generate(saltLength, (_) => _random.nextInt(256)); + final hash = await _deriveHash(pin, salt, iterations); + return PinHash( + saltBase64: base64UrlEncode(salt), + hashBase64: base64UrlEncode(hash), + iterations: iterations, + ); + } + + @override + Future verify({ + required String pin, + required PinHash stored, + }) async { + final salt = base64Url.decode(stored.saltBase64); + final derived = await _deriveHash(pin, salt, stored.iterations); + final storedHash = base64Url.decode(stored.hashBase64); + return _constantTimeEquals(derived, storedHash); + } + + Future> _deriveHash(String pin, List salt, int iterations) async { + final pbkdf2 = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: iterations, + bits: 256, + ); + final key = await pbkdf2.deriveKey( + secretKey: SecretKey(utf8.encode(pin)), + nonce: salt, + ); + return (await key.extractBytes()); + } + + bool _constantTimeEquals(List a, List b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/domain/offline_lock_repository.dart b/front001/mosenioring/lib/src/features/offline_lock/domain/offline_lock_repository.dart new file mode 100644 index 0000000..233fd65 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/domain/offline_lock_repository.dart @@ -0,0 +1,8 @@ +abstract class OfflineLockRepository { + Future canUseBiometrics(); + Future biometricUnlock(); + + Future hasPin(); + Future setPin(String pin); + Future verifyPin(String pin); +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/domain/pin_hash.dart b/front001/mosenioring/lib/src/features/offline_lock/domain/pin_hash.dart new file mode 100644 index 0000000..349bb35 --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/domain/pin_hash.dart @@ -0,0 +1,11 @@ +class PinHash { + const PinHash({ + required this.saltBase64, + required this.hashBase64, + required this.iterations, + }); + + final String saltBase64; + final String hashBase64; + final int iterations; +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/domain/pin_hasher.dart b/front001/mosenioring/lib/src/features/offline_lock/domain/pin_hasher.dart new file mode 100644 index 0000000..9e91e1b --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/domain/pin_hasher.dart @@ -0,0 +1,9 @@ +import 'pin_hash.dart'; + +abstract class PinHasher { + Future hash(String pin); + Future verify({ + required String pin, + required PinHash stored, + }); +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_controller.dart b/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_controller.dart new file mode 100644 index 0000000..c11a75b --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_controller.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../di/providers.dart'; +import '../../app_gate/presentation/app_gate_controller.dart'; +import '../domain/offline_lock_repository.dart'; +import 'unlock_state.dart'; + +class UnlockController extends Notifier { + @override + UnlockState build() { + Future.microtask(_bootstrap); + return const UnlockState(isLoading: true); + } + + OfflineLockRepository get _repository => ref.read(offlineLockRepositoryProvider); + AppGateController get _appGateController => + ref.read(appGateControllerProvider.notifier); + + Future _bootstrap() async { + final biometricsAvailable = await _repository.canUseBiometrics(); + final hasPin = await _repository.hasPin(); + if (!ref.mounted) return; + state = state.copyWith( + isLoading: false, + biometricsAvailable: biometricsAvailable, + hasPin: hasPin, + showPinEntry: !biometricsAvailable, + ); + } + + void showPinEntry() { + state = state.copyWith(showPinEntry: true, errorMessage: null); + } + + Future unlockWithBiometrics() async { + state = state.copyWith(isLoading: true, errorMessage: null); + final success = await _repository.biometricUnlock(); + if (!ref.mounted) return; + if (success) { + state = state.copyWith(isLoading: false, isUnlocked: true); + _appGateController.setOfflineUnlocked(); + return; + } + state = state.copyWith(isLoading: false, errorMessage: 'Biometric unlock failed'); + } + + Future submitPin(String pin) async { + final normalized = pin.trim(); + if (normalized.length < 4 || normalized.length > 6) { + state = state.copyWith( + isLoading: false, + errorMessage: 'PIN must be 4-6 digits', + ); + return; + } + + state = state.copyWith(isLoading: true, errorMessage: null); + final hasPin = await _repository.hasPin(); + if (!ref.mounted) return; + + if (!hasPin) { + await _repository.setPin(normalized); + if (!ref.mounted) return; + state = state.copyWith(isLoading: false, hasPin: true, isUnlocked: true); + _appGateController.setOfflineUnlocked(); + return; + } + + final success = await _repository.verifyPin(normalized); + if (!ref.mounted) return; + if (success) { + state = state.copyWith(isLoading: false, isUnlocked: true); + _appGateController.setOfflineUnlocked(); + return; + } + state = state.copyWith(isLoading: false, errorMessage: 'Invalid PIN'); + } +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_screen.dart b/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_screen.dart new file mode 100644 index 0000000..4bd10ee --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_screen.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../di/providers.dart'; + +class UnlockScreen extends ConsumerStatefulWidget { + const UnlockScreen({super.key}); + + @override + ConsumerState createState() => _UnlockScreenState(); +} + +class _UnlockScreenState extends ConsumerState { + final TextEditingController _pinController = TextEditingController(); + + @override + void dispose() { + _pinController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(unlockControllerProvider); + final controller = ref.read(unlockControllerProvider.notifier); + + return Scaffold( + appBar: AppBar(title: const Text('Unlock')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock, size: 64), + const SizedBox(height: 16), + const Text( + 'Unlock to continue', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + if (state.biometricsAvailable && !state.showPinEntry) ...[ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: state.isLoading ? null : controller.unlockWithBiometrics, + icon: const Icon(Icons.fingerprint), + label: state.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Use biometrics'), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: state.isLoading ? null : controller.showPinEntry, + child: const Text('Use PIN instead'), + ), + ], + if (state.showPinEntry || !state.biometricsAvailable) ...[ + TextField( + controller: _pinController, + keyboardType: TextInputType.number, + obscureText: true, + maxLength: 6, + decoration: InputDecoration( + labelText: state.hasPin ? 'Enter PIN' : 'Create a PIN', + counterText: '', + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: state.isLoading + ? null + : () { + controller.submitPin(_pinController.text); + }, + child: state.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(state.hasPin ? 'Unlock' : 'Set PIN'), + ), + ), + ], + if (state.errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + state.errorMessage!, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_state.dart b/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_state.dart new file mode 100644 index 0000000..25fa82b --- /dev/null +++ b/front001/mosenioring/lib/src/features/offline_lock/presentation/unlock_state.dart @@ -0,0 +1,35 @@ +class UnlockState { + const UnlockState({ + this.isLoading = false, + this.isUnlocked = false, + this.errorMessage, + this.biometricsAvailable = false, + this.showPinEntry = false, + this.hasPin = false, + }); + + final bool isLoading; + final bool isUnlocked; + final String? errorMessage; + final bool biometricsAvailable; + final bool showPinEntry; + final bool hasPin; + + UnlockState copyWith({ + bool? isLoading, + bool? isUnlocked, + String? errorMessage, + bool? biometricsAvailable, + bool? showPinEntry, + bool? hasPin, + }) { + return UnlockState( + isLoading: isLoading ?? this.isLoading, + isUnlocked: isUnlocked ?? this.isUnlocked, + errorMessage: errorMessage, + biometricsAvailable: biometricsAvailable ?? this.biometricsAvailable, + showPinEntry: showPinEntry ?? this.showPinEntry, + hasPin: hasPin ?? this.hasPin, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/session/data/session_repository_impl.dart b/front001/mosenioring/lib/src/features/session/data/session_repository_impl.dart new file mode 100644 index 0000000..204b6b1 --- /dev/null +++ b/front001/mosenioring/lib/src/features/session/data/session_repository_impl.dart @@ -0,0 +1,27 @@ +import '../../auth/data/auth_local_data_source.dart'; +import '../../auth/domain/models/auth_token.dart'; +import '../domain/models/session_marker.dart'; +import '../domain/session_repository.dart'; + +class SessionRepositoryImpl implements SessionRepository { + SessionRepositoryImpl(this._localDataSource); + + final AuthLocalDataSource _localDataSource; + + @override + Future clear() => _localDataSource.clear(); + + @override + Future readSessionMarker() => _localDataSource.readSessionMarker(); + + @override + Future readToken() => _localDataSource.readToken(); + + @override + Future saveSessionMarker(SessionMarker marker) { + return _localDataSource.saveSessionMarker(marker); + } + + @override + Future saveToken(AuthToken token) => _localDataSource.saveToken(token); +} diff --git a/front001/mosenioring/lib/src/features/session/domain/models/session_marker.dart b/front001/mosenioring/lib/src/features/session/domain/models/session_marker.dart new file mode 100644 index 0000000..a77b7e4 --- /dev/null +++ b/front001/mosenioring/lib/src/features/session/domain/models/session_marker.dart @@ -0,0 +1,9 @@ +class SessionMarker { + const SessionMarker({ + required this.userId, + required this.lastOnlineAt, + }); + + final String userId; + final DateTime lastOnlineAt; +} diff --git a/front001/mosenioring/lib/src/features/session/domain/session_repository.dart b/front001/mosenioring/lib/src/features/session/domain/session_repository.dart new file mode 100644 index 0000000..c2b07bd --- /dev/null +++ b/front001/mosenioring/lib/src/features/session/domain/session_repository.dart @@ -0,0 +1,11 @@ +import '../../auth/domain/models/auth_token.dart'; +import 'models/session_marker.dart'; + +abstract class SessionRepository { + Future readToken(); + Future saveToken(AuthToken token); + Future clear(); + + Future readSessionMarker(); + Future saveSessionMarker(SessionMarker marker); +} diff --git a/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift b/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift index d836919..25b674d 100644 --- a/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,16 @@ import FlutterMacOS import Foundation +import connectivity_plus import flutter_appauth import flutter_secure_storage_darwin +import local_auth_darwin import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/front001/mosenioring/pubspec.yaml b/front001/mosenioring/pubspec.yaml index 1cff5b0..68933d5 100644 --- a/front001/mosenioring/pubspec.yaml +++ b/front001/mosenioring/pubspec.yaml @@ -36,12 +36,15 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + connectivity_plus: ^7.0.0 + cryptography: ^2.7.0 dio: ^5.7.0 flutter_appauth: ^11.0.0 flutter_riverpod: ^3.1.0 flutter_secure_storage: ^10.0.0 go_router: ^17.0.1 intl: ^0.20.2 + local_auth: ^3.0.0 dev_dependencies: flutter_test: @@ -53,7 +56,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_launcher_icons: ^0.14.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/front001/mosenioring/test/features/app_gate/app_gate_controller_test.dart b/front001/mosenioring/test/features/app_gate/app_gate_controller_test.dart new file mode 100644 index 0000000..2e82b78 --- /dev/null +++ b/front001/mosenioring/test/features/app_gate/app_gate_controller_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/core/network/connectivity_service.dart'; +import 'package:mosenioring/src/di/providers.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart'; +import 'package:mosenioring/src/features/auth/domain/auth_repository.dart'; +import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart'; +import 'package:mosenioring/src/features/session/domain/models/session_marker.dart'; +import 'package:mosenioring/src/features/session/domain/session_repository.dart'; + +class FakeSessionRepository implements SessionRepository { + AuthToken? token; + SessionMarker? marker; + + @override + Future clear() async { + token = null; + marker = null; + } + + @override + Future readSessionMarker() async => marker; + + @override + Future readToken() async => token; + + @override + Future saveSessionMarker(SessionMarker marker) async { + this.marker = marker; + } + + @override + Future saveToken(AuthToken token) async { + this.token = token; + } +} + +class FakeConnectivityService implements ConnectivityService { + FakeConnectivityService(this._online); + + bool _online; + + set online(bool value) => _online = value; + + @override + Future isOnline() async => _online; +} + +class FakeAuthRepository implements AuthRepository { + AuthToken? token; + + @override + Future getSavedToken() async => token; + + @override + Future login({required String email, required String password}) async { + return const AuthToken(accessToken: 'token'); + } + + @override + Future logout() async {} + + @override + Future persistToken(AuthToken token) async { + this.token = token; + } + + @override + Future refreshToken({required String refreshToken}) async { + return const AuthToken(accessToken: 'new_token', refreshToken: 'refresh'); + } +} + +void main() { + test('no session routes to login', () async { + final sessionRepository = FakeSessionRepository(); + final connectivityService = FakeConnectivityService(true); + final authRepository = FakeAuthRepository(); + + final container = ProviderContainer( + overrides: [ + sessionRepositoryProvider.overrideWithValue(sessionRepository), + connectivityServiceProvider.overrideWithValue(connectivityService), + authRepositoryProvider.overrideWithValue(authRepository), + ], + ); + addTearDown(container.dispose); + + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + if (!container.read(appGateControllerProvider).isLoading) break; + } + final state = container.read(appGateControllerProvider); + + expect(state.destination, AppGateDestination.login); + }); + + test('session + offline routes to unlock', () async { + final sessionRepository = FakeSessionRepository() + ..marker = SessionMarker(userId: 'user', lastOnlineAt: DateTime.now()); + final connectivityService = FakeConnectivityService(false); + final authRepository = FakeAuthRepository(); + + final container = ProviderContainer( + overrides: [ + sessionRepositoryProvider.overrideWithValue(sessionRepository), + connectivityServiceProvider.overrideWithValue(connectivityService), + authRepositoryProvider.overrideWithValue(authRepository), + ], + ); + addTearDown(container.dispose); + + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + if (!container.read(appGateControllerProvider).isLoading) break; + } + final state = container.read(appGateControllerProvider); + + expect(state.destination, AppGateDestination.unlock); + expect(state.isOffline, isTrue); + }); + + test('session + online routes to home', () async { + final sessionRepository = FakeSessionRepository() + ..marker = SessionMarker(userId: 'user', lastOnlineAt: DateTime.now()) + ..token = const AuthToken(accessToken: 'token', refreshToken: 'refresh'); + final connectivityService = FakeConnectivityService(true); + final authRepository = FakeAuthRepository(); + + final container = ProviderContainer( + overrides: [ + sessionRepositoryProvider.overrideWithValue(sessionRepository), + connectivityServiceProvider.overrideWithValue(connectivityService), + authRepositoryProvider.overrideWithValue(authRepository), + ], + ); + addTearDown(container.dispose); + + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + if (!container.read(appGateControllerProvider).isLoading) break; + } + final state = container.read(appGateControllerProvider); + + expect(state.destination, AppGateDestination.home); + expect(state.isOffline, isFalse); + }); +} diff --git a/front001/mosenioring/test/features/auth/auth_controller_test.dart b/front001/mosenioring/test/features/auth/auth_controller_test.dart index 60265d5..d6ef84f 100644 --- a/front001/mosenioring/test/features/auth/auth_controller_test.dart +++ b/front001/mosenioring/test/features/auth/auth_controller_test.dart @@ -5,6 +5,10 @@ import 'package:mosenioring/src/features/auth/domain/auth_repository.dart'; import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart'; import 'package:mosenioring/src/features/auth/data/auth_local_data_source.dart'; import 'package:mosenioring/src/core/config/app_config.dart'; +import 'package:mosenioring/src/features/session/domain/models/session_marker.dart'; +import 'package:mosenioring/src/features/session/domain/session_repository.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart'; class MockAuthRepository implements AuthRepository { AuthToken? token; @@ -20,6 +24,11 @@ class MockAuthRepository implements AuthRepository { return const AuthToken(accessToken: 'new_token'); } + @override + Future refreshToken({required String refreshToken}) async { + return const AuthToken(accessToken: 'refreshed_token'); + } + @override Future logout() async { logoutCalled = true; @@ -34,11 +43,13 @@ class MockAuthRepository implements AuthRepository { class MockAuthLocalDataSource implements AuthLocalDataSource { LocalAuthSession? session; AuthToken? token; + SessionMarker? marker; @override Future clear() async { session = null; token = null; + marker = null; } @override @@ -56,18 +67,62 @@ class MockAuthLocalDataSource implements AuthLocalDataSource { Future saveToken(AuthToken token) async { this.token = token; } + + @override + Future readSessionMarker() async => marker; + + @override + Future saveSessionMarker(SessionMarker marker) async { + this.marker = marker; + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class FakeSessionRepository implements SessionRepository { + AuthToken? token; + SessionMarker? marker; + + @override + Future clear() async { + token = null; + marker = null; + } + + @override + Future readSessionMarker() async => marker; + + @override + Future readToken() async => token; + + @override + Future saveSessionMarker(SessionMarker marker) async { + this.marker = marker; + } + + @override + Future saveToken(AuthToken token) async { + this.token = token; + } +} + +class FakeAppGateController extends AppGateController { + @override + AppGateState build() => const AppGateState(); +} + void main() { group('AuthController', () { late MockAuthRepository mockRepository; + late FakeSessionRepository fakeSessionRepository; + late FakeAppGateController fakeAppGateController; late AppConfig testConfig; setUp(() { mockRepository = MockAuthRepository(); + fakeSessionRepository = FakeSessionRepository(); + fakeAppGateController = FakeAppGateController(); testConfig = const AppConfig( apiBaseUrl: 'https://api.example.com', useLocalAuth: false, @@ -84,6 +139,10 @@ void main() { overrides: [ authRepositoryProvider.overrideWithValue(mockRepository), appConfigProvider.overrideWithValue(config ?? testConfig), + sessionRepositoryProvider.overrideWithValue(fakeSessionRepository), + appGateControllerProvider.overrideWith( + () => fakeAppGateController, + ), ], ); addTearDown(container.dispose); diff --git a/front001/mosenioring/test/features/offline_lock/offline_lock_repository_test.dart b/front001/mosenioring/test/features/offline_lock/offline_lock_repository_test.dart new file mode 100644 index 0000000..1da895d --- /dev/null +++ b/front001/mosenioring/test/features/offline_lock/offline_lock_repository_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/features/offline_lock/data/biometric_authenticator.dart'; +import 'package:mosenioring/src/features/offline_lock/data/offline_lock_repository_impl.dart'; +import 'package:mosenioring/src/features/offline_lock/data/key_value_storage.dart'; +import 'package:mosenioring/src/features/offline_lock/domain/pin_hash.dart'; +import 'package:mosenioring/src/features/offline_lock/domain/pin_hasher.dart'; + +class InMemoryStorage implements KeyValueStorage { + final Map _data = {}; + + @override + Future delete(String key) async { + _data.remove(key); + } + + @override + Future read(String key) async => _data[key]; + + @override + Future write(String key, String value) async { + _data[key] = value; + } +} + +class FakeBiometricAuthenticator implements BiometricAuthenticator { + @override + Future authenticate({required String reason}) async => true; + + @override + Future isAvailable() async => false; +} + +class DeterministicPinHasher implements PinHasher { + @override + Future hash(String pin) async { + return PinHash( + saltBase64: base64UrlEncode(utf8.encode('salt')), + hashBase64: base64UrlEncode(utf8.encode('hash:$pin')), + iterations: 1, + ); + } + + @override + Future verify({required String pin, required PinHash stored}) async { + final expected = base64UrlEncode(utf8.encode('hash:$pin')); + return stored.hashBase64 == expected; + } +} + +void main() { + test('verifyPin returns true for correct pin and false for incorrect pin', () async { + final storage = InMemoryStorage(); + final repository = OfflineLockRepositoryImpl( + storage: storage, + hasher: DeterministicPinHasher(), + biometricAuthenticator: FakeBiometricAuthenticator(), + ); + + await repository.setPin('1234'); + + expect(await repository.verifyPin('1234'), isTrue); + expect(await repository.verifyPin('9876'), isFalse); + }); +} diff --git a/front001/mosenioring/test/features/offline_lock/unlock_controller_test.dart b/front001/mosenioring/test/features/offline_lock/unlock_controller_test.dart new file mode 100644 index 0000000..d94137a --- /dev/null +++ b/front001/mosenioring/test/features/offline_lock/unlock_controller_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/di/providers.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart'; +import 'package:mosenioring/src/features/offline_lock/domain/offline_lock_repository.dart'; +import 'package:mosenioring/src/features/offline_lock/presentation/unlock_state.dart'; + +class FakeOfflineLockRepository implements OfflineLockRepository { + bool biometricsAvailable = false; + bool pinSet = true; + bool biometricResult = true; + bool verifyResult = true; + + @override + Future biometricUnlock() async => biometricResult; + + @override + Future canUseBiometrics() async => biometricsAvailable; + + @override + Future hasPin() async => pinSet; + + @override + Future setPin(String pin) async { + pinSet = true; + } + + @override + Future verifyPin(String pin) async => verifyResult; +} + +class FakeAppGateController extends AppGateController { + @override + AppGateState build() => const AppGateState(); +} + +void main() { + test('submitPin transitions idle -> loading -> success', () async { + final repository = FakeOfflineLockRepository() + ..biometricsAvailable = false + ..pinSet = true + ..verifyResult = true; + final appGateController = FakeAppGateController(); + final states = []; + + final container = ProviderContainer( + overrides: [ + offlineLockRepositoryProvider.overrideWithValue(repository), + appGateControllerProvider.overrideWith(() => appGateController), + ], + ); + addTearDown(container.dispose); + + container.listen(unlockControllerProvider, (_, next) => states.add(next)); + await Future.delayed(Duration.zero); + + await container.read(unlockControllerProvider.notifier).submitPin('1234'); + + expect(states.any((state) => state.isLoading), isTrue); + expect(states.last.isUnlocked, isTrue); + expect(states.last.errorMessage, isNull); + }); + + test('submitPin transitions idle -> loading -> failure', () async { + final repository = FakeOfflineLockRepository() + ..biometricsAvailable = false + ..pinSet = true + ..verifyResult = false; + final appGateController = FakeAppGateController(); + final states = []; + + final container = ProviderContainer( + overrides: [ + offlineLockRepositoryProvider.overrideWithValue(repository), + appGateControllerProvider.overrideWith(() => appGateController), + ], + ); + addTearDown(container.dispose); + + container.listen(unlockControllerProvider, (_, next) => states.add(next)); + await Future.delayed(Duration.zero); + + await container.read(unlockControllerProvider.notifier).submitPin('1234'); + + expect(states.any((state) => state.isLoading), isTrue); + expect(states.last.isUnlocked, isFalse); + expect(states.last.errorMessage, 'Invalid PIN'); + }); +} diff --git a/front001/mosenioring/test/widget_test.dart b/front001/mosenioring/test/widget_test.dart index da8cd78..1a52e91 100644 --- a/front001/mosenioring/test/widget_test.dart +++ b/front001/mosenioring/test/widget_test.dart @@ -9,11 +9,85 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mosenioring/src/app/app.dart'; +import 'package:mosenioring/src/core/config/app_config.dart'; +import 'package:mosenioring/src/di/providers.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart'; +import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart'; +import 'package:mosenioring/src/features/auth/domain/auth_repository.dart'; +import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart'; +import 'package:mosenioring/src/features/session/domain/session_repository.dart'; +import 'package:mosenioring/src/features/session/domain/models/session_marker.dart'; + +class FakeAuthRepository implements AuthRepository { + @override + Future getSavedToken() async => null; + + @override + Future login({required String email, required String password}) async { + return const AuthToken(accessToken: 'token'); + } + + @override + Future logout() async {} + + @override + Future persistToken(AuthToken token) async {} + + @override + Future refreshToken({required String refreshToken}) async { + return const AuthToken(accessToken: 'token'); + } +} + +class FakeSessionRepository implements SessionRepository { + @override + Future clear() async {} + + @override + Future readSessionMarker() async => null; + + @override + Future readToken() async => null; + + @override + Future saveSessionMarker(SessionMarker marker) async {} + + @override + Future saveToken(AuthToken token) async {} +} + +class FakeAppGateController extends AppGateController { + @override + AppGateState build() => const AppGateState( + isLoading: false, + destination: AppGateDestination.login, + ); +} void main() { testWidgets('App smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const ProviderScope(child: App())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + authRepositoryProvider.overrideWithValue(FakeAuthRepository()), + sessionRepositoryProvider.overrideWithValue(FakeSessionRepository()), + appConfigProvider.overrideWithValue( + const AppConfig( + apiBaseUrl: 'https://api.example.com', + useLocalAuth: false, + localTenantId: '123', + localRoles: 'USER', + keycloakIssuer: 'https://keycloak.com', + keycloakClientId: 'client', + keycloakRedirectUrl: 'app://callback', + ), + ), + appGateControllerProvider.overrideWith(() => FakeAppGateController()), + ], + child: const App(), + ), + ); await tester.pump(); // Start _bootstrap await tester.pump(const Duration(milliseconds: 100)); // Allow some time diff --git a/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc b/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc index 0c50753..d7240f1 100644 --- a/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc +++ b/front001/mosenioring/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); } diff --git a/front001/mosenioring/windows/flutter/generated_plugins.cmake b/front001/mosenioring/windows/flutter/generated_plugins.cmake index 4fc759c..9b83ab5 100644 --- a/front001/mosenioring/windows/flutter/generated_plugins.cmake +++ b/front001/mosenioring/windows/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus flutter_secure_storage_windows + local_auth_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST