]> git.proxmox.com Git - pmg-api.git/commitdiff
implement tfa authentication
authorWolfgang Bumiller <w.bumiller@proxmox.com>
Fri, 26 Nov 2021 13:55:09 +0000 (14:55 +0100)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Sun, 28 Nov 2021 16:26:01 +0000 (17:26 +0100)
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
src/PMG/API2/AccessControl.pm
src/PMG/API2/TFA.pm
src/PMG/AccessControl.pm
src/PMG/HTTPServer.pm
src/PMG/Service/pmgproxy.pm
src/PMG/Ticket.pm

index 942f8dc9e6f92e27b27b4e9387ce878fe0bcbedc..5774fab5fdaa46738f7282abc66668435e26ab63 100644 (file)
@@ -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@pam' || $ticketuser eq $username)) {
-       # valid ticket. Note: root@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@pam' || $ticketuser eq $username)) {
+           # valid ticket. Note: root@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() || '';
index 626d4f8df9c1c1a24ed0dc828af4a91f01ce08bc..33c718f5bdf37f6f7aba4eff5aeeadfbd35c4b53 100644 (file)
@@ -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(
index 1461335106500e0da7eefcac64caf3eb48c0a496..b09366664f4716113292a0ffc190165754ae5efe 100644 (file)
@@ -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 {
index 3dc9655e662c6e249c033ba9159f0955f14bddad..b6c50d9ec4a1fa2354b71bc1ae1fb2e6ed60be81 100755 (executable)
@@ -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);
index 89efa6af6e5429b696b8b3a5198af07af86963be..8a8a9d0fb88c53c8e50e6ecdeb7eafeb5f8bfbd5 100755 (executable)
@@ -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})) {
index 344e7849148bb30f3a661859f010cfa723a8b731..0c2ec0b8580f8d5d1306bede85fc16797bc53250 100644 (file)
@@ -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