[pve-devel] [PATCH v2 dart-client] switch to new authentication API

Wolfgang Bumiller w.bumiller at proxmox.com
Tue Dec 14 10:08:13 CET 2021


and decode the tfa challenge

Signed-off-by: Wolfgang Bumiller <w.bumiller at 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






More information about the pve-devel mailing list