From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: 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 684068460A for ; Mon, 13 Dec 2021 13:24:38 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 5ED7317B91 for ; Mon, 13 Dec 2021 13:24:08 +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 4B51717B81 for ; Mon, 13 Dec 2021 13:24:07 +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 207AF44BF7 for ; Mon, 13 Dec 2021 13:24:07 +0100 (CET) From: Wolfgang Bumiller To: pve-devel@lists.proxmox.com Date: Mon, 13 Dec 2021 13:24:03 +0100 Message-Id: <20211213122404.84050-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.416 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 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 List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Mon, 13 Dec 2021 12:24:38 -0000 and decode the tfa challenge Signed-off-by: Wolfgang Bumiller --- lib/src/authenticate.dart | 2 +- lib/src/client.dart | 8 +--- lib/src/credentials.dart | 63 ++++++++++++++++++++++++----- lib/src/handle_ticket_response.dart | 13 ++++-- lib/src/tfa_challenge.dart | 27 +++++++++++++ 5 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 lib/src/tfa_challenge.dart diff --git a/lib/src/authenticate.dart b/lib/src/authenticate.dart index 5bbcfc4..e02dd96 100644 --- a/lib/src/authenticate.dart +++ b/lib/src/authenticate.dart @@ -25,7 +25,7 @@ Future 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); diff --git a/lib/src/client.dart b/lib/src/client.dart index 6c12191..9bdfaff 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -92,12 +92,8 @@ class ProxmoxApiClient extends http.BaseClient { return this; } - Future finishTfaChallenge(String code) async { - if (!credentials.tfa) { - throw StateError('No tfa challange expected'); - } - - credentials = await credentials.tfaChallenge(code, httpClient: this); + Future 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..f8746c9 100644 --- a/lib/src/credentials.dart +++ b/lib/src/credentials.dart @@ -1,12 +1,12 @@ 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'; const String ticketPath = '/api2/json/access/ticket'; -const String tfaPath = '/api2/json/access/tfa'; class Credentials { /// The URL of the authorization server @@ -20,7 +20,7 @@ class Credentials { final DateTime? expiration; - bool tfa; + final TfaChallenge? tfa; bool get canRefresh => ticket != null; @@ -30,15 +30,13 @@ class Credentials { Uri get ticketUrl => apiBaseUrl.replace(path: ticketPath); - Uri get tfaUrl => apiBaseUrl.replace(path: tfaPath); - Credentials( this.apiBaseUrl, this.username, { this.ticket, this.csrfToken, this.expiration, - this.tfa = false, + this.tfa = null, }); Future refresh({http.Client? httpClient}) async { @@ -48,7 +46,11 @@ class Credentials { throw ArgumentError("Can't refresh credentials without valid ticket"); } - var body = {'username': username, 'password': ticket}; + var body = { + 'username': username, + 'password': ticket, + 'new-format': '1', + }; var response = await httpClient .post(ticketUrl, body: body) @@ -59,13 +61,56 @@ class Credentials { return credentials; } - Future tfaChallenge(String code, + Future tfaChallenge(String type, String code, {http.Client? httpClient}) async { + + if (tfa == null) { + throw StateError('No tfa challange expected'); + } + + var tmp = this.tfa!; + + 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"); + } + httpClient ??= getCustomIOHttpClient(); - final body = {'response': code}; + var body = { + 'username': username, + 'password': '${type}:${code}', + 'tfa-challenge': ticket, + 'new-format': '1', + }; - final response = await httpClient.post(tfaUrl, body: body); + final response = await httpClient + .post(ticketUrl, 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..94f15cf 100644 --- a/lib/src/handle_ticket_response.dart +++ b/lib/src/handle_ticket_response.dart @@ -2,6 +2,7 @@ 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) { @@ -19,8 +20,15 @@ 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); + + final tfa = (ticketData != null && ticketData.startsWith("!tfa!")) + ? TfaChallenge.fromJson( + jsonDecode( + Uri.decodeComponent(ticketData.substring(5)), + ), + ) + : null; return Credentials( unauthenicatedCredentials.apiBaseUrl, @@ -52,6 +60,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..b92f5ee --- /dev/null +++ b/lib/src/tfa_challenge.dart @@ -0,0 +1,27 @@ +class TfaChallenge { + final bool totp; + final List recovery; + final bool yubico; + final dynamic? u2f; + final dynamic? webauthn; + + TfaChallenge( + this.totp, + this.recovery, + this.yubico, { + this.u2f = null, + this.webauthn = null, + }); + + TfaChallenge.fromJson(Map data) + : totp = data['totp'] ?? false + , yubico = data['yubico'] ?? false + , recovery = ( + data['recovery'] != null + ? List.from(data['recovery'].map((x) => x)) + : [] + ) + , u2f = data['u2f'] + , webauthn = data['webauthn'] + ; +} -- 2.30.2