[pmg-devel] [PATCH api 5/6] implement tfa authentication
Wolfgang Bumiller
w.bumiller at proxmox.com
Fri Nov 26 14:55:09 CET 2021
Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
src/PMG/API2/AccessControl.pm | 76 +++++++++++++++++++++++++++++------
src/PMG/API2/TFA.pm | 4 +-
src/PMG/AccessControl.pm | 29 +++++++++----
src/PMG/HTTPServer.pm | 5 ++-
src/PMG/Service/pmgproxy.pm | 2 +-
src/PMG/Ticket.pm | 30 ++++++++++----
6 files changed, 114 insertions(+), 32 deletions(-)
diff --git a/src/PMG/API2/AccessControl.pm b/src/PMG/API2/AccessControl.pm
index 942f8dc..5774fab 100644
--- a/src/PMG/API2/AccessControl.pm
+++ b/src/PMG/API2/AccessControl.pm
@@ -63,11 +63,11 @@ __PACKAGE__->register_method ({
return $res;
}});
-
-my $create_or_verify_ticket = sub {
- my ($rpcenv, $username, $pw_or_ticket, $otp, $path) = @_;
+my sub create_or_verify_ticket : prototype($$$$$$) {
+ my ($rpcenv, $username, $pw_or_ticket, $path, $otp, $tfa_challenge) = @_;
my $ticketuser;
+ my $aad;
if ($pw_or_ticket =~ m/^PMGQUAR:/) {
my $ticketuser = PMG::Ticket::verify_quarantine_ticket($pw_or_ticket);
@@ -85,13 +85,22 @@ my $create_or_verify_ticket = sub {
my $role = PMG::AccessControl::check_user_enabled($rpcenv->{usercfg}, $username);
- if (($ticketuser = PMG::Ticket::verify_ticket($pw_or_ticket, 1)) &&
- ($ticketuser eq 'root at pam' || $ticketuser eq $username)) {
- # valid ticket. Note: root at pam can create tickets for other users
- } elsif ($path && PMG::Ticket::verify_vnc_ticket($pw_or_ticket, $username, $path, 1)) {
- # valid vnc ticket for $path
- } else {
- $username = PMG::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+ my $tfa_challenge_is_ticket = 1;
+
+ if (!$tfa_challenge) {
+ $tfa_challenge_is_ticket = 0;
+ ($ticketuser, undef, $tfa_challenge) = PMG::Ticket::verify_ticket($pw_or_ticket, undef, 1);
+ die "No ticket\n" if $tfa_challenge;
+
+ if ($ticketuser && ($ticketuser eq 'root at pam' || $ticketuser eq $username)) {
+ # valid ticket. Note: root at pam can create tickets for other users
+ } elsif ($path && PMG::Ticket::verify_vnc_ticket($pw_or_ticket, $username, $path, 1)) {
+ # valid vnc ticket for $path
+ } else {
+ ($username, $tfa_challenge) =
+ PMG::AccessControl::authenticate_user($username, $pw_or_ticket, 0);
+ $pw_or_ticket = $otp;
+ }
}
if (defined($path)) {
@@ -99,7 +108,42 @@ my $create_or_verify_ticket = sub {
return { username => $username };
}
- my $ticket = PMG::Ticket::assemble_ticket($username);
+ if ($tfa_challenge && $pw_or_ticket) {
+ if ($tfa_challenge_is_ticket) {
+ (undef, undef, $tfa_challenge) = PMG::Ticket::verify_ticket($tfa_challenge, $username, 0);
+ }
+ PMG::TFAConfig::lock_config(sub {
+ my $tfa_cfg = PMG::TFAConfig->new();
+
+ my $origin = undef;
+ if (!$tfa_cfg->has_webauthn_origin()) {
+ my $rpcenv = PMG::RESTEnvironment->get();
+ $origin = 'https://'.$rpcenv->get_request_host(1);
+ }
+ my $must_save = $tfa_cfg->authentication_verify(
+ $username,
+ $tfa_challenge,
+ $pw_or_ticket,
+ $origin,
+ );
+
+ $tfa_cfg->write() if $must_save;
+ });
+
+ $tfa_challenge = undef;
+ }
+
+ my $ticket_data;
+ my %extra;
+ if ($tfa_challenge) {
+ $ticket_data = '!tfa!' . $tfa_challenge;
+ $aad = $username;
+ $extra{NeedTFA} = 1;
+ } else {
+ $ticket_data = $username;
+ }
+
+ my $ticket = PMG::Ticket::assemble_ticket($ticket_data, $aad);
my $csrftoken = PMG::Ticket::assemble_csrf_prevention_token($username);
return {
@@ -107,6 +151,7 @@ my $create_or_verify_ticket = sub {
ticket => $ticket,
username => $username,
CSRFPreventionToken => $csrftoken,
+ %extra,
};
};
@@ -160,6 +205,11 @@ __PACKAGE__->register_method ({
optional => 1,
maxLength => 64,
},
+ 'tfa-challenge' => {
+ type => 'string',
+ description => "The signed TFA challenge string the user wants to respond to.",
+ optional => 1,
+ },
}
},
returns => {
@@ -187,8 +237,8 @@ __PACKAGE__->register_method ({
my $res;
eval {
- $res = &$create_or_verify_ticket($rpcenv, $username,
- $param->{password}, $param->{otp}, $param->{path});
+ $res = create_or_verify_ticket($rpcenv, $username,
+ $param->{password}, $param->{path}, $param->{otp}, $param->{'tfa-challenge'});
};
if (my $err = $@) {
my $clientip = $rpcenv->get_client_ip() || '';
diff --git a/src/PMG/API2/TFA.pm b/src/PMG/API2/TFA.pm
index 626d4f8..33c718f 100644
--- a/src/PMG/API2/TFA.pm
+++ b/src/PMG/API2/TFA.pm
@@ -132,7 +132,7 @@ my sub check_permission_password : prototype($$$$) {
raise_param_exc({ 'password' => 'password is required to modify TFA data' })
if !defined($password);
- PMG::AccessControl::authenticate_user($userid, $password);
+ PMG::AccessControl::authenticate_user($userid, $password, 1);
}
return wantarray ? ($userid, $realm) : $userid;
@@ -381,7 +381,7 @@ __PACKAGE__->register_method ({
set_user_tfa_enabled($userid, $realm, $tfa_cfg);
my $origin = undef;
if (!$tfa_cfg->has_webauthn_origin()) {
- $origin = $rpcenv->get_request_host(1);
+ $origin = 'https://'.$rpcenv->get_request_host(1);
}
my $response = $tfa_cfg->api_add_tfa_entry(
diff --git a/src/PMG/AccessControl.pm b/src/PMG/AccessControl.pm
index 1461335..b093666 100644
--- a/src/PMG/AccessControl.pm
+++ b/src/PMG/AccessControl.pm
@@ -26,8 +26,10 @@ sub normalize_path {
# password should be utf8 encoded
# Note: some plugins delay/sleep if auth fails
-sub authenticate_user {
- my ($username, $password, $otp) = @_;
+#
+# returns ($username, $tfa_challenge)
+sub authenticate_user : prototype($$$) {
+ my ($username, $password, $skip_tfa) = @_;
die "no username specified\n" if !$username;
@@ -38,24 +40,35 @@ sub authenticate_user {
if ($realm eq 'pam') {
die "invalid pam user (only root allowed)\n" if $ruid ne 'root';
authenticate_pam_user($ruid, $password);
- return $username;
} elsif ($realm eq 'pmg') {
my $usercfg = PMG::UserConfig->new();
$usercfg->authenticate_user($username, $password);
- return $username;
} elsif ($realm eq 'quarantine') {
my $ldap_cfg = PMG::LDAPConfig->new();
my $ldap = PMG::LDAPSet->new_from_ldap_cfg($ldap_cfg, 1);
if (my $ldapinfo = $ldap->account_info($ruid, $password)) {
my $pmail = $ldapinfo->{pmail};
- return $pmail . '@quarantine';
- } else {
- die "ldap login failed\n";
+ return ($pmail . '@quarantine', undef);
}
+ die "ldap login failed\n";
+ } else {
+ die "no such realm '$realm'\n";
}
- die "no such realm '$realm'\n";
+ return ($username, undef) if $skip_tfa;
+
+ my $tfa = PMG::TFAConfig->new();
+
+ my $origin = undef;
+ if (!$tfa->has_webauthn_origin()) {
+ my $rpcenv = PMG::RESTEnvironment->get();
+ $origin = 'https://'.$rpcenv->get_request_host(1);
+ }
+
+ my $tfa_challenge = $tfa->authentication_challenge($username, $origin);
+
+ return ($username, $tfa_challenge);
}
sub set_user_password {
diff --git a/src/PMG/HTTPServer.pm b/src/PMG/HTTPServer.pm
index 3dc9655..b6c50d9 100755
--- a/src/PMG/HTTPServer.pm
+++ b/src/PMG/HTTPServer.pm
@@ -76,7 +76,10 @@ sub auth_handler {
$rpcenv->set_user($username);
$rpcenv->set_role('quser');
} else {
- ($username, $age) = PMG::Ticket::verify_ticket($ticket);
+ ($username, $age, my $tfa) = PMG::Ticket::verify_ticket($ticket, undef, 0);
+ # TFA tickets don't return a username, and return a tfa challenge, either is enough to
+ # fail here:
+ die "No ticket\n" if !$username || $tfa;
my $role = PMG::AccessControl::check_user_enabled($self->{usercfg}, $username);
$rpcenv->set_user($username);
$rpcenv->set_role($role);
diff --git a/src/PMG/Service/pmgproxy.pm b/src/PMG/Service/pmgproxy.pm
index 89efa6a..8a8a9d0 100755
--- a/src/PMG/Service/pmgproxy.pm
+++ b/src/PMG/Service/pmgproxy.pm
@@ -199,7 +199,7 @@ sub get_index {
if ($ticket =~ m/^PMGQUAR:/) {
$username = PMG::Ticket::verify_quarantine_ticket($ticket, 1);
} else {
- $username = PMG::Ticket::verify_ticket($ticket, 1);
+ $username = PMG::Ticket::verify_ticket($ticket, undef, 1);
}
} else {
if (defined($args->{ticket})) {
diff --git a/src/PMG/Ticket.pm b/src/PMG/Ticket.pm
index 344e784..0c2ec0b 100644
--- a/src/PMG/Ticket.pm
+++ b/src/PMG/Ticket.pm
@@ -164,22 +164,38 @@ sub assemble_csrf_prevention_token {
return PVE::Ticket::assemble_csrf_prevention_token ($secret, $username);
}
-sub assemble_ticket {
- my ($username) = @_;
+sub assemble_ticket : prototype($;$) {
+ my ($data, $aad) = @_;
my $rsa_priv = PVE::INotify::read_file('auth_priv_key');
- return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PMG', $username);
+ return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PMG', $data, $aad);
}
-sub verify_ticket {
- my ($ticket, $noerr) = @_;
+# Returns (username, age, tfa-challenge) or just the username in scalar context.
+# Note that in scalar context, tfa tickets return `undef`.
+sub verify_ticket : prototype($$$) {
+ my ($ticket, $aad, $noerr) = @_;
my $rsa_pub = PVE::INotify::read_file('auth_pub_key');
- return PVE::Ticket::verify_rsa_ticket(
- $rsa_pub, 'PMG', $ticket, undef,
+ my $tfa_challenge;
+ my ($data, $age) = PVE::Ticket::verify_rsa_ticket(
+ $rsa_pub, 'PMG', $ticket, $aad,
$min_ticket_lifetime, $max_ticket_lifetime, $noerr);
+
+ if ($noerr && !$data) {
+ # if $noerr was set $data can be undef:
+ return wantarray ? (undef, undef, undef) : undef;
+ }
+
+
+ if ($data =~ /^!tfa!(.*)$/) {
+ return (undef, $age, $1) if wantarray;
+ return undef if $noerr;
+ die "second factor required\n";
+ }
+ return wantarray ? ($data, $age, undef) : $data;
}
# VNC tickets
--
2.30.2
More information about the pmg-devel
mailing list