From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <w.bumiller@proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by lists.proxmox.com (Postfix) with ESMTPS id 485B184805
 for <pve-devel@lists.proxmox.com>; Tue, 14 Dec 2021 10:08:53 +0100 (CET)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id 4396621551
 for <pve-devel@lists.proxmox.com>; Tue, 14 Dec 2021 10:08:23 +0100 (CET)
Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com
 [94.136.29.106])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by firstgate.proxmox.com (Proxmox) with ESMTPS id 2CD1821545
 for <pve-devel@lists.proxmox.com>; Tue, 14 Dec 2021 10:08:22 +0100 (CET)
Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1])
 by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 02B7144F19
 for <pve-devel@lists.proxmox.com>; Tue, 14 Dec 2021 10:08:16 +0100 (CET)
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pve-devel@lists.proxmox.com
Date: Tue, 14 Dec 2021 10:08:13 +0100
Message-Id: <20211214090814.45225-1-w.bumiller@proxmox.com>
X-Mailer: git-send-email 2.30.2
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.413 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_PASS               -0.001 SPF: sender matches SPF record
Subject: [pve-devel] [PATCH v2 dart-client] switch to new authentication API
X-BeenThere: pve-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox VE development discussion <pve-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pve-devel/>
List-Post: <mailto:pve-devel@lists.proxmox.com>
List-Help: <mailto:pve-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=subscribe>
X-List-Received-Date: Tue, 14 Dec 2021 09:08:53 -0000

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