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`.
This commit is contained in:
parent
d096fc479d
commit
1315b95a72
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="mosenioring"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart';
|
|||
import '../di/providers.dart';
|
||||
import '../features/auth/presentation/login_page.dart';
|
||||
import '../features/home/presentation/home_page.dart';
|
||||
import '../features/offline_lock/presentation/unlock_screen.dart';
|
||||
import '../features/app_gate/presentation/app_gate_state.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final appGateState = ref.watch(appGateControllerProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
|
|
@ -19,15 +21,26 @@ final appRouterProvider = Provider<GoRouter>((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;
|
||||
|
|
|
|||
|
|
@ -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<bool> isOnline() async {
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
if (result.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !result.contains(ConnectivityResult.none);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
abstract class ConnectivityService {
|
||||
Future<bool> isOnline();
|
||||
}
|
||||
23
front001/mosenioring/lib/src/core/security/jwt_utils.dart
Normal file
23
front001/mosenioring/lib/src/core/security/jwt_utils.dart
Normal file
|
|
@ -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<String, dynamic>) {
|
||||
final sub = data['sub'];
|
||||
if (sub is String && sub.isNotEmpty) {
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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<FlutterSecureStorage>((ref) {
|
|||
return const FlutterSecureStorage();
|
||||
});
|
||||
|
||||
final connectivityProvider = Provider<Connectivity>((ref) {
|
||||
return Connectivity();
|
||||
});
|
||||
|
||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||
return ConnectivityPlusService(ref.watch(connectivityProvider));
|
||||
});
|
||||
|
||||
final localAuthProvider = Provider<LocalAuthentication>((ref) {
|
||||
return LocalAuthentication();
|
||||
});
|
||||
|
||||
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
|
||||
return AuthLocalDataSource(ref.watch(secureStorageProvider));
|
||||
});
|
||||
|
|
@ -118,10 +147,42 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
|
||||
return SessionRepositoryImpl(ref.watch(authLocalDataSourceProvider));
|
||||
});
|
||||
|
||||
final authControllerProvider = NotifierProvider<AuthController, AuthState>(() {
|
||||
return AuthController();
|
||||
});
|
||||
|
||||
final appGateControllerProvider = NotifierProvider<AppGateController, AppGateState>(() {
|
||||
return AppGateController();
|
||||
});
|
||||
|
||||
final offlineLockStorageProvider = Provider<FlutterSecureStorageAdapter>((ref) {
|
||||
return FlutterSecureStorageAdapter(ref.watch(secureStorageProvider));
|
||||
});
|
||||
|
||||
final pinHasherProvider = Provider<PinHasher>((ref) {
|
||||
return Pbkdf2PinHasher();
|
||||
});
|
||||
|
||||
final biometricAuthenticatorProvider = Provider<BiometricAuthenticator>((ref) {
|
||||
return LocalAuthBiometricAuthenticator(ref.watch(localAuthProvider));
|
||||
});
|
||||
|
||||
final offlineLockRepositoryProvider = Provider<OfflineLockRepository>((ref) {
|
||||
return OfflineLockRepositoryImpl(
|
||||
storage: ref.watch(offlineLockStorageProvider),
|
||||
hasher: ref.watch(pinHasherProvider),
|
||||
biometricAuthenticator: ref.watch(biometricAuthenticatorProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final unlockControllerProvider = NotifierProvider<UnlockController, UnlockState>(() {
|
||||
return UnlockController();
|
||||
});
|
||||
|
||||
final httpClientProvider = Provider<HttpClient>((ref) {
|
||||
return ApiHttpClient(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AppGateState> {
|
||||
@override
|
||||
AppGateState build() {
|
||||
Future.microtask(_bootstrap);
|
||||
return const AppGateState(isLoading: true);
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AuthToken?> readToken() async {
|
||||
final accessToken = await _storage.read(key: _accessTokenKey);
|
||||
|
|
@ -24,8 +27,11 @@ class AuthLocalDataSource {
|
|||
|
||||
Future<void> 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<void> saveSessionMarker(SessionMarker marker) async {
|
||||
await _storage.write(key: _sessionUserIdKey, value: marker.userId);
|
||||
await _storage.write(
|
||||
key: _sessionLastOnlineKey,
|
||||
value: marker.lastOnlineAt.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SessionMarker?> 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 {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||
return token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
final token = await _remote.refreshToken(refreshToken: refreshToken);
|
||||
await _local.saveToken(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken?> getSavedToken() {
|
||||
return _local.readToken();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ abstract class AuthRepository {
|
|||
required String password,
|
||||
});
|
||||
|
||||
Future<AuthToken> refreshToken({required String refreshToken});
|
||||
|
||||
Future<AuthToken?> getSavedToken();
|
||||
|
||||
Future<void> persistToken(AuthToken token);
|
||||
|
|
|
|||
|
|
@ -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<AuthState> {
|
||||
|
|
@ -15,6 +19,9 @@ class AuthController extends Notifier<AuthState> {
|
|||
|
||||
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<void> _bootstrap() async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
|
@ -59,11 +66,22 @@ class AuthController extends Notifier<AuthState> {
|
|||
);
|
||||
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<AuthState> {
|
|||
Future<void> logout() async {
|
||||
await _repository.logout();
|
||||
state = const AuthState();
|
||||
_appGateController.resetToLogin();
|
||||
}
|
||||
|
||||
String _friendlyError(Object error) {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||
|
||||
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<HomePage> {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
abstract class BiometricAuthenticator {
|
||||
Future<bool> isAvailable();
|
||||
Future<bool> authenticate({required String reason});
|
||||
}
|
||||
|
|
@ -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<void> delete(String key) => _storage.delete(key: key);
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) => _storage.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) => _storage.write(key: key, value: value);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
abstract class KeyValueStorage {
|
||||
Future<String?> read(String key);
|
||||
Future<void> write(String key, String value);
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
|
|
@ -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<bool> authenticate({required String reason}) async {
|
||||
try {
|
||||
return _localAuth.authenticate(
|
||||
localizedReason: reason,
|
||||
biometricOnly: true,
|
||||
persistAcrossBackgrounding: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async {
|
||||
try {
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
if (!supported) return false;
|
||||
return _localAuth.canCheckBiometrics;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> canUseBiometrics() => _biometricAuthenticator.isAvailable();
|
||||
|
||||
@override
|
||||
Future<bool> biometricUnlock() {
|
||||
return _biometricAuthenticator.authenticate(reason: 'Unlock to continue');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> 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<bool> verifyPin(String pin) async {
|
||||
final stored = await _readPinHash();
|
||||
if (stored == null) {
|
||||
return false;
|
||||
}
|
||||
return _hasher.verify(pin: pin, stored: stored);
|
||||
}
|
||||
|
||||
Future<PinHash?> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PinHash> hash(String pin) async {
|
||||
final salt = List<int>.generate(saltLength, (_) => _random.nextInt(256));
|
||||
final hash = await _deriveHash(pin, salt, iterations);
|
||||
return PinHash(
|
||||
saltBase64: base64UrlEncode(salt),
|
||||
hashBase64: base64UrlEncode(hash),
|
||||
iterations: iterations,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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<List<int>> _deriveHash(String pin, List<int> 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<int> a, List<int> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
abstract class OfflineLockRepository {
|
||||
Future<bool> canUseBiometrics();
|
||||
Future<bool> biometricUnlock();
|
||||
|
||||
Future<bool> hasPin();
|
||||
Future<void> setPin(String pin);
|
||||
Future<bool> verifyPin(String pin);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import 'pin_hash.dart';
|
||||
|
||||
abstract class PinHasher {
|
||||
Future<PinHash> hash(String pin);
|
||||
Future<bool> verify({
|
||||
required String pin,
|
||||
required PinHash stored,
|
||||
});
|
||||
}
|
||||
|
|
@ -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<UnlockState> {
|
||||
@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<void> _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<void> 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<void> 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UnlockScreen> createState() => _UnlockScreenState();
|
||||
}
|
||||
|
||||
class _UnlockScreenState extends ConsumerState<UnlockScreen> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> clear() => _localDataSource.clear();
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() => _localDataSource.readSessionMarker();
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() => _localDataSource.readToken();
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) {
|
||||
return _localDataSource.saveSessionMarker(marker);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) => _localDataSource.saveToken(token);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
class SessionMarker {
|
||||
const SessionMarker({
|
||||
required this.userId,
|
||||
required this.lastOnlineAt,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final DateTime lastOnlineAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import '../../auth/domain/models/auth_token.dart';
|
||||
import 'models/session_marker.dart';
|
||||
|
||||
abstract class SessionRepository {
|
||||
Future<AuthToken?> readToken();
|
||||
Future<void> saveToken(AuthToken token);
|
||||
Future<void> clear();
|
||||
|
||||
Future<SessionMarker?> readSessionMarker();
|
||||
Future<void> saveSessionMarker(SessionMarker marker);
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> clear() async {
|
||||
token = null;
|
||||
marker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => token;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||
this.marker = marker;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeConnectivityService implements ConnectivityService {
|
||||
FakeConnectivityService(this._online);
|
||||
|
||||
bool _online;
|
||||
|
||||
set online(bool value) => _online = value;
|
||||
|
||||
@override
|
||||
Future<bool> isOnline() async => _online;
|
||||
}
|
||||
|
||||
class FakeAuthRepository implements AuthRepository {
|
||||
AuthToken? token;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> getSavedToken() async => token;
|
||||
|
||||
@override
|
||||
Future<AuthToken> login({required String email, required String password}) async {
|
||||
return const AuthToken(accessToken: 'token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {}
|
||||
|
||||
@override
|
||||
Future<void> persistToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken> 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
return const AuthToken(accessToken: 'refreshed_token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
logoutCalled = true;
|
||||
|
|
@ -34,11 +43,13 @@ class MockAuthRepository implements AuthRepository {
|
|||
class MockAuthLocalDataSource implements AuthLocalDataSource {
|
||||
LocalAuthSession? session;
|
||||
AuthToken? token;
|
||||
SessionMarker? marker;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
session = null;
|
||||
token = null;
|
||||
marker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -56,18 +67,62 @@ class MockAuthLocalDataSource implements AuthLocalDataSource {
|
|||
Future<void> saveToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> clear() async {
|
||||
token = null;
|
||||
marker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => token;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||
this.marker = marker;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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);
|
||||
|
|
|
|||
|
|
@ -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<String, String> _data = {};
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async {
|
||||
_data.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async => _data[key];
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
_data[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBiometricAuthenticator implements BiometricAuthenticator {
|
||||
@override
|
||||
Future<bool> authenticate({required String reason}) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async => false;
|
||||
}
|
||||
|
||||
class DeterministicPinHasher implements PinHasher {
|
||||
@override
|
||||
Future<PinHash> hash(String pin) async {
|
||||
return PinHash(
|
||||
saltBase64: base64UrlEncode(utf8.encode('salt')),
|
||||
hashBase64: base64UrlEncode(utf8.encode('hash:$pin')),
|
||||
iterations: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<bool> biometricUnlock() async => biometricResult;
|
||||
|
||||
@override
|
||||
Future<bool> canUseBiometrics() async => biometricsAvailable;
|
||||
|
||||
@override
|
||||
Future<bool> hasPin() async => pinSet;
|
||||
|
||||
@override
|
||||
Future<void> setPin(String pin) async {
|
||||
pinSet = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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 = <UnlockState>[];
|
||||
|
||||
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 = <UnlockState>[];
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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<AuthToken?> getSavedToken() async => null;
|
||||
|
||||
@override
|
||||
Future<AuthToken> login({required String email, required String password}) async {
|
||||
return const AuthToken(accessToken: 'token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {}
|
||||
|
||||
@override
|
||||
Future<void> persistToken(AuthToken token) async {}
|
||||
|
||||
@override
|
||||
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
return const AuthToken(accessToken: 'token');
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSessionRepository implements SessionRepository {
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => null;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => null;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {}
|
||||
|
||||
@override
|
||||
Future<void> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Reference in a new issue