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).
|
2. Generate a Dart client (OpenAPI Generator or Swagger Codegen).
|
||||||
3. Swap `AuthRemoteDataSource` or `TelemetryRemoteDataSource` to call the generated client.
|
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
|
## Running
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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
|
<application
|
||||||
android:label="mosenioring"
|
android:label="mosenioring"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../di/providers.dart';
|
import '../di/providers.dart';
|
||||||
import '../features/auth/presentation/login_page.dart';
|
import '../features/auth/presentation/login_page.dart';
|
||||||
import '../features/home/presentation/home_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 appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authControllerProvider);
|
final appGateState = ref.watch(appGateControllerProvider);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/login',
|
initialLocation: '/login',
|
||||||
|
|
@ -19,15 +21,26 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
path: '/',
|
path: '/',
|
||||||
builder: (context, state) => const HomePage(),
|
builder: (context, state) => const HomePage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/unlock',
|
||||||
|
builder: (context, state) => const UnlockScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final isLoggedIn = authState.isAuthenticated;
|
if (appGateState.isLoading) {
|
||||||
final isLoggingIn = state.matchedLocation == '/login';
|
return null;
|
||||||
|
}
|
||||||
|
final destination = appGateState.destination;
|
||||||
|
final location = state.matchedLocation;
|
||||||
|
|
||||||
if (!isLoggedIn && !isLoggingIn) {
|
if (destination == AppGateDestination.login && location != '/login') {
|
||||||
return '/login';
|
return '/login';
|
||||||
}
|
}
|
||||||
if (isLoggedIn && isLoggingIn) {
|
if (destination == AppGateDestination.unlock && location != '/unlock') {
|
||||||
|
return '/unlock';
|
||||||
|
}
|
||||||
|
if (destination == AppGateDestination.home &&
|
||||||
|
(location == '/login' || location == '/unlock')) {
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
return null;
|
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:dio/dio.dart';
|
||||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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/config/app_config.dart';
|
||||||
import '../core/network/api_client.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 '../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_local_data_source.dart';
|
||||||
import '../features/auth/data/auth_remote_data_source.dart';
|
import '../features/auth/data/auth_remote_data_source.dart';
|
||||||
import '../features/auth/data/auth_repository_impl.dart';
|
import '../features/auth/data/auth_repository_impl.dart';
|
||||||
import '../features/auth/domain/auth_repository.dart';
|
import '../features/auth/domain/auth_repository.dart';
|
||||||
import '../features/auth/presentation/auth_controller.dart';
|
import '../features/auth/presentation/auth_controller.dart';
|
||||||
import '../features/auth/presentation/auth_state.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/auth_token_provider.dart';
|
||||||
import '../features/telemetry/data/telemetry_remote_data_source.dart';
|
import '../features/telemetry/data/telemetry_remote_data_source.dart';
|
||||||
import '../features/telemetry/data/telemetry_service_impl.dart';
|
import '../features/telemetry/data/telemetry_service_impl.dart';
|
||||||
|
|
@ -28,6 +45,18 @@ final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||||
return const FlutterSecureStorage();
|
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) {
|
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
|
||||||
return AuthLocalDataSource(ref.watch(secureStorageProvider));
|
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>(() {
|
final authControllerProvider = NotifierProvider<AuthController, AuthState>(() {
|
||||||
return AuthController();
|
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) {
|
final httpClientProvider = Provider<HttpClient>((ref) {
|
||||||
return ApiHttpClient(ref.watch(apiClientProvider));
|
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 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
import '../domain/models/auth_token.dart';
|
import '../domain/models/auth_token.dart';
|
||||||
|
import '../../session/domain/models/session_marker.dart';
|
||||||
|
|
||||||
class AuthLocalDataSource {
|
class AuthLocalDataSource {
|
||||||
AuthLocalDataSource(this._storage);
|
AuthLocalDataSource(this._storage);
|
||||||
|
|
@ -12,6 +13,8 @@ class AuthLocalDataSource {
|
||||||
static const _localEmailKey = 'local_email';
|
static const _localEmailKey = 'local_email';
|
||||||
static const _localRolesKey = 'local_roles';
|
static const _localRolesKey = 'local_roles';
|
||||||
static const _localTenantKey = 'local_tenant';
|
static const _localTenantKey = 'local_tenant';
|
||||||
|
static const _sessionUserIdKey = 'session_user_id';
|
||||||
|
static const _sessionLastOnlineKey = 'session_last_online_at';
|
||||||
|
|
||||||
Future<AuthToken?> readToken() async {
|
Future<AuthToken?> readToken() async {
|
||||||
final accessToken = await _storage.read(key: _accessTokenKey);
|
final accessToken = await _storage.read(key: _accessTokenKey);
|
||||||
|
|
@ -24,8 +27,11 @@ class AuthLocalDataSource {
|
||||||
|
|
||||||
Future<void> saveToken(AuthToken token) async {
|
Future<void> saveToken(AuthToken token) async {
|
||||||
await _storage.write(key: _accessTokenKey, value: token.accessToken);
|
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);
|
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: _localEmailKey),
|
||||||
_storage.delete(key: _localRolesKey),
|
_storage.delete(key: _localRolesKey),
|
||||||
_storage.delete(key: _localTenantKey),
|
_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 {
|
class LocalAuthSession {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||||
|
final token = await _remote.refreshToken(refreshToken: refreshToken);
|
||||||
|
await _local.saveToken(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthToken?> getSavedToken() {
|
Future<AuthToken?> getSavedToken() {
|
||||||
return _local.readToken();
|
return _local.readToken();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ abstract class AuthRepository {
|
||||||
required String password,
|
required String password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<AuthToken> refreshToken({required String refreshToken});
|
||||||
|
|
||||||
Future<AuthToken?> getSavedToken();
|
Future<AuthToken?> getSavedToken();
|
||||||
|
|
||||||
Future<void> persistToken(AuthToken token);
|
Future<void> persistToken(AuthToken token);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../di/providers.dart';
|
import '../../../di/providers.dart';
|
||||||
|
import '../../../core/security/jwt_utils.dart';
|
||||||
import '../data/auth_local_data_source.dart';
|
import '../data/auth_local_data_source.dart';
|
||||||
import '../domain/auth_repository.dart';
|
import '../domain/auth_repository.dart';
|
||||||
import '../domain/models/auth_token.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';
|
import 'auth_state.dart';
|
||||||
|
|
||||||
class AuthController extends Notifier<AuthState> {
|
class AuthController extends Notifier<AuthState> {
|
||||||
|
|
@ -15,6 +19,9 @@ class AuthController extends Notifier<AuthState> {
|
||||||
|
|
||||||
AuthRepository get _repository => ref.watch(authRepositoryProvider);
|
AuthRepository get _repository => ref.watch(authRepositoryProvider);
|
||||||
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
|
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
|
||||||
|
SessionRepository get _sessionRepository => ref.watch(sessionRepositoryProvider);
|
||||||
|
AppGateController get _appGateController =>
|
||||||
|
ref.read(appGateControllerProvider.notifier);
|
||||||
|
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||||
|
|
@ -59,11 +66,22 @@ class AuthController extends Notifier<AuthState> {
|
||||||
);
|
);
|
||||||
const token = AuthToken(accessToken: 'local');
|
const token = AuthToken(accessToken: 'local');
|
||||||
await _repository.persistToken(token);
|
await _repository.persistToken(token);
|
||||||
|
await _sessionRepository.saveSessionMarker(
|
||||||
|
SessionMarker(userId: email, lastOnlineAt: DateTime.now()),
|
||||||
|
);
|
||||||
state = const AuthState(isLoading: false, token: token);
|
state = const AuthState(isLoading: false, token: token);
|
||||||
|
_appGateController.setOnlineAuthenticated();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final token = await _repository.login(email: email, password: password);
|
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);
|
state = AuthState(isLoading: false, token: token);
|
||||||
|
_appGateController.setOnlineAuthenticated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -75,6 +93,7 @@ class AuthController extends Notifier<AuthState> {
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await _repository.logout();
|
await _repository.logout();
|
||||||
state = const AuthState();
|
state = const AuthState();
|
||||||
|
_appGateController.resetToLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _friendlyError(Object error) {
|
String _friendlyError(Object error) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||||
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final telemetryState = ref.watch(telemetryControllerProvider);
|
final telemetryState = ref.watch(telemetryControllerProvider);
|
||||||
|
final appGateState = ref.watch(appGateControllerProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -57,6 +58,46 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||||
l10n.signedInMessage,
|
l10n.signedInMessage,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
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),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Developer tools',
|
'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 FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import connectivity_plus
|
||||||
import flutter_appauth
|
import flutter_appauth
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import local_auth_darwin
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
|
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,15 @@ dependencies:
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
connectivity_plus: ^7.0.0
|
||||||
|
cryptography: ^2.7.0
|
||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
flutter_appauth: ^11.0.0
|
flutter_appauth: ^11.0.0
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
local_auth: ^3.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -53,7 +56,7 @@ dev_dependencies:
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
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
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# 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/domain/models/auth_token.dart';
|
||||||
import 'package:mosenioring/src/features/auth/data/auth_local_data_source.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/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 {
|
class MockAuthRepository implements AuthRepository {
|
||||||
AuthToken? token;
|
AuthToken? token;
|
||||||
|
|
@ -20,6 +24,11 @@ class MockAuthRepository implements AuthRepository {
|
||||||
return const AuthToken(accessToken: 'new_token');
|
return const AuthToken(accessToken: 'new_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||||
|
return const AuthToken(accessToken: 'refreshed_token');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
logoutCalled = true;
|
logoutCalled = true;
|
||||||
|
|
@ -34,11 +43,13 @@ class MockAuthRepository implements AuthRepository {
|
||||||
class MockAuthLocalDataSource implements AuthLocalDataSource {
|
class MockAuthLocalDataSource implements AuthLocalDataSource {
|
||||||
LocalAuthSession? session;
|
LocalAuthSession? session;
|
||||||
AuthToken? token;
|
AuthToken? token;
|
||||||
|
SessionMarker? marker;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
session = null;
|
session = null;
|
||||||
token = null;
|
token = null;
|
||||||
|
marker = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -57,17 +68,61 @@ class MockAuthLocalDataSource implements AuthLocalDataSource {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||||
|
this.marker = marker;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
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() {
|
void main() {
|
||||||
group('AuthController', () {
|
group('AuthController', () {
|
||||||
late MockAuthRepository mockRepository;
|
late MockAuthRepository mockRepository;
|
||||||
|
late FakeSessionRepository fakeSessionRepository;
|
||||||
|
late FakeAppGateController fakeAppGateController;
|
||||||
late AppConfig testConfig;
|
late AppConfig testConfig;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockRepository = MockAuthRepository();
|
mockRepository = MockAuthRepository();
|
||||||
|
fakeSessionRepository = FakeSessionRepository();
|
||||||
|
fakeAppGateController = FakeAppGateController();
|
||||||
testConfig = const AppConfig(
|
testConfig = const AppConfig(
|
||||||
apiBaseUrl: 'https://api.example.com',
|
apiBaseUrl: 'https://api.example.com',
|
||||||
useLocalAuth: false,
|
useLocalAuth: false,
|
||||||
|
|
@ -84,6 +139,10 @@ void main() {
|
||||||
overrides: [
|
overrides: [
|
||||||
authRepositoryProvider.overrideWithValue(mockRepository),
|
authRepositoryProvider.overrideWithValue(mockRepository),
|
||||||
appConfigProvider.overrideWithValue(config ?? testConfig),
|
appConfigProvider.overrideWithValue(config ?? testConfig),
|
||||||
|
sessionRepositoryProvider.overrideWithValue(fakeSessionRepository),
|
||||||
|
appGateControllerProvider.overrideWith(
|
||||||
|
() => fakeAppGateController,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
addTearDown(container.dispose);
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:mosenioring/src/app/app.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() {
|
void main() {
|
||||||
testWidgets('App smoke test', (WidgetTester tester) async {
|
testWidgets('App smoke test', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// 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(); // Start _bootstrap
|
||||||
await tester.pump(const Duration(milliseconds: 100)); // Allow some time
|
await tester.pump(const Duration(milliseconds: 100)); // Allow some time
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,15 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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 <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
connectivity_plus
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
local_auth_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue