all lists on 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal