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 485B184805 for ; 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 ; 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 ; 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 ; Tue, 14 Dec 2021 10:08:16 +0100 (CET) From: Wolfgang Bumiller 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 List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 14 Dec 2021 09:08:53 -0000 and decode the tfa challenge Signed-off-by: Wolfgang Bumiller --- 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 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 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 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..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 refresh({http.Client? httpClient}) async { @@ -59,13 +60,66 @@ class Credentials { return credentials; } - Future tfaChallenge(String code, + Future 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 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 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'] + , oldApi = false + ; +} -- 2.30.2