115 lines
4.0 KiB
Dart
115 lines
4.0 KiB
Dart
import "package:flutter/services.dart";
|
|
import "package:flutter_secure_storage/flutter_secure_storage.dart";
|
|
import "package:local_auth/local_auth.dart";
|
|
import "package:local_auth_android/local_auth_android.dart";
|
|
|
|
/// Biometric ("Face recognition" / "Fingerprint") sign-in.
|
|
///
|
|
/// The app already persists a JWT in SharedPreferences, so a valid session
|
|
/// auto-restores on boot and the login screen is only shown after logout or
|
|
/// token expiry. Biometric login covers that case: the user's credentials are
|
|
/// kept in Android Keystore-backed secure storage and released only after the
|
|
/// system BiometricPrompt succeeds, then replayed against the normal login API
|
|
/// (so each biometric sign-in mints a fresh session/token).
|
|
class BiometricAuth {
|
|
final LocalAuthentication _auth = LocalAuthentication();
|
|
final FlutterSecureStorage _store = const FlutterSecureStorage(
|
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
|
);
|
|
|
|
static const _emailKey = "cc_bio_email";
|
|
static const _passwordKey = "cc_bio_password";
|
|
|
|
/// Synchronous mirror of [isEnabled], kept fresh by the async calls below so
|
|
/// the app-lifecycle handler (which can't await) can decide whether to lock.
|
|
bool enabledCached = false;
|
|
|
|
/// Whether the device has usable biometric hardware with something enrolled.
|
|
Future<bool> isAvailable() async {
|
|
try {
|
|
if (!await _auth.isDeviceSupported()) return false;
|
|
return await _auth.canCheckBiometrics;
|
|
} on PlatformException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Which biometric kinds are enrolled (used to label the sign-in buttons).
|
|
Future<List<BiometricType>> availableTypes() async {
|
|
try {
|
|
return await _auth.getAvailableBiometrics();
|
|
} on PlatformException {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<bool> hasFace() async =>
|
|
(await availableTypes()).contains(BiometricType.face);
|
|
|
|
Future<bool> hasFingerprint() async =>
|
|
(await availableTypes()).contains(BiometricType.fingerprint);
|
|
|
|
/// The email biometric login was enabled for, or null if it's off.
|
|
Future<String?> enabledEmail() async {
|
|
final v = await _store.read(key: _emailKey);
|
|
enabledCached = v != null;
|
|
return v;
|
|
}
|
|
|
|
Future<bool> isEnabled() async => (await enabledEmail()) != null;
|
|
|
|
/// Remember credentials for biometric sign-in. Caller should only invoke this
|
|
/// after a successful password login so the stored creds are known-good.
|
|
Future<void> enable(String email, String password) async {
|
|
await _store.write(key: _emailKey, value: email);
|
|
await _store.write(key: _passwordKey, value: password);
|
|
enabledCached = true;
|
|
}
|
|
|
|
Future<void> disable() async {
|
|
await _store.delete(key: _emailKey);
|
|
await _store.delete(key: _passwordKey);
|
|
enabledCached = false;
|
|
}
|
|
|
|
/// Prompt for biometrics and, on success, return the stored (email, password).
|
|
/// Returns null if the user cancels or nothing is stored; throws
|
|
/// [BiometricException] with a friendly message on a hard error.
|
|
Future<(String, String)?> unlock({String? reason}) async {
|
|
bool ok;
|
|
try {
|
|
ok = await _auth.authenticate(
|
|
localizedReason: reason ?? "Sign in to Car Control",
|
|
options: const AuthenticationOptions(
|
|
biometricOnly: true,
|
|
stickyAuth: true,
|
|
),
|
|
authMessages: const [
|
|
AndroidAuthMessages(
|
|
signInTitle: "Car Control sign-in",
|
|
biometricHint: "",
|
|
cancelButton: "Use password",
|
|
),
|
|
],
|
|
);
|
|
} on PlatformException catch (e) {
|
|
throw BiometricException(e.message ?? "Biometric authentication failed.");
|
|
}
|
|
if (!ok) return null;
|
|
final email = await _store.read(key: _emailKey);
|
|
final password = await _store.read(key: _passwordKey);
|
|
if (email == null || password == null) return null;
|
|
return (email, password);
|
|
}
|
|
}
|
|
|
|
class BiometricException implements Exception {
|
|
final String message;
|
|
BiometricException(this.message);
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
/// App-wide singleton (mirrors the other services in main.dart).
|
|
final biometricAuth = BiometricAuth();
|