From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id A723562615 for ; Wed, 30 Sep 2020 12:32:45 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 96F0417DE0 for ; Wed, 30 Sep 2020 12:32:45 +0200 (CEST) Received: from erna.proxmox.com (212-186-127-178.static.upcbusiness.at [212.186.127.178]) by firstgate.proxmox.com (Proxmox) with ESMTP id BE24A17DD7 for ; Wed, 30 Sep 2020 12:32:43 +0200 (CEST) Received: by erna.proxmox.com (Postfix, from userid 1000) id C401F2C0971; Wed, 30 Sep 2020 12:32:45 +0200 (CEST) From: Tim Marx To: pve-devel@lists.proxmox.com Date: Wed, 30 Sep 2020 12:32:43 +0200 Message-Id: <20200930103243.1200300-2-t.marx@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20200930103243.1200300-1-t.marx@proxmox.com> References: <20200930103243.1200300-1-t.marx@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 1 AWL -0.633 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods KHOP_HELO_FCRDNS 0.4 Relay HELO differs from its IP's reverse DNS NO_DNS_FOR_FROM 0.379 Envelope sender has no MX or A DNS records SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [snapshot.data, dartlang.org] Subject: [pve-devel] [PATCH proxmox_login_manager] add option for local biometric authentication X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Wed, 30 Sep 2020 10:32:45 -0000 Signed-off-by: Tim Marx --- lib/proxmox_general_settings_form.dart | 39 +++- lib/proxmox_general_settings_model.dart | 12 +- lib/proxmox_login_form.dart | 9 +- lib/proxmox_login_model.dart | 4 +- lib/proxmox_login_selector.dart | 234 +++++++++++++++--------- pubspec.lock | 14 ++ pubspec.yaml | 1 + 7 files changed, 216 insertions(+), 97 deletions(-) diff --git a/lib/proxmox_general_settings_form.dart b/lib/proxmox_general_settings_form.dart index f3fdd44..61a7d76 100644 --- a/lib/proxmox_general_settings_form.dart +++ b/lib/proxmox_general_settings_form.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:proxmox_login_manager/proxmox_general_settings_model.dart'; +import 'package:local_auth/local_auth.dart'; class ProxmoxGeneralSettingsForm extends StatefulWidget { @override @@ -43,7 +47,40 @@ class _ProxmoxGeneralSettingsFormState ProxmoxGeneralSettingsModel.fromLocalStorage(); }); }, - ) + ), + if (Platform.isAndroid) + SwitchListTile( + title: Text('Biometric lock'), + subtitle: Text('Lock app with fingerprint'), + value: settings.useBiometrics, + onChanged: (value) async { + try { + bool didAuthenticate = await LocalAuthentication() + .authenticateWithBiometrics( + localizedReason: + 'Please authenticate to enable app lock'); + if (didAuthenticate) { + await settings + .rebuild((b) => b.useBiometrics = value) + .toLocalStorage(); + setState(() { + _settings = ProxmoxGeneralSettingsModel + .fromLocalStorage(); + }); + } + } on PlatformException catch (e) { + print(e); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Sensor Error'), + content: + Text('Accessing biometric sensor failed'), + ), + ); + } + }, + ) ], ), ); diff --git a/lib/proxmox_general_settings_model.dart b/lib/proxmox_general_settings_model.dart index 72fc0f7..3f3b9a3 100644 --- a/lib/proxmox_general_settings_model.dart +++ b/lib/proxmox_general_settings_model.dart @@ -11,6 +11,7 @@ abstract class ProxmoxGeneralSettingsModel implements Built { bool get sslValidation; + bool get useBiometrics; ProxmoxGeneralSettingsModel._(); factory ProxmoxGeneralSettingsModel( @@ -18,8 +19,15 @@ abstract class ProxmoxGeneralSettingsModel _$ProxmoxGeneralSettingsModel; factory ProxmoxGeneralSettingsModel.defaultValues() => - ProxmoxGeneralSettingsModel((b) => b..sslValidation = true); - + ProxmoxGeneralSettingsModel( + (b) => b + ..sslValidation = true + ..useBiometrics = false, + ); + static void _initializeBuilder(ProxmoxGeneralSettingsModelBuilder builder) => + builder + ..sslValidation = true + ..useBiometrics = false; Object toJson() { return serializers.serializeWith( ProxmoxGeneralSettingsModel.serializer, this); diff --git a/lib/proxmox_login_form.dart b/lib/proxmox_login_form.dart index 3115ffd..50afb3a 100644 --- a/lib/proxmox_login_form.dart +++ b/lib/proxmox_login_form.dart @@ -556,8 +556,10 @@ class _ProxmoxLoginPageState extends State { } class ProxmoxProgressOverlay extends StatelessWidget { + final Color foregroundColor; const ProxmoxProgressOverlay({ Key key, + Color this.foregroundColor, @required this.message, }) : super(key: key); @@ -574,13 +576,16 @@ class ProxmoxProgressOverlay extends StatelessWidget { Text( message, style: TextStyle( - color: Theme.of(context).accentColor, + color: foregroundColor ?? Theme.of(context).accentColor, fontSize: 20, ), ), Padding( padding: const EdgeInsets.only(top: 20.0), - child: CircularProgressIndicator(), + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + foregroundColor ?? Theme.of(context).accentColor), + ), ) ], ), diff --git a/lib/proxmox_login_model.dart b/lib/proxmox_login_model.dart index af7c4fd..067e92f 100644 --- a/lib/proxmox_login_model.dart +++ b/lib/proxmox_login_model.dart @@ -44,8 +44,10 @@ abstract class ProxmoxLoginStorage } Future recoverLatestSession() async { - final latestSession = logins.singleWhere((e) => e.ticket.isNotEmpty); final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage(); + if (settings.useBiometrics) + throw Exception('Biometric authentication required'); + final latestSession = logins.singleWhere((e) => e.ticket.isNotEmpty); final apiClient = await proxclient.authenticate(latestSession.fullUsername, latestSession.ticket, latestSession.origin, settings.sslValidation); return apiClient; diff --git a/lib/proxmox_login_selector.dart b/lib/proxmox_login_selector.dart index f6ca2fa..fc8dda9 100644 --- a/lib/proxmox_login_selector.dart +++ b/lib/proxmox_login_selector.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:local_auth/local_auth.dart'; import 'package:proxmox_login_manager/proxmox_general_settings_form.dart'; +import 'package:proxmox_login_manager/proxmox_general_settings_model.dart'; import 'package:proxmox_login_manager/proxmox_login_form.dart'; import 'package:proxmox_login_manager/proxmox_login_model.dart'; import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart' @@ -19,12 +22,44 @@ class ProxmoxLoginSelector extends StatefulWidget { class _ProxmoxLoginSelectorState extends State { Future loginStorage; + Future authenticated; + final LocalAuthentication auth = LocalAuthentication(); + bool _isAuthenticating = false; + @override void initState() { super.initState(); + authenticated = _authenticate(); loginStorage = ProxmoxLoginStorage.fromLocalStorage(); } + Future _authenticate() async { + final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage(); + + if (!settings.useBiometrics) return true; + + var authenticated = false; + + try { + setState(() { + _isAuthenticating = true; + }); + while (!authenticated) { + authenticated = await auth.authenticateWithBiometrics( + localizedReason: 'Scan your fingerprint to authenticate', + useErrorDialogs: true, + stickyAuth: true); + } + setState(() { + _isAuthenticating = false; + }); + } on PlatformException catch (e) { + print(e); + } + if (!mounted) return authenticated; + return authenticated; + } + @override Widget build(BuildContext context) { return SafeArea( @@ -57,103 +92,120 @@ class _ProxmoxLoginSelectorState extends State { }) ], ), - body: FutureBuilder( - future: loginStorage, + body: FutureBuilder( + future: authenticated, builder: (context, snapshot) { - if (!snapshot.hasData) { - return Center( - child: CircularProgressIndicator(), - ); - } - if (snapshot.hasData && (snapshot.data.logins?.isEmpty ?? true)) { - return Center( - child: Text('Add an account'), + if (_isAuthenticating || !snapshot.hasData || !snapshot.data) { + return ProxmoxProgressOverlay( + message: 'Waiting for authentication', + foregroundColor: Colors.white, ); } - var items = []; - final logins = snapshot.data?.logins; + return FutureBuilder( + future: loginStorage, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasData && + (snapshot.data.logins?.isEmpty ?? true)) { + return Center( + child: Text('Add an account'), + ); + } + var items = []; + final logins = snapshot.data?.logins; - final activeSessions = - logins.rebuild((b) => b.where((b) => b.activeSession)); + final activeSessions = + logins.rebuild((b) => b.where((b) => b.activeSession)); - if (activeSessions.isNotEmpty) { - items.addAll([ - Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - 'Active Sessions', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + if (activeSessions.isNotEmpty) { + items.addAll([ + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + 'Active Sessions', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ...activeSessions.map((s) => ListTile( + title: Text(s.fullHostname), + subtitle: Text(s.fullUsername), + trailing: Icon(Icons.navigate_next), + leading: PopupMenuButton( + icon: Icon(Icons.more_vert, + color: Colors.green), + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + dense: true, + leading: Icon(Icons.logout), + title: Text('Logout'), + onTap: () async { + await snapshot.data + .rebuild((b) => b.logins + .rebuildWhere( + (m) => s == m, + (b) => + b..ticket = '')) + .saveToDisk(); + refreshFromStorage(); + Navigator.of(context).pop(); + }, + ), + ), + ]), + onTap: () => _login(user: s), + )), + ]); + } + items.addAll([ + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + 'Available Sites', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), ), - ), - ), - ...activeSessions.map((s) => ListTile( - title: Text(s.fullHostname), - subtitle: Text(s.fullUsername), - trailing: Icon(Icons.navigate_next), - leading: PopupMenuButton( - icon: Icon(Icons.more_vert, color: Colors.green), - itemBuilder: (context) => [ - PopupMenuItem( - child: ListTile( - dense: true, - leading: Icon(Icons.logout), - title: Text('Logout'), - onTap: () async { - await snapshot.data - .rebuild((b) => b.logins - .rebuildWhere((m) => s == m, - (b) => b..ticket = '')) - .saveToDisk(); - refreshFromStorage(); - Navigator.of(context).pop(); - }, - ), - ), - ]), - onTap: () => _login(user: s), - )), - ]); - } - items.addAll([ - Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - 'Available Sites', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ...logins.where((b) => !b.activeSession)?.map((l) => ListTile( - title: Text(l.fullHostname), - subtitle: Text(l.fullUsername), - trailing: Icon(Icons.navigate_next), - leading: PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - child: ListTile( - dense: true, - leading: Icon(Icons.delete), - title: Text('Delete'), - onTap: () async { - await snapshot.data - .rebuild((b) => b.logins.remove(l)) - .saveToDisk(); - refreshFromStorage(); - Navigator.of(context).pop(); - }, - ), - ), - ]), - onTap: () => _login(user: l), - )) - ]); - return ListView( - children: items, - ); + ...logins + .where((b) => !b.activeSession) + ?.map((l) => ListTile( + title: Text(l.fullHostname), + subtitle: Text(l.fullUsername), + trailing: Icon(Icons.navigate_next), + leading: PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + dense: true, + leading: Icon(Icons.delete), + title: Text('Delete'), + onTap: () async { + await snapshot.data + .rebuild((b) => + b.logins.remove(l)) + .saveToDisk(); + refreshFromStorage(); + Navigator.of(context).pop(); + }, + ), + ), + ]), + onTap: () => _login(user: l), + )) + ]); + return ListView( + children: items, + ); + }); }), floatingActionButton: FloatingActionButton.extended( onPressed: () => _login(isCreate: true), diff --git a/pubspec.lock b/pubspec.lock index b0bf674..58794ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -209,6 +209,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.11" flutter_test: dependency: "direct dev" description: flutter @@ -289,6 +296,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3+2" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dc11ec8..2b5f5a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: shared_preferences: '>=0.5.10 <2.0.0' built_value: ^7.1.0 built_collection: ^4.3.2 + local_auth: ^0.6.3+2 proxmox_dart_api_client: path: ../proxmox_dart_api_client -- 2.20.1