Add fingerprint (local_auth) session + 3-min inactivity timeout (default), activity resets timer, settings: biometric toggle + timeout picker

This commit is contained in:
cania
2026-08-12 20:13:57 +02:00
parent 9c3fe4f8e9
commit 68fca2c2c6
12 changed files with 305 additions and 52 deletions

View File

@@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet permission required for Google Sign-In -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Biometric / fingerprint authentication -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<queries>
<!-- Used by Google Sign-In to query browser packages -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
@@ -31,8 +33,6 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />

View File

@@ -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<AuthScreen> {
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<void> _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(

View File

@@ -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<String?> getEmail() async =>
await _storage.read(key: AppConstants.secureKeyEmail);
Future<String?> getAppPassword() async =>
await _storage.read(key: AppConstants.secureKeyAppPassword);
/// True if a user identity already exists locally.
Future<bool> isRegistered() async => (await getEmail()) != null;
/// Register with email + app password.
Future<void> 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<void> 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<bool> 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<void> changePassword(String newPassword) async {
await _storage.write(
key: AppConstants.secureKeyAppPassword, value: newPassword);
}
Future<void> 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<bool> 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<bool> 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<void> openSession() async {
await _storage.write(
key: AppConstants.secureKeySession, value: DateTime.now().toIso8601String());
}
Future<void> 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<bool> 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<int> getTimeoutMinutes() async {
final sp = await _prefs();
return sp.getInt(AppConstants.prefTimeout) ?? 3;
}
Future<void> setTimeoutMinutes(int minutes) async {
final sp = await _prefs();
await sp.setInt(AppConstants.prefTimeout, minutes);
}
// ---------- Biometric toggle setting ----------
Future<bool> isBiometricEnabled() async {
final sp = await _prefs();
return sp.getBool(AppConstants.prefBiometric) ?? false;
}
Future<void> setBiometricEnabled(bool on) async {
final sp = await _prefs();
await sp.setBool(AppConstants.prefBiometric, on);
}
Future<SharedPreferences> _prefs() async =>
await SharedPreferences.getInstance();
Future<void> reset() async {
await _storage.delete(key: AppConstants.secureKeyEmail);
await _storage.delete(key: AppConstants.secureKeyAppPassword);
await _storage.delete(key: AppConstants.secureKeySession);
}
}

View File

@@ -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';
}

View File

@@ -29,17 +29,25 @@ class _DashboardScreenState extends State<DashboardScreen> {
@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<void> _refresh() async {
@@ -66,6 +74,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
}
Future<void> _editEntry(VaultEntry e) async {
_touch();
final result = await showEntryDialog(
context,
title: 'Edit konten',
@@ -80,6 +89,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
}
Future<void> _addEntry() async {
_touch();
final result = await showEntryDialog(
context,
title: 'Tambah entri baru',
@@ -110,13 +120,16 @@ class _DashboardScreenState extends State<DashboardScreen> {
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Pengaturan',
onPressed: () => Navigator.push(
onPressed: () {
_touch();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SettingsScreen(
auth: widget.auth, theme: widget.theme),
),
).then((_) => _refresh()),
).then((_) => _refresh());
},
),
],
),
@@ -139,6 +152,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_touch();
_searchCtrl.clear();
_refresh();
},
@@ -167,20 +181,20 @@ class _DashboardScreenState extends State<DashboardScreen> {
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)),

View File

@@ -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<AuthService>.value(value: auth),
ChangeNotifierProvider<ThemeProvider>.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<ThemeProvider>(context);
final auth = Provider.of<AuthService>(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,

View File

@@ -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<SettingsScreen> {
final _oldCtrl = TextEditingController();
final _newCtrl = TextEditingController();
String? _msg;
bool _biometricOn = false;
bool _biometricAvailable = false;
int _timeout = 3;
final List<int> _timeoutOptions = [3, 5, 10, 15, 30, 60, 0];
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _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<void> _changePassword() async {
final oldP = _oldCtrl.text;
@@ -37,6 +62,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
_newCtrl.clear();
}
Future<void> _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<void> _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<SettingsScreen> {
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<SettingsScreen> {
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<int>(
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<SettingsScreen> {
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();

View File

@@ -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"))
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.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) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
}

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
local_auth_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST