]> git.proxmox.com Git - flutter/proxmox_login_manager.git/commitdiff
optionally save passwords with biometric storage
authorDominik Csapak <d.csapak@proxmox.com>
Tue, 13 Sep 2022 13:11:30 +0000 (15:11 +0200)
committerDominik Csapak <d.csapak@proxmox.com>
Fri, 21 Apr 2023 12:19:23 +0000 (14:19 +0200)
uses 'biometric_storage' to save passwords secured by the platform
biometric method (e.g. fingerprint).

fingerprint is necessary when saving the password, as well as everytime
we need to read the password, e.g. selecting a login with a saved
password from the list.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
lib/proxmox_login_form.dart
lib/proxmox_login_model.dart
lib/proxmox_login_selector.dart
lib/proxmox_password_store.dart [new file with mode: 0644]
pubspec.yaml

index d807da4f2836285b70ff50e6430e65425b0a0d93..c275143949ab9bcae9df3afd7b0cf2828868b029 100644 (file)
@@ -1,6 +1,7 @@
 import 'dart:io';
 import 'dart:async';
 
+import 'package:biometric_storage/biometric_storage.dart';
 import 'package:flutter/material.dart';
 import 'package:collection/src/iterable_extensions.dart';
 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
@@ -11,6 +12,7 @@ import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
 import 'package:proxmox_login_manager/proxmox_login_model.dart';
 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
 import 'package:proxmox_login_manager/extension.dart';
+import 'package:proxmox_login_manager/proxmox_password_store.dart';
 
 class ProxmoxProgressModel {
   int inProgress = 0;
@@ -38,6 +40,9 @@ class ProxmoxLoginForm extends StatefulWidget {
   final ValueChanged<PveAccessDomainModel?> onDomainChanged;
   final Function? onPasswordSubmitted;
   final Function? onOriginSubmitted;
+  final Function? onSavePasswordChanged;
+  final bool? canSavePassword;
+  final bool? passwordSaved;
 
   const ProxmoxLoginForm({
     Key? key,
@@ -50,6 +55,9 @@ class ProxmoxLoginForm extends StatefulWidget {
     required this.onDomainChanged,
     this.onPasswordSubmitted,
     this.onOriginSubmitted,
+    this.onSavePasswordChanged,
+    this.canSavePassword,
+    this.passwordSaved,
   }) : super(key: key);
 
   @override
@@ -58,6 +66,7 @@ class ProxmoxLoginForm extends StatefulWidget {
 
 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
   bool _obscure = true;
+  bool? _savePwCheckbox;
   FocusNode? passwordFocusNode;
 
   @override
@@ -151,6 +160,19 @@ class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
               )
             ],
           ),
+          if (widget.canSavePassword ?? false)
+            CheckboxListTile(
+              title: const Text('Save password'),
+              value: _savePwCheckbox ?? widget.passwordSaved ?? false,
+              onChanged: (value) {
+                if (widget.onSavePasswordChanged != null) {
+                  widget.onSavePasswordChanged!(value!);
+                }
+                setState(() {
+                  _savePwCheckbox = value!;
+                });
+              },
+            )
         ],
       ),
     );
@@ -167,12 +189,14 @@ class ProxmoxLoginPage extends StatefulWidget {
   final ProxmoxLoginModel? userModel;
   final bool? isCreate;
   final String? ticket;
+  final String? password;
 
   const ProxmoxLoginPage({
     Key? key,
     this.userModel,
     this.isCreate,
     this.ticket = '',
+    this.password,
   }) : super(key: key);
   @override
   _ProxmoxLoginPageState createState() => _ProxmoxLoginPageState();
@@ -187,6 +211,8 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
   final _formKey = GlobalKey<FormState>();
   ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
   bool _submittButtonEnabled = true;
+  bool _canSavePassword = false;
+  bool _savePasswordCB = false;
 
   @override
   void initState() {
@@ -195,9 +221,12 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
     _progressModel = ProxmoxProgressModel();
     if (!widget.isCreate! && userModel != null) {
       _originController.text = userModel.origin?.toString() ?? '';
+      _passwordController.text = widget.password ?? '';
       _accessDomains = _getAccessDomains();
       _usernameController.text = userModel.username!;
-      if (widget.ticket!.isNotEmpty && userModel.activeSession) {
+      _savePasswordCB = widget.password != null;
+      if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
+          widget.password != null) {
         _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
       }
     }
@@ -215,6 +244,7 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
             disabledBackgroundColor: Colors.grey,
           ),
         ),
+        toggleableActiveColor: ProxmoxColors.orange,
         colorScheme: ColorScheme.dark().copyWith(
             primary: ProxmoxColors.orange,
             secondary: ProxmoxColors.orange,
@@ -287,6 +317,11 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
                                   passwordController: _passwordController,
                                   accessDomains: snapshot.data,
                                   selectedDomain: _selectedDomain,
+                                  onSavePasswordChanged: (value) {
+                                    _savePasswordCB = value;
+                                  },
+                                  canSavePassword: _canSavePassword,
+                                  passwordSaved: widget.password != null,
                                   onDomainChanged: (value) {
                                     setState(() {
                                       _selectedDomain = value;
@@ -378,12 +413,13 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
 
     try {
       final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
-
       //cleaned form fields
       final origin = normalizeUrl(_originController.text.trim());
       final username = _usernameController.text.trim();
-      final password =
-          ticket.isNotEmpty ? ticket : _passwordController.text.trim();
+      final String enteredPassword = _passwordController.text.trim();
+      final String? savedPassword = widget.password;
+
+      final password = ticket.isNotEmpty ? ticket : enteredPassword;
       final realm = _selectedDomain?.realm ?? mRealm;
 
       var client = await proxclient.authenticate(
@@ -402,6 +438,12 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
           status.singleWhereOrNull((element) => element.local ?? false)?.name;
       var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
 
+      final savePW = enteredPassword != '' &&
+          _savePasswordCB &&
+          enteredPassword != savedPassword;
+      final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
+      String? id;
+
       if (widget.isCreate!) {
         final newLogin = ProxmoxLoginModel((b) => b
           ..origin = origin
@@ -409,16 +451,29 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
           ..realm = realm
           ..productType = ProxmoxProductType.pve
           ..ticket = client.credentials.ticket
+          ..passwordSaved = savePW
           ..hostname = hostname);
 
         loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
+        id = newLogin.identifier;
       } else {
         loginStorage = loginStorage!.rebuild((b) => b
           ..logins.rebuildWhere(
               (m) => m == widget.userModel,
               (b) => b
                 ..ticket = client.credentials.ticket
+                ..passwordSaved =
+                    savePW || (deletePW ? false : b.passwordSaved ?? false)
                 ..hostname = hostname));
+        id = widget.userModel!.identifier;
+      }
+
+      if (id != null) {
+        if (savePW) {
+          await savePassword(id, enteredPassword);
+        } else if (deletePW) {
+          await deletePassword(id);
+        }
       }
       await loginStorage.saveToDisk();
 
@@ -469,6 +524,12 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
         ..inProgress += 1
         ..message = 'Connection test...';
     });
+
+    final canSavePW = await BiometricStorage().canAuthenticate();
+    setState(() {
+      _canSavePassword = canSavePW == CanAuthenticateResponse.success;
+    });
+
     var host = _originController.text.trim();
     var apiBaseUrl = normalizeUrl(host);
 
index ac10b222a9295ab7ac5c9aa555aac4802cef2f2a..6e9b0e037494b6e9e9e644a19e9b0e616de3088c 100644 (file)
@@ -66,6 +66,8 @@ abstract class ProxmoxLoginModel
 
   String? get realm;
 
+  bool? get passwordSaved;
+
   ProxmoxProductType? get productType;
 
   String? get ticket;
@@ -93,6 +95,16 @@ abstract class ProxmoxLoginModel
     return '${location.host} - $hostname';
   }
 
+  String? get identifier {
+    if (origin == null) {
+      return null;
+    }
+
+    var host = origin!.host;
+    var port = origin!.port;
+    return '$username@$realm@$host:$port';
+  }
+
   ProxmoxLoginModel._();
 
   factory ProxmoxLoginModel(
index 2ad51ae8eead50ff8348d0eec8d29a356bd1b32a..e1af146caa85560815765e80c842a2581d6055f8 100644 (file)
@@ -6,6 +6,7 @@ import 'package:proxmox_login_manager/proxmox_login_model.dart';
 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
     as proxclient;
 import 'package:proxmox_login_manager/extension.dart';
+import 'package:proxmox_login_manager/proxmox_password_store.dart';
 
 typedef OnLoginCallback = Function(proxclient.ProxmoxApiClient client);
 
@@ -131,20 +132,44 @@ class _ProxmoxLoginSelectorState extends State<ProxmoxLoginSelector> {
                     ),
                   ),
                 ),
-                ...logins.where((b) => !b.activeSession).map((l) => ListTile(
-                      title: Text(l.fullHostname),
-                      subtitle: Text(l.fullUsername),
+                ...logins.where((b) => !b.activeSession).map((login) =>
+                    ListTile(
+                      title: Text(login.fullHostname),
+                      subtitle: Text(login.fullUsername),
                       trailing: Icon(Icons.navigate_next),
                       leading: PopupMenuButton(
                           itemBuilder: (context) => [
+                                if (login.passwordSaved ?? false)
+                                  PopupMenuItem(
+                                    child: ListTile(
+                                      dense: true,
+                                      leading: Icon(Icons.key_off),
+                                      title: Text('Delete Password'),
+                                      onTap: () async {
+                                        await deletePassword(login.identifier!);
+
+                                        await snapshot.data!
+                                            .rebuild((b) => b
+                                              ..logins.rebuildWhere(
+                                                  (m) => m == login,
+                                                  (b) =>
+                                                      b..passwordSaved = false))
+                                            .saveToDisk();
+                                        refreshFromStorage();
+                                        Navigator.of(context).pop();
+                                      },
+                                    ),
+                                  ),
                                 PopupMenuItem(
                                   child: ListTile(
                                     dense: true,
                                     leading: Icon(Icons.delete),
                                     title: Text('Delete'),
                                     onTap: () async {
+                                      await deletePassword(login.identifier!);
                                       await snapshot.data!
-                                          .rebuild((b) => b.logins.remove(l))
+                                          .rebuild(
+                                              (b) => b.logins.remove(login))
                                           .saveToDisk();
                                       refreshFromStorage();
                                       Navigator.of(context).pop();
@@ -152,7 +177,7 @@ class _ProxmoxLoginSelectorState extends State<ProxmoxLoginSelector> {
                                   ),
                                 ),
                               ]),
-                      onTap: () => _login(user: l),
+                      onTap: () => _login(user: login),
                     ))
               ]);
               return ListView(
@@ -169,11 +194,20 @@ class _ProxmoxLoginSelectorState extends State<ProxmoxLoginSelector> {
   }
 
   Future<void> _login({ProxmoxLoginModel? user, bool isCreate = false}) async {
+    String? password;
+    String ticket = user?.ticket ?? '';
+    bool passwordSaved = user != null && (user.passwordSaved ?? false);
+
+    if (ticket == '' && passwordSaved) {
+      password = await getPassword(user.identifier!);
+    }
+
     final client = await Navigator.of(context).push(MaterialPageRoute(
         builder: (context) => ProxmoxLoginPage(
               userModel: user,
               isCreate: isCreate,
               ticket: user?.ticket,
+              password: password,
             )));
     refreshFromStorage();
     if (client != null) {
diff --git a/lib/proxmox_password_store.dart b/lib/proxmox_password_store.dart
new file mode 100644 (file)
index 0000000..20787e4
--- /dev/null
@@ -0,0 +1,30 @@
+import 'package:biometric_storage/biometric_storage.dart';
+
+Future<bool> canSavePassword() async {
+  return await BiometricStorage().canAuthenticate() ==
+      CanAuthenticateResponse.success;
+}
+
+Future<void> savePassword(String id, String password) async {
+  if (await canSavePassword()) {
+    BiometricStorageFile store = await BiometricStorage().getStorage(id);
+    await store.write(password);
+  }
+}
+
+Future<String?> getPassword(String id) async {
+  String? password;
+  if (await canSavePassword()) {
+    BiometricStorageFile store = await BiometricStorage().getStorage(id);
+    password = await store.read();
+  }
+
+  return password;
+}
+
+Future<void> deletePassword(String id) async {
+  if (await canSavePassword()) {
+    BiometricStorageFile store = await BiometricStorage().getStorage(id);
+    await store.delete();
+  }
+}
index b55b3aa3bdd69efca0b2ee562aeb4f8802b00842..c4835611e46d6a2cfb195402da911d58406891cc 100644 (file)
@@ -10,6 +10,7 @@ dependencies:
   flutter:
     sdk: flutter
   shared_preferences: ^2.0.15
+  biometric_storage: ^4.1.3
   built_value: ^8.4.1
   built_collection: ^5.0.0
   proxmox_dart_api_client: