public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Tim Marx <t.marx@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH proxmox_login_manager] add option for local biometric authentication
Date: Wed, 30 Sep 2020 12:32:43 +0200	[thread overview]
Message-ID: <20200930103243.1200300-2-t.marx@proxmox.com> (raw)
In-Reply-To: <20200930103243.1200300-1-t.marx@proxmox.com>

Signed-off-by: Tim Marx <t.marx@proxmox.com>
---
 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<ProxmoxGeneralSettingsModel, ProxmoxGeneralSettingsModelBuilder> {
   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<ProxmoxLoginPage> {
 }
 
 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<Color>(
+                    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<proxclient.ProxmoxApiClient> 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<ProxmoxLoginSelector> {
   Future<ProxmoxLoginStorage> loginStorage;
+  Future<bool> authenticated;
+  final LocalAuthentication auth = LocalAuthentication();
+  bool _isAuthenticating = false;
+
   @override
   void initState() {
     super.initState();
+    authenticated = _authenticate();
     loginStorage = ProxmoxLoginStorage.fromLocalStorage();
   }
 
+  Future<bool> _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<ProxmoxLoginSelector> {
                 })
           ],
         ),
-        body: FutureBuilder<ProxmoxLoginStorage>(
-            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 = <Widget>[];
-              final logins = snapshot.data?.logins;
+              return FutureBuilder<ProxmoxLoginStorage>(
+                  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 = <Widget>[];
+                    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




  reply	other threads:[~2020-09-30 10:32 UTC|newest]

Thread overview: 3+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2020-09-30 10:32 [pve-devel] [PATCH pve_flutter_frontend] add changes to support " Tim Marx
2020-09-30 10:32 ` Tim Marx [this message]
2020-11-12 15:39 ` Aaron Lauterer

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20200930103243.1200300-2-t.marx@proxmox.com \
    --to=t.marx@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal