Kaynağa Gözat

Fingerprint and pin added

Sasan Salamzadeh 1 yıl önce
ebeveyn
işleme
7354e7da0d

+ 2 - 0
android/app/src/main/AndroidManifest.xml

@@ -49,4 +49,6 @@
     </queries>
     <!-- TODO: Review permission, app should work without camera permissions -->
     <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.USE_BIOMETRIC" />
+    <uses-permission android:name="android.permission.USE_FINGERPRINT" />
 </manifest>

+ 2 - 2
android/app/src/main/kotlin/ir/pbdp/pb_authenticator/MainActivity.kt

@@ -1,12 +1,12 @@
 package ir.pbdp.pb_authenticator
 
-import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.android.FlutterFragmentActivity
 import android.os.Bundle
 import io.flutter.embedding.engine.FlutterEngine
 import io.flutter.plugin.common.MethodChannel
 import android.view.WindowManager
 
-class MainActivity: FlutterActivity() {
+class MainActivity: FlutterFragmentActivity() {
     private val CHANNEL = "screen_capture"
 
     override fun configureFlutterEngine(flutterEngine: FlutterEngine) {

+ 98 - 12
lib/main.dart

@@ -13,6 +13,7 @@ import 'pages/pages.dart';
 import 'package:provider/provider.dart';
 
 import 'config/routes.dart';
+import 'pages/lock_screen.dart';
 
 void main() {
   var repository = Repository(FileStorage());
@@ -22,7 +23,7 @@ void main() {
   ));
 }
 
-class MainApp extends StatelessWidget {
+class MainApp extends StatefulWidget {
   // Locale information
   final Iterable<Locale> supportedLocales = [const Locale('en')];
   final Iterable<LocalizationsDelegate> localizationsDelegates = [
@@ -43,6 +44,35 @@ class MainApp extends StatelessWidget {
 
   MainApp({super.key});
 
+  @override
+  State<MainApp> createState() => _MainAppState();
+}
+
+class _MainAppState extends State<MainApp> {
+  bool _locked = false;
+  bool _initialCheckDone = false;
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    _checkInitialAuth();
+  }
+
+  Future<void> _checkInitialAuth() async {
+    if (_initialCheckDone) return;
+    final appState = Provider.of<AppState>(context, listen: false);
+    if (appState.shouldRequireAuth()) {
+      setState(() { _locked = true; });
+      final unlocked = await Navigator.of(context).push(
+        MaterialPageRoute(builder: (_) => const LockScreenPage(), fullscreenDialog: true),
+      );
+      if (unlocked == true) {
+        setState(() { _locked = false; });
+      }
+    }
+    _initialCheckDone = true;
+  }
+
   @override
   Widget build(BuildContext context) {
     SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
@@ -50,7 +80,7 @@ class MainApp extends StatelessWidget {
 
     return Consumer<AppState>(
       builder: (context, appState, _) {
-        _updateScreenCapture(appState.screenCapturePrevented);
+        widget._updateScreenCapture(appState.screenCapturePrevented);
         return MaterialApp(
           onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
           theme: ThemeData.light(),
@@ -58,19 +88,75 @@ class MainApp extends StatelessWidget {
           themeMode: appState.themeMode,
           initialRoute: AppRoutes.home,
           routes: {
-            AppRoutes.home: (context) => const AndroidHomePage(),
-            AppRoutes.edit: (context) => const AndroidEditPage(),
-            AppRoutes.add: (context) => const AddPage(),
-            AppRoutes.addScan: (context) => const ScanQRPage(),
-            AppRoutes.settings: (context) => const SettingsPage(),
-            AppRoutes.howItWorks: (context) => const HowItWorksPage(),
-            AppRoutes.transferCodes: (context) => const TransferCodesPage(),
-            AppRoutes.help: (context) => const HelpPage(),
+            AppRoutes.home: (context) => _AuthGate(child: const AndroidHomePage()),
+            AppRoutes.edit: (context) => _AuthGate(child: const AndroidEditPage()),
+            AppRoutes.add: (context) => _AuthGate(child: const AddPage()),
+            AppRoutes.addScan: (context) => _AuthGate(child: const ScanQRPage()),
+            AppRoutes.settings: (context) => _AuthGate(child: const SettingsPage()),
+            AppRoutes.howItWorks: (context) => _AuthGate(child: const HowItWorksPage()),
+            AppRoutes.transferCodes: (context) => _AuthGate(child: const TransferCodesPage()),
+            AppRoutes.help: (context) => _AuthGate(child: const HelpPage()),
+          },
+          localizationsDelegates: widget.localizationsDelegates,
+          supportedLocales: widget.supportedLocales,
+          builder: (context, child) {
+            if (_locked) return const SizedBox.shrink();
+            return child!;
           },
-          localizationsDelegates: localizationsDelegates,
-          supportedLocales: supportedLocales,
         );
       },
     );
   }
 }
+
+class _AuthGate extends StatefulWidget {
+  final Widget child;
+  const _AuthGate({required this.child});
+  @override
+  State<_AuthGate> createState() => _AuthGateState();
+}
+
+class _AuthGateState extends State<_AuthGate> with WidgetsBindingObserver {
+  bool _locked = false;
+
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addObserver(this);
+    _checkAuth();
+  }
+
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      _checkAuth();
+    }
+  }
+
+  Future<void> _checkAuth() async {
+    final appState = Provider.of<AppState>(context, listen: false);
+    if (appState.shouldRequireAuth()) {
+      setState(() { _locked = true; });
+      final unlocked = await Navigator.of(context).push(
+        MaterialPageRoute(builder: (_) => const LockScreenPage(), fullscreenDialog: true),
+      );
+      if (unlocked == true) {
+        setState(() { _locked = false; });
+      }
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_locked) {
+      return const SizedBox.shrink();
+    }
+    return widget.child;
+  }
+}

+ 156 - 0
lib/pages/lock_screen.dart

@@ -0,0 +1,156 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:local_auth/local_auth.dart';
+import '../state/app_state.dart';
+
+class LockScreenPage extends StatefulWidget {
+  const LockScreenPage({super.key});
+
+  @override
+  State<LockScreenPage> createState() => _LockScreenPageState();
+}
+
+class _LockScreenPageState extends State<LockScreenPage> {
+  final _pinController = TextEditingController();
+  String? _error;
+  bool _loading = false;
+  bool _biometricAvailable = false;
+  String? _biometricError;
+  bool _biometricInProgress = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _checkBiometricAvailable();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      _tryBiometric(auto: true);
+    });
+  }
+
+  Future<void> _checkBiometricAvailable() async {
+    final localAuth = LocalAuthentication();
+    final canCheck = await localAuth.canCheckBiometrics;
+    final supported = await localAuth.isDeviceSupported();
+    final enrolled = (await localAuth.getAvailableBiometrics()).isNotEmpty;
+    setState(() {
+      _biometricAvailable = canCheck && supported && enrolled;
+    });
+  }
+
+  Future<void> _tryBiometric({bool auto = false}) async {
+    final appState = Provider.of<AppState>(context, listen: false);
+    if (!appState.biometricEnabled || !_biometricAvailable || _biometricInProgress) return;
+    setState(() { _loading = true; _biometricError = null; _biometricInProgress = true; });
+    final localAuth = LocalAuthentication();
+    try {
+      final didAuth = await localAuth.authenticate(
+        localizedReason: 'Authenticate to unlock',
+        options: const AuthenticationOptions(biometricOnly: true, stickyAuth: true),
+      );
+      if (didAuth) {
+        appState.updateLastUnlockTime();
+        if (mounted) {
+          WidgetsBinding.instance.addPostFrameCallback((_) {
+            if (mounted) Navigator.of(context).pop(true);
+          });
+        }
+        return;
+      } else {
+        setState(() { _biometricError = 'Biometric authentication failed.'; });
+      }
+    } catch (e) {
+      debugPrint('Biometric error: $e');
+      setState(() { _biometricError = 'Biometric authentication unavailable. ($e)'; });
+    }
+    setState(() { _loading = false; _biometricInProgress = false; });
+  }
+
+  Future<void> _checkPin() async {
+    setState(() { _loading = true; _error = null; });
+    final appState = Provider.of<AppState>(context, listen: false);
+    final pin = await appState.getPin();
+    if (_pinController.text == pin) {
+      appState.updateLastUnlockTime();
+      if (mounted) {
+        WidgetsBinding.instance.addPostFrameCallback((_) {
+          if (mounted) Navigator.of(context).pop(true);
+        });
+      }
+    } else {
+      setState(() { _error = 'Incorrect PIN'; _loading = false; });
+      // After PIN failure, offer biometric again if enabled
+      if (appState.biometricEnabled && _biometricAvailable) {
+        _tryBiometric();
+      }
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final appState = Provider.of<AppState>(context);
+    return Scaffold(
+      body: Center(
+        child: Padding(
+          padding: const EdgeInsets.all(32.0),
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              const Icon(Icons.lock, size: 64),
+              const SizedBox(height: 16),
+              Text('Unlock PB Authenticator', style: Theme.of(context).textTheme.titleLarge),
+              const SizedBox(height: 24),
+              if (appState.pinEnabled)
+                TextField(
+                  controller: _pinController,
+                  keyboardType: TextInputType.number,
+                  obscureText: true,
+                  maxLength: 6,
+                  enabled: !_loading,
+                  decoration: InputDecoration(
+                    labelText: 'Enter PIN',
+                    errorText: _error,
+                  ),
+                  onSubmitted: (_) => _checkPin(),
+                ),
+              const SizedBox(height: 16),
+              if (appState.pinEnabled)
+                ElevatedButton(
+                  onPressed: _loading ? null : _checkPin,
+                  child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Unlock'),
+                ),
+              if (appState.biometricEnabled)
+                Padding(
+                  padding: const EdgeInsets.only(top: 16),
+                  child: Column(
+                    children: [
+                      ElevatedButton.icon(
+                        icon: const Icon(Icons.fingerprint),
+                        label: const Text('Use biometrics'),
+                        onPressed: (_loading || !_biometricAvailable) ? null : () => _tryBiometric(),
+                      ),
+                      if (_biometricError != null)
+                        Padding(
+                          padding: const EdgeInsets.only(top: 8),
+                          child: Text(_biometricError!, style: const TextStyle(color: Colors.red)),
+                        ),
+                      if (!_biometricAvailable)
+                        const Padding(
+                          padding: EdgeInsets.only(top: 8),
+                          child: Text('No biometrics enrolled or not available.', style: TextStyle(color: Colors.red)),
+                        ),
+                    ],
+                  ),
+                ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _pinController.dispose();
+    super.dispose();
+  }
+} 

+ 73 - 1
lib/pages/settings.dart

@@ -1,5 +1,5 @@
 import 'package:flutter/widgets.dart';
-import 'package:flutter/material.dart' show Icons, ListTile, Material, ThemeMode, SimpleDialogOption, SimpleDialog, showDialog, SwitchListTile;
+import 'package:flutter/material.dart' show Icons, ListTile, Material, ThemeMode, SimpleDialogOption, SimpleDialog, showDialog, SwitchListTile, AlertDialog, InputDecoration, TextField, TextButton;
 import 'package:flutter/cupertino.dart' show CupertinoIcons;
 import '../l10n/app_localizations.dart';
 import '../state/app_state.dart';
@@ -9,6 +9,8 @@ import 'package:package_info_plus/package_info_plus.dart' show PackageInfo;
 import '../config/routes.dart';
 import '../l10n/constants.dart';
 import 'package:provider/provider.dart';
+import 'package:local_auth/local_auth.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
 
 /// Settings page.
 class SettingsPage extends StatelessWidget {
@@ -132,6 +134,76 @@ class SettingsPage extends StatelessWidget {
                       );
                     },
                   ),
+                  // PIN code
+                  Consumer<AppState>(
+                    builder: (context, appState, _) {
+                      return SwitchListTile(
+                        dense: true,
+                        secondary: const Icon(Icons.lock),
+                        title: Text('Enable PIN code', style: const TextStyle(fontSize: 15)),
+                        value: appState.pinEnabled,
+                        onChanged: (val) async {
+                          if (val) {
+                            // Show dialog to set PIN
+                            final pin = await showDialog<String>(
+                              context: context,
+                              builder: (context) {
+                                final controller = TextEditingController();
+                                return AlertDialog(
+                                  title: const Text('Set PIN'),
+                                  content: TextField(
+                                    controller: controller,
+                                    keyboardType: TextInputType.number,
+                                    obscureText: true,
+                                    maxLength: 6,
+                                    decoration: const InputDecoration(labelText: 'Enter a 4-6 digit PIN'),
+                                  ),
+                                  actions: [
+                                    TextButton(
+                                      onPressed: () => Navigator.pop(context),
+                                      child: const Text('Cancel'),
+                                    ),
+                                    TextButton(
+                                      onPressed: () {
+                                        if (controller.text.length >= 4 && controller.text.length <= 6) {
+                                          Navigator.pop(context, controller.text);
+                                        }
+                                      },
+                                      child: const Text('Save'),
+                                    ),
+                                  ],
+                                );
+                              },
+                            );
+                            if (pin != null && pin.length >= 4 && pin.length <= 6) {
+                              await appState.setPin(pin);
+                              await appState.setPinEnabled(true);
+                            }
+                          } else {
+                            await appState.setPinEnabled(false);
+                          }
+                        },
+                      );
+                    },
+                  ),
+                  // Biometric auth
+                  FutureBuilder<bool>(
+                    future: LocalAuthentication().canCheckBiometrics,
+                    builder: (context, snapshot) {
+                      if (snapshot.data != true) return const SizedBox.shrink();
+                      return Consumer<AppState>(
+                        builder: (context, appState, _) {
+                          return SwitchListTile(
+                            dense: true,
+                            secondary: const Icon(Icons.fingerprint),
+                            title: Text('Enable biometric authentication', style: const TextStyle(fontSize: 15)),
+                            value: appState.biometricEnabled,
+                            onChanged: (val) => appState.setBiometricEnabled(val),
+                          );
+                        },
+                      );
+                    },
+                  ),
                   // Source
                   ListTile(
                     dense: true,

+ 52 - 1
lib/state/app_state.dart

@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
 import 'package:collection/collection.dart' show ListEquality;
 import 'package:shared_preferences/shared_preferences.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:local_auth/local_auth.dart';
 
 // TODO: Transition to new type
 typedef BaseItemType = LegacyAuthenticatorItem;
@@ -16,6 +18,21 @@ class AppState extends ChangeNotifier {
   ThemeMode _themeMode = ThemeMode.system;
   bool _screenCapturePrevented = false;
 
+  // PIN and biometric settings
+  bool _pinEnabled = false;
+  bool _biometricEnabled = false;
+  DateTime? _lastUnlockTime;
+  static const _pinTimeout = Duration(minutes: 5);
+  static const _pinKey = 'app_pin';
+  static const _pinEnabledKey = 'pinEnabled';
+  static const _biometricEnabledKey = 'biometricEnabled';
+  static final _secureStorage = FlutterSecureStorage();
+
+  bool get pinEnabled => _pinEnabled;
+  bool get biometricEnabled => _biometricEnabled;
+  DateTime? get lastUnlockTime => _lastUnlockTime;
+  Duration get pinTimeout => _pinTimeout;
+
   ThemeMode get themeMode => _themeMode;
   bool get screenCapturePrevented => _screenCapturePrevented;
 
@@ -60,7 +77,8 @@ class AppState extends ChangeNotifier {
     final themeIndex = prefs.getInt('themeMode') ?? 0;
     _themeMode = ThemeMode.values[themeIndex];
     _screenCapturePrevented = prefs.getBool('screenCapturePrevented') ?? false;
-    // Platform channel will handle screen capture prevention
+    _pinEnabled = prefs.getBool(_pinEnabledKey) ?? false;
+    _biometricEnabled = prefs.getBool(_biometricEnabledKey) ?? false;
     notifyListeners();
   }
 
@@ -78,4 +96,37 @@ class AppState extends ChangeNotifier {
     // Platform channel will handle screen capture prevention
     notifyListeners();
   }
+
+  Future<void> setPinEnabled(bool value) async {
+    _pinEnabled = value;
+    final prefs = await SharedPreferences.getInstance();
+    await prefs.setBool(_pinEnabledKey, value);
+    notifyListeners();
+  }
+
+  Future<void> setBiometricEnabled(bool value) async {
+    _biometricEnabled = value;
+    final prefs = await SharedPreferences.getInstance();
+    await prefs.setBool(_biometricEnabledKey, value);
+    notifyListeners();
+  }
+
+  Future<void> setPin(String pin) async {
+    await _secureStorage.write(key: _pinKey, value: pin);
+  }
+
+  Future<String?> getPin() async {
+    return await _secureStorage.read(key: _pinKey);
+  }
+
+  void updateLastUnlockTime() {
+    _lastUnlockTime = DateTime.now();
+    notifyListeners();
+  }
+
+  bool shouldRequireAuth() {
+    if (!_pinEnabled && !_biometricEnabled) return false;
+    if (_lastUnlockTime == null) return true;
+    return DateTime.now().difference(_lastUnlockTime!) > _pinTimeout;
+  }
 }

+ 104 - 0
pubspec.lock

@@ -171,6 +171,62 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_plugin_android_lifecycle:
+    dependency: transitive
+    description:
+      name: flutter_plugin_android_lifecycle
+      sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.28"
+  flutter_secure_storage:
+    dependency: "direct main"
+    description:
+      name: flutter_secure_storage
+      sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
+      url: "https://pub.dev"
+    source: hosted
+    version: "9.2.4"
+  flutter_secure_storage_linux:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_linux
+      sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.3"
+  flutter_secure_storage_macos:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_macos
+      sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.3"
+  flutter_secure_storage_platform_interface:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_platform_interface
+      sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.2"
+  flutter_secure_storage_web:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_web
+      sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  flutter_secure_storage_windows:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_windows
+      sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.2"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -213,6 +269,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "3.1.17"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.7"
   leak_tracker:
     dependency: transitive
     description:
@@ -245,6 +309,46 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "4.0.0"
+  local_auth:
+    dependency: "direct main"
+    description:
+      name: local_auth
+      sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.0"
+  local_auth_android:
+    dependency: transitive
+    description:
+      name: local_auth_android
+      sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.49"
+  local_auth_darwin:
+    dependency: transitive
+    description:
+      name: local_auth_darwin
+      sha256: "25163ce60a5a6c468cf7a0e3dc8a165f824cabc2aa9e39a5e9fc5c2311b7686f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.5.0"
+  local_auth_platform_interface:
+    dependency: transitive
+    description:
+      name: local_auth_platform_interface
+      sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.10"
+  local_auth_windows:
+    dependency: transitive
+    description:
+      name: local_auth_windows
+      sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.11"
   matcher:
     dependency: transitive
     description:

+ 2 - 0
pubspec.yaml

@@ -33,6 +33,8 @@ dependencies:
   provider: ^6.1.2
   introduction_screen: ^3.1.11
   qr_flutter: ^4.1.0
+  local_auth: ^2.1.7
+  flutter_secure_storage: ^9.0.0
 
 
 dev_dependencies: