inou-mobile/lib/services/biometric_service.dart

300 lines
8.9 KiB
Dart

import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:shared_preferences/shared_preferences.dart';
/// Biometric authentication result
enum BiometricResult {
success,
failed,
cancelled,
notAvailable,
notEnrolled,
lockedOut,
permanentlyLockedOut,
error,
}
/// Lock requirement policy
enum LockPolicy {
always,
afterInactive, // After 5 min of inactivity
never,
}
/// Biometric authentication service with session state and preferences
class BiometricService {
static final BiometricService _instance = BiometricService._internal();
factory BiometricService() => _instance;
BiometricService._internal();
final LocalAuthentication _localAuth = LocalAuthentication();
// Session state
bool _isAuthenticated = false;
DateTime? _lastActivityTime;
// Constants
static const String _prefKeyEnabled = 'biometric_enabled';
static const String _prefKeyPolicy = 'biometric_lock_policy';
static const Duration _inactivityTimeout = Duration(minutes: 5);
/// Whether user is currently authenticated in this session
bool get isAuthenticated => _isAuthenticated;
/// Mark activity to track inactivity timeout
void recordActivity() {
_lastActivityTime = DateTime.now();
}
/// Check if device supports any biometrics
Future<bool> isDeviceSupported() async {
try {
return await _localAuth.isDeviceSupported();
} catch (e) {
return false;
}
}
/// Check if biometrics can be used (hardware exists)
Future<bool> canCheckBiometrics() async {
try {
return await _localAuth.canCheckBiometrics;
} catch (e) {
return false;
}
}
/// Check if biometrics are available (supported + enrolled)
Future<bool> isBiometricsAvailable() async {
try {
final canCheck = await _localAuth.canCheckBiometrics;
final isSupported = await _localAuth.isDeviceSupported();
return canCheck && isSupported;
} catch (e) {
return false;
}
}
/// Get available biometric types on this device
Future<List<BiometricType>> getAvailableBiometrics() async {
try {
return await _localAuth.getAvailableBiometrics();
} catch (e) {
return [];
}
}
/// Get human-readable biometric type name
Future<String> getBiometricTypeName() async {
final types = await getAvailableBiometrics();
if (types.contains(BiometricType.face)) {
return 'Face ID';
} else if (types.contains(BiometricType.fingerprint)) {
return 'Fingerprint';
} else if (types.contains(BiometricType.iris)) {
return 'Iris';
} else if (types.contains(BiometricType.strong)) {
return 'Biometrics';
} else if (types.contains(BiometricType.weak)) {
return 'Biometrics';
}
return 'Biometrics';
}
/// Authenticate with biometrics, with fallback to device PIN/password
Future<BiometricResult> authenticate({
String reason = 'Please authenticate to access inou',
bool biometricOnly = false,
}) async {
// Check if already authenticated in session
if (_isAuthenticated && !_shouldRequireReauth()) {
return BiometricResult.success;
}
// Check availability
final available = await isBiometricsAvailable();
if (!available) {
final canCheck = await canCheckBiometrics();
if (!canCheck) {
return BiometricResult.notEnrolled;
}
return BiometricResult.notAvailable;
}
try {
final success = await _localAuth.authenticate(
localizedReason: reason,
options: AuthenticationOptions(
stickyAuth: true,
biometricOnly: biometricOnly,
useErrorDialogs: true,
sensitiveTransaction: true,
),
);
if (success) {
_isAuthenticated = true;
_lastActivityTime = DateTime.now();
return BiometricResult.success;
}
return BiometricResult.failed;
} on PlatformException catch (e) {
return _handlePlatformException(e);
} catch (e) {
return BiometricResult.error;
}
}
/// Handle platform-specific errors
BiometricResult _handlePlatformException(PlatformException e) {
switch (e.code) {
case auth_error.notAvailable:
return BiometricResult.notAvailable;
case auth_error.notEnrolled:
return BiometricResult.notEnrolled;
case auth_error.lockedOut:
return BiometricResult.lockedOut;
case auth_error.permanentlyLockedOut:
return BiometricResult.permanentlyLockedOut;
case auth_error.passcodeNotSet:
return BiometricResult.notEnrolled;
default:
// User cancelled or other error
if (e.message?.toLowerCase().contains('cancel') == true) {
return BiometricResult.cancelled;
}
return BiometricResult.error;
}
}
/// Check if re-authentication is needed based on policy
bool _shouldRequireReauth() {
final policy = _currentPolicyCache ?? LockPolicy.afterInactive;
switch (policy) {
case LockPolicy.always:
return true;
case LockPolicy.never:
return false;
case LockPolicy.afterInactive:
if (_lastActivityTime == null) return true;
return DateTime.now().difference(_lastActivityTime!) > _inactivityTimeout;
}
}
/// Check if authentication is required (for app resume scenarios)
Future<bool> isAuthenticationRequired() async {
final enabled = await isBiometricEnabled();
if (!enabled) return false;
final available = await isBiometricsAvailable();
if (!available) return false;
final policy = await getLockPolicy();
switch (policy) {
case LockPolicy.always:
return true;
case LockPolicy.never:
return false;
case LockPolicy.afterInactive:
if (!_isAuthenticated) return true;
if (_lastActivityTime == null) return true;
return DateTime.now().difference(_lastActivityTime!) > _inactivityTimeout;
}
}
/// Reset authentication state (for logout or app background)
void resetAuthState() {
_isAuthenticated = false;
_lastActivityTime = null;
}
/// Cancel any ongoing authentication
Future<void> cancelAuthentication() async {
try {
await _localAuth.stopAuthentication();
} catch (_) {}
}
// Preference management
LockPolicy? _currentPolicyCache;
/// Check if biometric authentication is enabled
Future<bool> isBiometricEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_prefKeyEnabled) ?? false;
}
/// Enable or disable biometric authentication
Future<void> setBiometricEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, enabled);
if (!enabled) {
_isAuthenticated = true; // Don't require auth if disabled
}
}
/// Get current lock policy
Future<LockPolicy> getLockPolicy() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_prefKeyPolicy);
_currentPolicyCache = _policyFromString(value);
return _currentPolicyCache!;
}
/// Set lock policy
Future<void> setLockPolicy(LockPolicy policy) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKeyPolicy, _policyToString(policy));
_currentPolicyCache = policy;
}
String _policyToString(LockPolicy policy) {
switch (policy) {
case LockPolicy.always:
return 'always';
case LockPolicy.afterInactive:
return 'after_inactive';
case LockPolicy.never:
return 'never';
}
}
LockPolicy _policyFromString(String? value) {
switch (value) {
case 'always':
return LockPolicy.always;
case 'never':
return LockPolicy.never;
case 'after_inactive':
default:
return LockPolicy.afterInactive;
}
}
/// Get user-friendly error message for a result
String getErrorMessage(BiometricResult result) {
switch (result) {
case BiometricResult.success:
return 'Authentication successful';
case BiometricResult.failed:
return 'Authentication failed. Please try again.';
case BiometricResult.cancelled:
return 'Authentication was cancelled';
case BiometricResult.notAvailable:
return 'Biometric authentication is not available on this device';
case BiometricResult.notEnrolled:
return 'No biometrics enrolled. Please set up Face ID, Touch ID, or fingerprint in your device settings.';
case BiometricResult.lockedOut:
return 'Too many failed attempts. Please try again later or use your device PIN.';
case BiometricResult.permanentlyLockedOut:
return 'Biometrics are locked. Please unlock your device using PIN/password first.';
case BiometricResult.error:
return 'An error occurred. Please try again.';
}
}
}