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 4DFE082801
 for <pve-devel@lists.proxmox.com>; Tue, 30 Nov 2021 10:57:42 +0100 (CET)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id 443E114BB9
 for <pve-devel@lists.proxmox.com>; Tue, 30 Nov 2021 10:57:42 +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) server-digest SHA256)
 (No client certificate requested)
 by firstgate.proxmox.com (Proxmox) with ESMTPS id 7CE3814BB0
 for <pve-devel@lists.proxmox.com>; Tue, 30 Nov 2021 10:57:41 +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 50A844380A
 for <pve-devel@lists.proxmox.com>; Tue, 30 Nov 2021 10:57:41 +0100 (CET)
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pve-devel@lists.proxmox.com
Date: Tue, 30 Nov 2021 10:57:40 +0100
Message-Id: <20211130095740.35774-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.431 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
 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See
 http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more
 information. [lwp.pm]
Subject: [pve-devel] [PATCH apiclient] support new tfa 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, 30 Nov 2021 09:57:42 -0000

Note that in PVE we should instantiate the API client with
`pve_new_format` in order to have this client also switch to
the new mechanism, otherwise the old api will be used which
does not support multiple factors or recovery keys.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
 PVE/APIClient/LWP.pm | 85 +++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 80 insertions(+), 5 deletions(-)

diff --git a/PVE/APIClient/LWP.pm b/PVE/APIClient/LWP.pm
index 63f2177..27ecfed 100755
--- a/PVE/APIClient/LWP.pm
+++ b/PVE/APIClient/LWP.pm
@@ -93,7 +93,7 @@ sub update_ticket {
     $agent->default_header('Cookie', $cookie);
 }
 
-sub two_factor_auth_login {
+my sub two_factor_auth_login_old : prototype($$$) {
     my ($self, $type, $challenge) = @_;
 
     if ($type eq 'PVE:tfa') {
@@ -110,6 +110,74 @@ sub two_factor_auth_login {
     }
 }
 
+my sub extra_login_params : prototype($) {
+    my ($self) = @_;
+    return $self->{pve_new_format} ? ('new-format' => 1) : ();
+}
+
+my sub two_factor_auth_login : prototype($$$) {
+    my ($self, $challenge, $ticket) = @_;
+
+    raise("TFA-enabled login currently works only with a TTY.") if !-t STDIN;
+
+    $challenge = eval { from_json($challenge, { utf8 => 1 }) };
+    if (my $err = $@) {
+	raise("Bad TFA challenge: $err");
+    }
+    raise("Bad TFA challenge!") if !$challenge;
+
+    my @available;
+    push @available, 'totp' if $challenge->{totp};
+    push @available, 'recovery' if $challenge->{recovery};
+    push @available, 'yubico' if $challenge->{yubico};
+
+    my $selected;
+    if (@available == 1) {
+	$selected = $available[0];
+    } elsif (@available > 1) {
+	while (!defined($selected)) {
+	    print "Available TFA methods:\n";
+	    print "$_: $available[$_]\n" for (0..(@available - 1));
+	    print "Select TFA method: ";
+	    STDOUT->flush;
+	    my $response = <STDIN>;
+	    if ($response =~ /^\s*(\d+)\s*$/) {
+		$selected = int($response);
+	    }
+	}
+	$selected = $available[$selected];
+    } else {
+	raise("TFA required, but available authentication types are not supported, aborting!");
+    }
+
+    if ($selected eq 'recovery') {
+	my $keys = $challenge->{recovery};
+	if (@$keys <= 3) {
+	    print("WARNING: Few recovery keys remaining: ");
+	} else {
+	    print("The following recovery codes are available: ");
+	}
+	print(join(', ', @$keys), "\n");
+    }
+
+    print "Enter $selected code for user $self->{username}: ";
+    STDOUT->flush;
+    my $tfa_response = <STDIN>;
+    chomp $tfa_response;
+
+    return $self->post(
+	'/api2/json/access/ticket',
+	{
+	    username => $self->{username},
+	    password => "$selected:$tfa_response",
+	    'tfa-challenge' => $ticket,
+	    (extra_login_params($self))
+	},
+    );
+}
+
+my $new_tfa_ticket_re = qr/^[^\s:]+:!tfa!([^:]+):/;
+my $old_tfa_ticket_re = qr/^([^\s!]+)![^!]*(!([0-9a-zA-Z\/.=_\-+]+))?$/;
 sub login {
     my ($self) = @_;
 
@@ -127,7 +195,8 @@ sub login {
     my $exec_login = sub {
 	return $ua->post($uri, {
 	    username => $username,
-	    password => $self->{password} || ''
+	    password => $self->{password} || '',
+	    (extra_login_params($self))
 	});
     };
 
@@ -152,10 +221,15 @@ sub login {
     $self->update_csrftoken($data->{CSRFPreventionToken});
 
     # handle two-factor login
-    my $tfa_ticket_re = qr/^([^\s!]+)![^!]*(!([0-9a-zA-Z\/.=_\-+]+))?$/;
-    if ($data->{ticket} =~ m/$tfa_ticket_re/) {
+    my $ticket = $data->{ticket};
+    if ($ticket =~ $new_tfa_ticket_re) {
+	my $challenge = uri_unescape($1);
+	$data = two_factor_auth_login($self, $challenge, $ticket);
+	$self->update_ticket($data->{ticket});
+    } elsif ($ticket =~ $old_tfa_ticket_re) {
+	# handle old-style two-factor login for PVE:
 	my ($type, $challenge) = ($1, $2);
-	$data = $self->two_factor_auth_login($type, $challenge);
+	$data = two_factor_auth_login_old($self, $type, $challenge);
 	$self->update_ticket($data->{ticket});
     }
 
@@ -329,6 +403,7 @@ sub new {
 	},
 	register_fingerprint_cb => $param{register_fingerprint_cb},
 	timeout => $param{timeout} || 60,
+	pve_new_format => $param{pve_new_format},
     };
     bless $self, $class;
 
-- 
2.30.2