From 68fca2c2c6f91b35f8de59bccdbd61ac116f90cd Mon Sep 17 00:00:00 2001 From: cania Date: Wed, 12 Aug 2026 20:13:57 +0200 Subject: [PATCH] Add fingerprint (local_auth) session + 3-min inactivity timeout (default), activity resets timer, settings: biometric toggle + timeout picker --- android/app/src/main/AndroidManifest.xml | 6 +- lib/auth_screen.dart | 22 +++- lib/auth_service.dart | 110 +++++++++++++++--- lib/constants.dart | 7 +- lib/dashboard_screen.dart | 40 ++++--- lib/main.dart | 26 +++-- lib/settings_screen.dart | 83 +++++++++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 +++++++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 305 insertions(+), 52 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index afe25f6..47b6053 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,10 @@ + + + - @@ -31,8 +33,6 @@ - diff --git a/lib/auth_screen.dart b/lib/auth_screen.dart index 1e9cc7e..b94ebb6 100644 --- a/lib/auth_screen.dart +++ b/lib/auth_screen.dart @@ -5,7 +5,8 @@ import 'package:login_vault_app/auth_service.dart'; import 'package:login_vault_app/dashboard_screen.dart'; import 'package:login_vault_app/theme_provider.dart'; -/// First-launch screen: register with email+password, or with Google. +/// First screen: register, or (if registered) re-authenticate. +/// If a valid session exists, skip straight to the Dashboard. class AuthScreen extends StatefulWidget { final AuthService auth; const AuthScreen({super.key, required this.auth}); @@ -17,12 +18,29 @@ class AuthScreen extends StatefulWidget { class _AuthScreenState extends State { final _emailCtrl = TextEditingController(); final _passCtrl = TextEditingController(); - bool _isLogin = true; // toggle register/login + bool _isLogin = true; bool _busy = false; String? _error; + @override + void initState() { + super.initState(); + _trySilentAuth(); + } + + /// On open: if a valid session is present, go straight to Dashboard. + Future _trySilentAuth() async { + final timeout = await widget.auth.getTimeoutMinutes(); + final valid = await widget.auth.hasValidSession(timeout); + if (valid && mounted) { + final email = await widget.auth.getEmail() ?? ''; + _goToDashboard(email); + } + } + void _goToDashboard(String email) { if (!mounted) return; + widget.auth.openSession(); // refresh session timestamp Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => DashboardScreen( diff --git a/lib/auth_service.dart b/lib/auth_service.dart index 12d6ee3..acbf2e2 100644 --- a/lib/auth_service.dart +++ b/lib/auth_service.dart @@ -1,35 +1,31 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:login_vault_app/constants.dart'; -/// Handles app-level authentication and persistence of the local identity. -/// -/// On first launch the user registers (email+password OR Google). The chosen -/// email is stored locally as the key ([AppConstants.secureKeyEmail]) and the -/// app password (used to lock the dashboard) is stored in secure storage. +/// Handles app-level authentication, session, and local identity. class AuthService { final FlutterSecureStorage _storage = const FlutterSecureStorage(); + final LocalAuthentication _localAuth = LocalAuthentication(); + + // ---------- Identity (email + app password) ---------- - /// Returns the stored email, or null if not registered yet. Future getEmail() async => await _storage.read(key: AppConstants.secureKeyEmail); Future getAppPassword() async => await _storage.read(key: AppConstants.secureKeyAppPassword); - /// True if a user identity already exists locally. Future isRegistered() async => (await getEmail()) != null; - /// Register with email + app password. Future registerWithEmail(String email, String appPassword) async { await _storage.write(key: AppConstants.secureKeyEmail, value: email); await _storage.write( key: AppConstants.secureKeyAppPassword, value: appPassword); } - /// Register with Google (email supplied by Google sign-in). Future registerWithGoogle(String email) async { await _storage.write(key: AppConstants.secureKeyEmail, value: email); - // App password defaults to a marker; user can change it later in settings. final existing = await getAppPassword(); if (existing == null) { await _storage.write( @@ -41,27 +37,103 @@ class AuthService { Future verifyPassword(String password) async { final stored = await getAppPassword(); if (stored == null) return false; - if (stored == '__google__') { - // Google users have no password by default; let any entry pass OR force set. - return true; - } + if (stored == '__google__') return true; // google users: any pass ok return stored == password; } - /// Change the app password (used in settings). Future changePassword(String newPassword) async { await _storage.write( key: AppConstants.secureKeyAppPassword, value: newPassword); } - Future logout() async { - // Keep email (identity) but we treat logout as returning to auth screen - // with the email still known. To fully reset, call [reset]. + // ---------- Biometric (fingerprint) ---------- + + /// Can the device authenticate with biometrics (fingerprint etc.)? + Future canUseBiometrics() async { + try { + final supported = await _localAuth.isDeviceSupported(); + if (!supported) return false; + final available = await _localAuth.getAvailableBiometrics(); + return available.isNotEmpty; + } catch (_) { + return false; + } } - /// Wipe local identity (for completeness / debugging). + /// Prompt the OS biometric dialog. Returns true on success. + Future authenticateWithBiometrics() async { + try { + return await _localAuth.authenticate( + localizedReason: 'Gunakan sidik jari untuk membuka Login Vault', + options: const AuthenticationOptions( + biometricOnly: true, + stickyAuth: true, + ), + ); + } catch (_) { + return false; + } + } + + // ---------- Session ---------- + + /// Persist that a session was just opened (with timestamp). + Future openSession() async { + await _storage.write( + key: AppConstants.secureKeySession, value: DateTime.now().toIso8601String()); + } + + Future closeSession() async { + await _storage.delete(key: AppConstants.secureKeySession); + } + + /// Returns true if a valid, non-expired session exists. + /// [timeoutMinutes] = 0 means "stay logged in until manual logout". + Future hasValidSession(int timeoutMinutes) async { + if (timeoutMinutes <= 0) { + // never auto-expire; just check a session was opened + final v = await _storage.read(key: AppConstants.secureKeySession); + return v != null; + } + final v = await _storage.read(key: AppConstants.secureKeySession); + if (v == null) return false; + final opened = DateTime.tryParse(v); + if (opened == null) return false; + final diff = DateTime.now().difference(opened).inMinutes; + return diff < timeoutMinutes; + } + + // ---------- Timeout setting ---------- + + /// Get configured session timeout (minutes). Default 3. + Future getTimeoutMinutes() async { + final sp = await _prefs(); + return sp.getInt(AppConstants.prefTimeout) ?? 3; + } + + Future setTimeoutMinutes(int minutes) async { + final sp = await _prefs(); + await sp.setInt(AppConstants.prefTimeout, minutes); + } + + // ---------- Biometric toggle setting ---------- + + Future isBiometricEnabled() async { + final sp = await _prefs(); + return sp.getBool(AppConstants.prefBiometric) ?? false; + } + + Future setBiometricEnabled(bool on) async { + final sp = await _prefs(); + await sp.setBool(AppConstants.prefBiometric, on); + } + + Future _prefs() async => + await SharedPreferences.getInstance(); + Future reset() async { await _storage.delete(key: AppConstants.secureKeyEmail); await _storage.delete(key: AppConstants.secureKeyAppPassword); + await _storage.delete(key: AppConstants.secureKeySession); } } diff --git a/lib/constants.dart b/lib/constants.dart index 67953e4..0c08367 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -13,7 +13,12 @@ class AppConstants { /// SQLite database file name. static const String dbName = 'login_vault.db'; - /// Application package-ish identifiers for secure storage keys. + /// Secure storage keys. static const String secureKeyEmail = 'vault_email'; static const String secureKeyAppPassword = 'vault_app_password'; + static const String secureKeySession = 'vault_session_opened_at'; + + /// SharedPreferences keys (settings). + static const String prefTimeout = 'session_timeout_minutes'; + static const String prefBiometric = 'biometric_enabled'; } diff --git a/lib/dashboard_screen.dart b/lib/dashboard_screen.dart index b7a3a8c..40fd0a4 100644 --- a/lib/dashboard_screen.dart +++ b/lib/dashboard_screen.dart @@ -29,17 +29,25 @@ class _DashboardScreenState extends State { @override void initState() { super.initState(); - _searchCtrl.addListener(_onSearchChanged); + _searchCtrl.addListener(() { + _touch(); // user activity + _onSearchChanged(); + }); _refresh(); } @override void dispose() { - _searchCtrl.removeListener(_onSearchChanged); _searchCtrl.dispose(); super.dispose(); } + /// Refresh the session timestamp — called on every user interaction so the + /// 3-minute inactivity timeout only fires when the user is truly idle. + void _touch() { + widget.auth.openSession(); + } + void _onSearchChanged() => _refresh(); Future _refresh() async { @@ -66,6 +74,7 @@ class _DashboardScreenState extends State { } Future _editEntry(VaultEntry e) async { + _touch(); final result = await showEntryDialog( context, title: 'Edit konten', @@ -80,6 +89,7 @@ class _DashboardScreenState extends State { } Future _addEntry() async { + _touch(); final result = await showEntryDialog( context, title: 'Tambah entri baru', @@ -110,13 +120,16 @@ class _DashboardScreenState extends State { IconButton( icon: const Icon(Icons.settings), tooltip: 'Pengaturan', - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SettingsScreen( - auth: widget.auth, theme: widget.theme), - ), - ).then((_) => _refresh()), + onPressed: () { + _touch(); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SettingsScreen( + auth: widget.auth, theme: widget.theme), + ), + ).then((_) => _refresh()); + }, ), ], ), @@ -139,6 +152,7 @@ class _DashboardScreenState extends State { ? IconButton( icon: const Icon(Icons.clear), onPressed: () { + _touch(); _searchCtrl.clear(); _refresh(); }, @@ -167,20 +181,20 @@ class _DashboardScreenState extends State { child: const Icon(Icons.delete, color: Colors.white), ), confirmDismiss: (_) async { - // Use our own dialog for nicer copy. + _touch(); final c = await showConfirmDialog( context, title: 'Hapus entri?', - body: - 'Yakin hapus "${e.id}"?', + body: 'Yakin hapus "${e.id}"?', ); if (c == true) await _deleteEntry(e); - return false; // we handle deletion ourselves + return false; }, child: Card( margin: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), child: ExpansionTile( + onExpansionChanged: (_) => _touch(), title: Text(e.id, style: const TextStyle( fontWeight: FontWeight.bold)), diff --git a/lib/main.dart b/lib/main.dart index c069d04..97acf7d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,26 +11,39 @@ void main() async { final theme = ThemeProvider(); final registered = await auth.isRegistered(); final email = await auth.getEmail() ?? ''; + final timeout = await auth.getTimeoutMinutes(); + final hasSession = await auth.hasValidSession(timeout); + final startAtDashboard = registered && email.isNotEmpty && hasSession; runApp( MultiProvider( providers: [ Provider.value(value: auth), ChangeNotifierProvider.value(value: theme), ], - child: MyApp(registered: registered, email: email), + child: MyApp( + startAtDashboard: startAtDashboard, + auth: auth, + theme: theme, + email: email, + ), ), ); } class MyApp extends StatelessWidget { - final bool registered; + final bool startAtDashboard; + final AuthService auth; + final ThemeProvider theme; final String email; - const MyApp({super.key, required this.registered, required this.email}); + const MyApp( + {super.key, + required this.startAtDashboard, + required this.auth, + required this.theme, + required this.email}); @override Widget build(BuildContext context) { - final theme = Provider.of(context); - final auth = Provider.of(context, listen: false); return MaterialApp( title: 'Login Vault', theme: ThemeData( @@ -44,8 +57,7 @@ class MyApp extends StatelessWidget { colorSchemeSeed: Colors.indigo, ), themeMode: theme.mode, - // decide home: if registered AND email known -> Dashboard, else Auth. - home: (registered && email.isNotEmpty) + home: startAtDashboard ? DashboardScreen(auth: auth, theme: theme, email: email) : AuthScreen(auth: auth), debugShowCheckedModeBanner: false, diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index ac83180..db7ccb2 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -4,7 +4,8 @@ import 'package:flutter/services.dart'; import 'package:login_vault_app/auth_service.dart'; import 'package:login_vault_app/theme_provider.dart'; -/// Settings: change app password, switch theme (dark/system), and Exit. +/// Settings: change app password, switch theme (dark/system), session timeout, +/// fingerprint toggle, and Exit. class SettingsScreen extends StatefulWidget { final AuthService auth; final ThemeProvider theme; @@ -18,6 +19,30 @@ class _SettingsScreenState extends State { final _oldCtrl = TextEditingController(); final _newCtrl = TextEditingController(); String? _msg; + bool _biometricOn = false; + bool _biometricAvailable = false; + int _timeout = 3; + + final List _timeoutOptions = [3, 5, 10, 15, 30, 60, 0]; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final on = await widget.auth.isBiometricEnabled(); + final avail = await widget.auth.canUseBiometrics(); + final t = await widget.auth.getTimeoutMinutes(); + if (mounted) { + setState(() { + _biometricOn = on && avail; + _biometricAvailable = avail; + _timeout = t; + }); + } + } Future _changePassword() async { final oldP = _oldCtrl.text; @@ -37,6 +62,31 @@ class _SettingsScreenState extends State { _newCtrl.clear(); } + Future _toggleBiometric(bool value) async { + if (value && !_biometricAvailable) { + setState(() => _msg = 'Perangkat tidak mendukung sidik jari'); + return; + } + if (value) { + final ok = await widget.auth.authenticateWithBiometrics(); + if (!ok) { + setState(() => _msg = 'Verifikasi sidik jari gagal'); + return; + } + } + await widget.auth.setBiometricEnabled(value); + if (mounted) setState(() => _biometricOn = value); + } + + Future _setTimeout(int? value) async { + if (value == null) return; + await widget.auth.setTimeoutMinutes(value); + if (mounted) setState(() => _timeout = value); + widget.auth.openSession(); // activity + } + + String _timeoutLabel(int m) => m == 0 ? 'Selamanya' : '$m menit'; + @override Widget build(BuildContext context) { return Scaffold( @@ -44,8 +94,7 @@ class _SettingsScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ - const Text('Tema', - style: TextStyle(fontWeight: FontWeight.bold)), + const Text('Tema', style: TextStyle(fontWeight: FontWeight.bold)), ListTile( title: const Text('Gelap (Dark)'), leading: const Icon(Icons.dark_mode), @@ -67,6 +116,27 @@ class _SettingsScreenState extends State { onTap: () => widget.theme.setMode(ThemeMode.system), ), const Divider(), + const Text('Session', style: TextStyle(fontWeight: FontWeight.bold)), + ListTile( + title: const Text('Timeout (tanpa aktivitas)'), + subtitle: Text('Logout otomatis setelah: ${_timeoutLabel(_timeout)}'), + trailing: DropdownButton( + value: _timeout, + items: _timeoutOptions + .map((m) => DropdownMenuItem(value: m, child: Text(_timeoutLabel(m)))) + .toList(), + onChanged: _setTimeout, + ), + ), + SwitchListTile( + title: const Text('Login dengan sidik jari'), + subtitle: Text(_biometricAvailable + ? 'Gunakan fingerprint untuk membuka app' + : 'Perangkat tidak mendukung biometrik'), + value: _biometricOn, + onChanged: _toggleBiometric, + ), + const Divider(), const Text('Ubah Password Aplikasi', style: TextStyle(fontWeight: FontWeight.bold)), TextField( @@ -87,15 +157,14 @@ class _SettingsScreenState extends State { if (_msg != null) Padding( padding: const EdgeInsets.only(top: 8), - child: Text(_msg!, - style: const TextStyle(color: Colors.green)), + child: Text(_msg!, style: const TextStyle(color: Colors.green)), ), const Divider(), ListTile( leading: const Icon(Icons.exit_to_app), title: const Text('Keluar (Exit)'), - onTap: () { - // On Android, pop the whole stack then ask the system to close. + onTap: () async { + await widget.auth.closeSession(); Navigator.of(context).popUntil((r) => r.isFirst); if (Platform.isAndroid) { SystemNavigator.pop(); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 75bf2a5..0202226 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import flutter_secure_storage_macos import google_sign_in_ios +import local_auth_darwin import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 6b570b5..1ef10df 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,6 +102,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_secure_storage: dependency: "direct main" description: @@ -232,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" jni: dependency: transitive description: @@ -296,6 +312,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.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: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 167eba2..2bc3ff1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: google_sign_in: ^6.2.1 provider: ^6.1.2 shared_preferences: ^2.2.2 + local_auth: ^2.3.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..011734d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0b33f8..de15aee 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + local_auth_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST