all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH v2 dart-client] switch to new authentication API
@ 2021-12-14  9:08 Wolfgang Bumiller
  2021-12-14  9:08 ` [pve-devel] [PATCH v2 dart-login-manager] support new TFA login flow Wolfgang Bumiller
  0 siblings, 1 reply; 2+ messages in thread
From: Wolfgang Bumiller @ 2021-12-14  9:08 UTC (permalink / raw)
  To: pve-devel

and decode the tfa challenge

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
Changes to v1:
  * Support the old login API as a fallback
    For this the API response's error is checked for whether it
    considered the 'new-format' parameter to be an error.
  * The TfaChallenge object now has an 'oldApi' member which is set if
    the `NeedTFA` property was returned from the ticket call if the
    check for the new-style TFA ticket fails.
  * The 'type' parameter in the finishTfaChallenge() & tfaChallenge()
    methods is now optional and may be null if `challenge.oldApi` is
    set.

 lib/src/authenticate.dart           | 16 ++++++-
 lib/src/client.dart                 |  8 +---
 lib/src/credentials.dart            | 66 ++++++++++++++++++++++++++---
 lib/src/handle_ticket_response.dart | 20 +++++++--
 lib/src/tfa_challenge.dart          | 40 +++++++++++++++++
 5 files changed, 133 insertions(+), 17 deletions(-)
 create mode 100644 lib/src/tfa_challenge.dart

diff --git a/lib/src/authenticate.dart b/lib/src/authenticate.dart
index 5bbcfc4..902ae56 100644
--- a/lib/src/authenticate.dart
+++ b/lib/src/authenticate.dart
@@ -25,7 +25,7 @@ Future<ProxmoxApiClient> authenticate(
 }) async {
   httpClient ??= getCustomIOHttpClient(validateSSL: validateSSL);
 
-  var body = {'username': username, 'password': password};
+  var body = {'username': username, 'password': password, 'new-format': '1'};
 
   try {
     var credentials = Credentials(apiBaseUrl, username);
@@ -33,6 +33,20 @@ Future<ProxmoxApiClient> authenticate(
     var response = await httpClient
         .post(credentials.ticketUrl, body: body)
         .timeout(Duration(seconds: 5));
+    try {
+      credentials = handleAccessTicketResponse(response, credentials);
+    } on ProxmoxApiException catch(e) {
+      if (e.details?['new-format'] != null) {
+        // retry with old api
+        body.remove('new-format');
+        response = await httpClient
+          .post(credentials.ticketUrl, body: body)
+          .timeout(Duration(seconds: 5));
+        credentials = handleAccessTicketResponse(response, credentials);
+      } else {
+        rethrow;
+      }
+    }
 
     credentials = handleAccessTicketResponse(response, credentials);
 
diff --git a/lib/src/client.dart b/lib/src/client.dart
index 6c12191..f2f5853 100644
--- a/lib/src/client.dart
+++ b/lib/src/client.dart
@@ -92,12 +92,8 @@ class ProxmoxApiClient extends http.BaseClient {
     return this;
   }
 
-  Future<ProxmoxApiClient> finishTfaChallenge(String code) async {
-    if (!credentials.tfa) {
-      throw StateError('No tfa challange expected');
-    }
-
-    credentials = await credentials.tfaChallenge(code, httpClient: this);
+  Future<ProxmoxApiClient> finishTfaChallenge(String? type, String code) async {
+    credentials = await credentials.tfaChallenge(type, code, httpClient: this);
 
     return this;
   }
diff --git a/lib/src/credentials.dart b/lib/src/credentials.dart
index fe75e63..e84bc85 100644
--- a/lib/src/credentials.dart
+++ b/lib/src/credentials.dart
@@ -1,6 +1,7 @@
 import 'package:http/http.dart' as http;
 
 import 'package:proxmox_dart_api_client/src/handle_ticket_response.dart';
+import 'package:proxmox_dart_api_client/src/tfa_challenge.dart';
 import 'package:proxmox_dart_api_client/src/utils.dart'
     if (dart.library.html) 'utils_web.dart'
     if (dart.library.io) 'utils_native.dart';
@@ -20,7 +21,7 @@ class Credentials {
 
   final DateTime? expiration;
 
-  bool tfa;
+  final TfaChallenge? tfa;
 
   bool get canRefresh => ticket != null;
 
@@ -38,7 +39,7 @@ class Credentials {
     this.ticket,
     this.csrfToken,
     this.expiration,
-    this.tfa = false,
+    this.tfa = null,
   });
 
   Future<Credentials> refresh({http.Client? httpClient}) async {
@@ -59,13 +60,66 @@ class Credentials {
     return credentials;
   }
 
-  Future<Credentials> tfaChallenge(String code,
+  Future<Credentials> tfaChallenge(String? type, String code,
       {http.Client? httpClient}) async {
-    httpClient ??= getCustomIOHttpClient();
 
-    final body = {'response': code};
+    if (tfa == null) {
+      throw StateError('No tfa challange expected');
+    }
+    var tmp = this.tfa!;
+
+    var body;
+    var url;
+    if (tmp.oldApi) {
+      url = tfaUrl;
+      body = {'response': code};
+    } else {
+      if (type == null) {
+        throw StateError('No tfa type provided with new login api');
+      }
+
+      switch (type!) {
+        case 'totp':
+          if (!tmp.totp) {
+            throw StateError("Totp challenge not available");
+          }
+          break;
+        case 'yubico':
+          if (!tmp.yubico) {
+            throw StateError("Yubico challenge not available");
+          }
+          break;
+        case 'recovery':
+          if (tmp.recovery.isEmpty) {
+            throw StateError("No recovery keys available");
+          }
+          break;
+        case 'u2f':
+          if (tmp.u2f == null) {
+            throw StateError("U2F challenge not available");
+          }
+          break;
+        case 'webauthn':
+          if (tmp.webauthn == null) {
+            throw StateError("Webauthn challenge not available");
+          }
+          break;
+        default:
+          throw StateError("unsupported tfa response type used");
+      }
+
+      url = ticketUrl;
+      body = {
+        'username': username,
+        'password': '${type}:${code}',
+        'tfa-challenge': ticket,
+        'new-format': '1',
+      };
+    }
+
+    httpClient ??= getCustomIOHttpClient();
 
-    final response = await httpClient.post(tfaUrl, body: body);
+    final response = await httpClient.post(url, body: body);
 
     final credentials = handleTfaChallengeResponse(response, this);
 
diff --git a/lib/src/handle_ticket_response.dart b/lib/src/handle_ticket_response.dart
index adcb3b1..4224753 100644
--- a/lib/src/handle_ticket_response.dart
+++ b/lib/src/handle_ticket_response.dart
@@ -2,10 +2,13 @@ import 'dart:convert';
 import 'package:http/http.dart' as http;
 import 'package:proxmox_dart_api_client/src/credentials.dart';
 import 'package:proxmox_dart_api_client/src/extentions.dart';
+import 'package:proxmox_dart_api_client/src/tfa_challenge.dart';
 
 Credentials handleAccessTicketResponse(
     http.Response response, Credentials unauthenicatedCredentials) {
-  response.validate(false);
+  // Full validation as we want to check for 'new-format' being unsupported via
+  // the exception's 'details'.
+  response.validate(true);
 
   final bodyJson = jsonDecode(response.body)['data'];
 
@@ -19,8 +22,18 @@ Credentials handleAccessTicketResponse(
   final time = DateTime.fromMillisecondsSinceEpoch(
       int.parse(ticketRegex.group(3)!, radix: 16) * 1000);
 
-  final tfa =
-      bodyJson['NeedTFA'] != null && bodyJson['NeedTFA'] == 1 ? true : false;
+  final ticketData = ticketRegex.group(2);
+
+  TfaChallenge? tfa = null;
+  if (ticketData != null && ticketData.startsWith("!tfa!")) {
+    tfa = TfaChallenge.fromJson(
+      jsonDecode(
+        Uri.decodeComponent(ticketData.substring(5)),
+      ),
+    );
+  } else if (bodyJson['NeedTFA'] != null && bodyJson['NeedTFA'] == 1) {
+    tfa = TfaChallenge.old();
+  }
 
   return Credentials(
     unauthenicatedCredentials.apiBaseUrl,
@@ -52,6 +65,5 @@ Credentials handleTfaChallengeResponse(
     ticket: ticket,
     csrfToken: pendingTfaCredentials.csrfToken,
     expiration: time,
-    tfa: false,
   );
 }
diff --git a/lib/src/tfa_challenge.dart b/lib/src/tfa_challenge.dart
new file mode 100644
index 0000000..0d1d868
--- /dev/null
+++ b/lib/src/tfa_challenge.dart
@@ -0,0 +1,40 @@
+class TfaChallenge {
+  final bool totp;
+  final List<int> recovery;
+  final bool yubico;
+  final dynamic? u2f;
+  final dynamic? webauthn;
+
+  final bool oldApi;
+
+  TfaChallenge(
+    this.totp,
+    this.recovery,
+    this.yubico, {
+    this.u2f = null,
+    this.webauthn = null,
+    this.oldApi = false,
+  });
+
+  TfaChallenge.old()
+    : totp = false
+    , recovery = []
+    , yubico = false
+    , u2f = null
+    , webauthn = null
+    , oldApi = true
+    ;
+
+  TfaChallenge.fromJson(Map<String, dynamic> data)
+    : totp = data['totp'] ?? false
+    , yubico = data['yubico'] ?? false
+    , recovery = (
+        data['recovery'] != null
+          ? List<int>.from(data['recovery'].map((x) => x))
+          : []
+      )
+    , u2f = data['u2f']
+    , webauthn = data['webauthn']
+    , oldApi = false
+    ;
+}
-- 
2.30.2





^ permalink raw reply	[flat|nested] 2+ messages in thread

* [pve-devel] [PATCH v2 dart-login-manager] support new TFA login flow
  2021-12-14  9:08 [pve-devel] [PATCH v2 dart-client] switch to new authentication API Wolfgang Bumiller
@ 2021-12-14  9:08 ` Wolfgang Bumiller
  0 siblings, 0 replies; 2+ messages in thread
From: Wolfgang Bumiller @ 2021-12-14  9:08 UTC (permalink / raw)
  To: pve-devel

For now this just shows a simple dialog to select the TFA type, this
can probably be switched to using tabs or a dropdown or something.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
Changes to v1:
  * Support the old TFA API by checking if the tfa challenge's `oldApi`
    property is set. Therefore the tfaType property of the tfa form is
    now optional.

 lib/proxmox_login_form.dart | 92 +++++++++++++++++++++++++++++++++----
 lib/proxmox_tfa_form.dart   | 17 +++++--
 2 files changed, 97 insertions(+), 12 deletions(-)

diff --git a/lib/proxmox_login_form.dart b/lib/proxmox_login_form.dart
index 59a9f61..32f8741 100644
--- a/lib/proxmox_login_form.dart
+++ b/lib/proxmox_login_form.dart
@@ -398,12 +398,85 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
       var client = await proxclient.authenticate(
           '$username@$realm', password, origin, settings.sslValidation!);
 
-      if (client.credentials.tfa) {
-        client = await Navigator.of(context).push(MaterialPageRoute(
-          builder: (context) => ProxmoxTfaForm(
-            apiClient: client,
-          ),
-        ));
+      if (client.credentials.tfa != null) {
+        var tfa = client.credentials.tfa!;
+
+        if (tfa.oldApi) {
+          client = await Navigator.of(context).push(MaterialPageRoute(
+            builder: (context) => ProxmoxTfaForm(
+              apiClient: client,
+              tfaType: null,
+              message: 'Enter your TFA code',
+            ),
+          ));
+        } else {
+          if (!tfa.totp && !tfa.yubico && tfa.recovery.isEmpty) {
+            return await showDialog<void>(
+              context: context,
+              builder: (context) => AlertDialog(
+                title: Text('TFA Error'),
+                content: Text('No supported TFA method available.'),
+                actions: [
+                  FlatButton(
+                    onPressed: () => Navigator.of(context).pop(),
+                    child: Text('Close'),
+                  ),
+                ],
+              ),
+            );
+          }
+
+          final route = (await showDialog<MaterialPageRoute>(
+            context: context,
+            builder: (BuildContext context) {
+              var buttons = <Widget>[];
+
+              void simpleTfa(String label, String tfaType, String message) {
+                buttons.add(
+                  SimpleDialogOption(
+                    onPressed: () => Navigator.pop(
+                      context,
+                      MaterialPageRoute(
+                        builder: (context) => ProxmoxTfaForm(
+                          apiClient: client,
+                          tfaType: tfaType,
+                          message: message,
+                        ),
+                      ),
+                    ),
+                    child: Row(
+                      mainAxisAlignment: MainAxisAlignment.center,
+                      crossAxisAlignment: CrossAxisAlignment.center,
+                      children: [ Text(label) ],
+                    ),
+                  ),
+                );
+              };
+
+              if (tfa.totp) {
+                simpleTfa('TOTP', 'totp', 'Enter your TOTP code');
+              }
+
+              if (tfa.yubico) {
+                simpleTfa('Yubico OTP', 'yubico', 'Enter your Yubico OTP code');
+              }
+
+              if (!tfa.recovery.isEmpty) {
+                simpleTfa('Recovery Code', 'recovery', 'Enter a Recovery Code');
+              }
+
+              return SimpleDialog(
+                title: const Text("Select 2nd Factor"),
+                children: buttons,
+              );
+            }
+          ));
+
+          if (route == null)
+            return;
+
+          client = await Navigator.of(context).push(route!);
+        }
       }
 
       final status = await client.getClusterStatus();
@@ -466,10 +539,11 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
           builder: (context) => ProxmoxCertificateErrorDialog(),
         );
       }
+    } finally {
+      setState(() {
+        _progressModel.inProgress = false;
+      });
     }
-    setState(() {
-      _progressModel.inProgress = false;
-    });
   }
 
   Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
diff --git a/lib/proxmox_tfa_form.dart b/lib/proxmox_tfa_form.dart
index db3cfa7..fcfb0e2 100644
--- a/lib/proxmox_tfa_form.dart
+++ b/lib/proxmox_tfa_form.dart
@@ -1,11 +1,19 @@
 import 'package:flutter/material.dart';
 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
+import 'package:proxmox_dart_api_client/src/tfa_challenge.dart';
 import 'package:proxmox_login_manager/proxmox_login_form.dart';
 
 class ProxmoxTfaForm extends StatefulWidget {
   final ProxmoxApiClient? apiClient;
+  final String? tfaType;
+  final String message;
 
-  const ProxmoxTfaForm({Key? key, this.apiClient}) : super(key: key);
+  const ProxmoxTfaForm({
+    Key? key,
+    this.apiClient,
+    required this.tfaType,
+    required this.message,
+  }) : super(key: key);
 
   @override
   _ProxmoxTfaFormState createState() => _ProxmoxTfaFormState();
@@ -57,7 +65,7 @@ class _ProxmoxTfaFormState extends State<ProxmoxTfaForm> {
                             fontWeight: FontWeight.bold),
                       ),
                       Text(
-                        'Check your second factor provider',
+                        widget.message,
                         style: TextStyle(
                             color: Colors.white38, fontWeight: FontWeight.bold),
                       ),
@@ -108,7 +116,10 @@ class _ProxmoxTfaFormState extends State<ProxmoxTfaForm> {
     });
     try {
       final client =
-          await widget.apiClient!.finishTfaChallenge(_codeController.text);
+          await widget.apiClient!.finishTfaChallenge(
+            widget.tfaType,
+            _codeController.text,
+          );
       Navigator.of(context).pop(client);
     } on ProxmoxApiException catch (e) {
       showDialog(
-- 
2.30.2





^ permalink raw reply	[flat|nested] 2+ messages in thread

end of thread, other threads:[~2021-12-14  9:08 UTC | newest]

Thread overview: 2+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2021-12-14  9:08 [pve-devel] [PATCH v2 dart-client] switch to new authentication API Wolfgang Bumiller
2021-12-14  9:08 ` [pve-devel] [PATCH v2 dart-login-manager] support new TFA login flow Wolfgang Bumiller

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