]> git.proxmox.com Git - pve-access-control.git/blobdiff - PVE/AccessControl.pm
partially fix #2825: authkey: rotate if it was generated in the future
[pve-access-control.git] / PVE / AccessControl.pm
index 6cfc8413924e3826c91ed1ed03fbbf2c05b4e8b7..6a85c1a20b21722bf6cad45a231721919d807a19 100644 (file)
@@ -11,6 +11,7 @@ use MIME::Base64;
 use Digest::SHA;
 use IO::File;
 use File::stat;
+use JSON;
 
 use PVE::OTP;
 use PVE::Ticket;
@@ -46,15 +47,18 @@ my $pve_auth_key_files = {
 
 my $pve_auth_key_cache = {};
 
-my $ticket_lifetime = 3600*2; # 2 hours
-# TODO: set to 24h for PVE 6.0
-my $authkey_lifetime = 3600*0; # rotation disabled
+my $ticket_lifetime = 3600 * 2; # 2 hours
+my $auth_graceperiod = 60 * 5; # 5 minutes
+my $authkey_lifetime = 3600 * 24; # rotate every 24 hours
 
 Crypt::OpenSSL::RSA->import_random_seed();
 
 cfs_register_file('user.cfg',
                  \&parse_user_config,
                  \&write_user_config);
+cfs_register_file('priv/tfa.cfg',
+                 \&parse_priv_tfa_config,
+                 \&write_priv_tfa_config);
 
 sub verify_username {
     PVE::Auth::Plugin::verify_username(@_);
@@ -145,9 +149,22 @@ sub check_authkey {
        warn "auth key pair missing, generating new one..\n"  if !$quiet;
        return 0;
     } else {
-       if (time() - $mtime >= $authkey_lifetime) {
+       my $now = time();
+       if ($now - $mtime >= $authkey_lifetime) {
            warn "auth key pair too old, rotating..\n" if !$quiet;;
            return 0;
+       } elsif ($mtime > $now + $auth_graceperiod) {
+           # a nodes RTC had a time set in the future during key generation -> ticket
+           # validity is clamped to 0+5 min grace period until now >= mtime again
+           my (undef, $old_mtime) = get_pubkey(1);
+           if ($old_mtime && $mtime >= $old_mtime && $mtime - $old_mtime < $ticket_lifetime) {
+               warn "auth key pair generated in the future (key $mtime > host $now),"
+                   ." but old key still exists and in valid grace period so avoid automatic"
+                   ." fixup. Cluster time not in sync?\n" if !$quiet;
+               return 1;
+           }
+           warn "auth key pair generated in the future (key $mtime > host $now), rotating..\n" if !$quiet;
+           return 0;
        } else {
            warn "auth key new enough, skipping rotation\n" if !$quiet;;
            return 1;
@@ -163,18 +180,21 @@ sub rotate_authkey {
        return if check_authkey();
 
        my $old = get_pubkey();
+       my $new = Crypt::OpenSSL::RSA->generate_key(2048);
 
        if ($old) {
            eval {
                my $pem = $old->get_public_key_x509_string();
+               # mtime is used for caching and ticket age range calculation
                PVE::Tools::file_set_contents($pve_auth_key_files->{pubold}, $pem);
            };
            die "Failed to store old auth key: $@\n" if $@;
        }
 
-       my $new = Crypt::OpenSSL::RSA->generate_key(2048);
        eval {
            my $pem = $new->get_public_key_x509_string();
+           # mtime is used for caching and ticket age range calculation,
+           # should be close to that of pubold above
            PVE::Tools::file_set_contents($pve_auth_key_files->{pub}, $pem);
        };
        if ($@) {
@@ -205,11 +225,60 @@ sub rotate_authkey {
     die $@ if $@;
 }
 
+PVE::JSONSchema::register_standard_option('tokenid', {
+    description => "API token identifier.",
+    type => "string",
+    format => "pve-tokenid",
+});
+
+our $token_subid_regex = $PVE::Auth::Plugin::realm_regex;
+
+# username@realm username realm tokenid
+our $token_full_regex = qr/((${PVE::Auth::Plugin::user_regex})\@(${PVE::Auth::Plugin::realm_regex}))!(${token_subid_regex})/;
+
+our $userid_or_token_regex = qr/^$PVE::Auth::Plugin::user_regex\@$PVE::Auth::Plugin::realm_regex(?:!$token_subid_regex)?$/;
+
+sub split_tokenid {
+    my ($tokenid, $noerr) = @_;
+
+    if ($tokenid =~ /^${token_full_regex}$/) {
+       return ($1, $4);
+    }
+
+    die "'$tokenid' is not a valid token ID - not able to split into user and token parts\n" if !$noerr;
+
+    return undef;
+}
+
+sub join_tokenid {
+    my ($username, $tokensubid) = @_;
+
+    my $joined = "${username}!${tokensubid}";
+
+    return pve_verify_tokenid($joined);
+}
+
+PVE::JSONSchema::register_format('pve-tokenid', \&pve_verify_tokenid);
+sub pve_verify_tokenid {
+    my ($tokenid, $noerr) = @_;
+
+    if ($tokenid =~ /^${token_full_regex}$/) {
+       return wantarray ? ($tokenid, $2, $3, $4) : $tokenid;
+    }
+
+    die "value '$tokenid' does not look like a valid token ID\n" if !$noerr;
+
+    return undef;
+}
+
+
 my $csrf_prevention_secret;
+my $csrf_prevention_secret_legacy;
 my $get_csrfr_secret = sub {
     if (!$csrf_prevention_secret) {
        my $input = PVE::Tools::file_get_contents($pve_www_key_fn);
-       $csrf_prevention_secret = Digest::SHA::sha1_base64($input);
+       $csrf_prevention_secret = Digest::SHA::hmac_sha256_base64($input);
+       $csrf_prevention_secret_legacy = Digest::SHA::sha1_base64($input);
     }
     return $csrf_prevention_secret;
 };
@@ -225,10 +294,19 @@ sub assemble_csrf_prevention_token {
 sub verify_csrf_prevention_token {
     my ($username, $token, $noerr) = @_;
 
-    my $secret =  &$get_csrfr_secret();
+    my $secret = $get_csrfr_secret->();
+
+    # FIXME: remove with PVE 7 and/or refactor all into PVE::Ticket ?
+    if ($token =~ m/^([A-Z0-9]{8}):(\S+)$/) {
+       my $sig = $2;
+       if (length($sig) == 27) {
+           # the legacy secret got populated by above get_csrfr_secret call
+           $secret = $csrf_prevention_secret_legacy;
+       }
+    }
 
     return PVE::Ticket::verify_csrf_prevention_token(
-       $secret, $username, $token, -300, $ticket_lifetime, $noerr);
+       $secret, $username, $token, -$auth_graceperiod, $ticket_lifetime, $noerr);
 }
 
 my $get_ticket_age_range = sub {
@@ -237,12 +315,12 @@ my $get_ticket_age_range = sub {
     my $key_age = $now - $mtime;
     $key_age = 0 if $key_age < 0;
 
-    my $min = -300;
+    my $min = -$auth_graceperiod;
     my $max = $ticket_lifetime;
 
     if ($rotated) {
        # ticket creation after rotation is not allowed
-       $min = $key_age - 300;
+       $min = $key_age - $auth_graceperiod;
     } else {
        if ($key_age > $authkey_lifetime && $authkey_lifetime > 0) {
            if (PVE::Cluster::check_cfs_quorum(1)) {
@@ -253,7 +331,7 @@ my $get_ticket_age_range = sub {
            }
        }
 
-       $max = $key_age + 300 if $key_age < $ticket_lifetime;
+       $max = $key_age + $auth_graceperiod if $key_age < $ticket_lifetime;
     }
 
     return undef if $min > $ticket_lifetime;
@@ -261,11 +339,11 @@ my $get_ticket_age_range = sub {
 };
 
 sub assemble_ticket {
-    my ($username) = @_;
+    my ($data) = @_;
 
     my $rsa_priv = get_privkey();
 
-    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $username);
+    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data);
 }
 
 sub verify_ticket {
@@ -280,31 +358,94 @@ sub verify_ticket {
        return undef if !$rsa_pub;
 
        my ($min, $max) = $get_ticket_age_range->($now, $rsa_mtime, $old);
-       return undef if !$min;
+       return undef if !defined($min);
 
        return PVE::Ticket::verify_rsa_ticket(
            $rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
     };
 
-    my ($username, $age) = $check->();
+    my ($data, $age) = $check->();
 
     # check with old, rotated key if current key failed
-    ($username, $age) = $check->(1) if !defined($username);
+    ($data, $age) = $check->(1) if !defined($data);
 
-    if (!defined($username)) {
+    my $auth_failure = sub {
        if ($noerr) {
            return undef;
        } else {
            # raise error via undef ticket
            PVE::Ticket::verify_rsa_ticket(undef, 'PVE');
        }
+    };
+
+    if (!defined($data)) {
+       return $auth_failure->();
+    }
+
+    my ($username, $tfa_info);
+    if ($data =~ m{^u2f!([^!]+)!([0-9a-zA-Z/.=_\-+]+)$}) {
+       # Ticket for u2f-users:
+       ($username, my $challenge) = ($1, $2);
+       if ($challenge eq 'verified') {
+           # u2f challenge was completed
+           $challenge = undef;
+       } elsif (!wantarray) {
+           # The caller is not aware there could be an ongoing challenge,
+           # so we treat this ticket as invalid:
+           return $auth_failure->();
+       }
+       $tfa_info = {
+           type => 'u2f',
+           challenge => $challenge,
+       };
+    } elsif ($data =~ /^tfa!(.*)$/) {
+       # TOTP and Yubico don't require a challenge so this is the generic
+       # 'missing 2nd factor ticket'
+       $username = $1;
+       $tfa_info = { type => 'tfa' };
+    } else {
+       # Regular ticket (full access)
+       $username = $data;
     }
 
     return undef if !PVE::Auth::Plugin::verify_username($username, $noerr);
 
-    return wantarray ? ($username, $age) : $username;
+    return wantarray ? ($username, $age, $tfa_info) : $username;
+}
+
+sub verify_token {
+    my ($api_token) = @_;
+
+    die "no API token specified\n" if !$api_token;
+
+    my ($tokenid, $value);
+    if ($api_token =~ /^(.*)=(.*)$/) {
+       $tokenid = $1;
+       $value = $2;
+    } else {
+       die "no tokenid specified\n";
+    }
+
+    my ($username, $token) = split_tokenid($tokenid);
+
+    my $usercfg = cfs_read_file('user.cfg');
+    check_user_enabled($usercfg, $username);
+    check_token_exist($usercfg, $username, $token);
+
+    my $ctime = time();
+
+    my $user = $usercfg->{users}->{$username};
+    die "account expired\n" if $user->{expire} && ($user->{expire} < $ctime);
+
+    my $token_info = $user->{tokens}->{$token};
+    die "token expired\n" if $token_info->{expire} && ($token_info->{expire} < $ctime);
+
+    die "invalid token value!\n" if !PVE::Cluster::verify_token($tokenid, $value);
+
+    return wantarray ? ($tokenid) : $tokenid;
 }
 
+
 # VNC tickets
 # - they do not contain the username in plain text
 # - they are restricted to a specific resource path (example: '/vms/100')
@@ -327,7 +468,7 @@ sub verify_vnc_ticket {
     my $secret_data = "$username:$path";
 
     my ($rsa_pub, $rsa_mtime) = get_pubkey();
-    if (!$rsa_pub || (time() - $rsa_mtime > $authkey_lifetime)) {
+    if (!$rsa_pub || (time() - $rsa_mtime > $authkey_lifetime && $authkey_lifetime > 0)) {
        if ($noerr) {
            return undef;
        } else {
@@ -445,10 +586,22 @@ sub check_user_enabled {
     return undef;
 }
 
-sub verify_one_time_pw {
-    my ($usercfg, $username, $tfa_cfg, $otp) = @_;
+sub check_token_exist {
+    my ($usercfg, $username, $tokenid, $noerr) = @_;
+
+    my $user = check_user_exist($usercfg, $username, $noerr);
+    return undef if !$user;
+
+    return $user->{tokens}->{$tokenid}
+       if defined($user->{tokens}) && $user->{tokens}->{$tokenid};
+
+    die "no such token '$tokenid' for user '$username'\n" if !$noerr;
+
+    return undef;
+}
 
-    my $type = $tfa_cfg->{type};
+sub verify_one_time_pw {
+    my ($type, $username, $keys, $tfa_cfg, $otp) = @_;
 
     die "missing one time password for two-factor authentication '$type'\n" if !$otp;
 
@@ -456,11 +609,9 @@ sub verify_one_time_pw {
     my $proxy;
 
     if ($type eq 'yubico') {
-       my $keys = $usercfg->{users}->{$username}->{keys};
        PVE::OTP::yubico_verify_otp($otp, $keys, $tfa_cfg->{url},
                                    $tfa_cfg->{id}, $tfa_cfg->{key}, $proxy);
     } elsif ($type eq 'oath') {
-       my $keys = $usercfg->{users}->{$username}->{keys};
        PVE::OTP::oath_verify_otp($otp, $keys, $tfa_cfg->{step}, $tfa_cfg->{digits});
     } else {
        die "unknown tfa type '$type'\n";
@@ -490,16 +641,36 @@ sub authenticate_user {
     my $domain_cfg = cfs_read_file('domains.cfg');
 
     my $cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exists\n" if !$cfg;
+    die "auth domain '$realm' does not exist\n" if !$cfg;
     my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
     $plugin->authenticate_user($cfg, $realm, $ruid, $password);
 
-    if ($cfg->{tfa}) {
-       my $tfa_cfg = PVE::Auth::Plugin::parse_tfa_config($cfg->{tfa});
-       verify_one_time_pw($usercfg, $username, $tfa_cfg, $otp);
+    my ($type, $tfa_data) = user_get_tfa($username, $realm);
+    if ($type) {
+       if ($type eq 'u2f') {
+           # Note that if the user did not manage to complete the initial u2f registration
+           # challenge we have a hash containing a 'challenge' entry in the user's tfa.cfg entry:
+           $tfa_data = undef if exists $tfa_data->{challenge};
+       } elsif (!defined($otp)) {
+           # The user requires a 2nd factor but has not provided one. Return success but
+           # don't clear $tfa_data.
+       } else {
+           my $keys = $tfa_data->{keys};
+           my $tfa_cfg = $tfa_data->{config};
+           verify_one_time_pw($type, $username, $keys, $tfa_cfg, $otp);
+           $tfa_data = undef;
+       }
+
+       # Return the type along with the rest:
+       if ($tfa_data) {
+           $tfa_data = {
+               type => $type,
+               data => $tfa_data,
+           };
+       }
     }
 
-    return $username;
+    return wantarray ? ($username, $tfa_data) : $username;
 }
 
 sub domain_set_password {
@@ -584,6 +755,7 @@ my $privgroups = {
        ],
        user => [
            'VM.Config.CDROM', # change CDROM media
+           'VM.Config.Cloudinit',
            'VM.Console',
            'VM.Backup',
            'VM.PowerMgmt',
@@ -620,6 +792,16 @@ my $privgroups = {
            'Datastore.Audit',
        ],
     },
+    SDN => {
+       root => [],
+       admin => [
+           'SDN.Allocate',
+           'SDN.Audit',
+       ],
+       audit => [
+           'SDN.Audit',
+       ],
+    },
     User => {
        root => [
            'Realm.Allocate',
@@ -874,10 +1056,10 @@ sub parse_user_config {
 
                if ($cfg->{users}->{$user}) { # user exists
                    $cfg->{users}->{$user}->{groups}->{$group} = 1;
-                   $cfg->{groups}->{$group}->{users}->{$user} = 1;
                } else {
                    warn "user config - ignore invalid group member '$user'\n";
                }
+               $cfg->{groups}->{$group}->{users}->{$user} = 1;
            }
 
        } elsif ($et eq 'role') {
@@ -895,13 +1077,15 @@ sub parse_user_config {
                if (defined ($valid_privs->{$priv})) {
                    $cfg->{roles}->{$role}->{$priv} = 1;
                } else {
-                   warn "user config - ignore invalid priviledge '$priv'\n";
+                   warn "user config - ignore invalid privilege '$priv'\n";
                }
            }
 
        } elsif ($et eq 'acl') {
            my ($propagate, $pathtxt, $uglist, $rolelist) = @data;
 
+           $propagate = $propagate ? 1 : 0;
+
            if (my $path = normalize_path($pathtxt)) {
                foreach my $role (split_list($rolelist)) {
 
@@ -910,20 +1094,30 @@ sub parse_user_config {
                        next;
                    }
 
+                   if (!$cfg->{roles}->{$role}) {
+                       warn "user config - ignore invalid acl role '$role'\n";
+                       next;
+                   }
+
                    foreach my $ug (split_list($uglist)) {
-                       if ($ug =~ m/^@(\S+)$/) {
-                           my $group = $1;
-                           if ($cfg->{groups}->{$group}) { # group exists
-                               $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
-                           } else {
+                       my ($group) = $ug =~ m/^@(\S+)$/;
+
+                       if ($group && verify_groupname($group, 1)) {
+                           if (!$cfg->{groups}->{$group}) { # group does not exist
                                warn "user config - ignore invalid acl group '$group'\n";
                            }
+                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
                        } elsif (PVE::Auth::Plugin::verify_username($ug, 1)) {
-                           if ($cfg->{users}->{$ug}) { # user exists
-                               $cfg->{acl}->{$path}->{users}->{$ug}->{$role} = $propagate;
-                           } else {
+                           if (!$cfg->{users}->{$ug}) { # user does not exist
                                warn "user config - ignore invalid acl member '$ug'\n";
                            }
+                           $cfg->{acl}->{$path}->{users}->{$ug}->{$role} = $propagate;
+                       } elsif (my ($user, $token) = split_tokenid($ug, 1)) {
+                           if (check_token_exist($cfg, $user, $token, 1)) {
+                               $cfg->{acl}->{$path}->{tokens}->{$ug}->{$role} = $propagate;
+                           } else {
+                               warn "user config - ignore invalid acl token '$ug'\n";
+                           }
                        } else {
                            warn "user config - invalid user/group '$ug' in acl\n";
                        }
@@ -970,6 +1164,34 @@ sub parse_user_config {
                }
                $cfg->{pools}->{$pool}->{storage}->{$storeid} = 1;
            }
+       } elsif ($et eq 'token') {
+           my ($tokenid, $expire, $privsep, $comment) = @data;
+
+           my ($user, $token) = split_tokenid($tokenid, 1);
+           if (!($user && $token)) {
+               warn "user config - ignore invalid tokenid '$tokenid'\n";
+               next;
+           }
+
+           $privsep = $privsep ? 1 : 0;
+
+           $expire = 0 if !$expire;
+
+           if ($expire !~ m/^\d+$/) {
+               warn "user config - ignore token '$tokenid' - (illegal characters in expire '$expire')\n";
+               next;
+           }
+           $expire = int($expire);
+
+           if (my $user_cfg = $cfg->{users}->{$user}) { # user exists
+               $user_cfg->{tokens}->{$token} = {} if !$user_cfg->{tokens}->{$token};
+               my $token_cfg = $user_cfg->{tokens}->{$token};
+               $token_cfg->{privsep} = $privsep;
+               $token_cfg->{expire} = $expire;
+               $token_cfg->{comment} = PVE::Tools::decode_text($comment) if $comment;
+           } else {
+               warn "user config - ignore token '$tokenid' - user does not exist\n";
+           }
        } else {
            warn "user config - ignore config line: $line\n";
        }
@@ -985,7 +1207,7 @@ sub write_user_config {
 
     my $data = '';
 
-    foreach my $user (keys %{$cfg->{users}}) {
+    foreach my $user (sort keys %{$cfg->{users}}) {
        my $d = $cfg->{users}->{$user};
        my $firstname = $d->{firstname} ? PVE::Tools::encode_text($d->{firstname}) : '';
        my $lastname = $d->{lastname} ? PVE::Tools::encode_text($d->{lastname}) : '';
@@ -995,49 +1217,59 @@ sub write_user_config {
        my $enable = $d->{enable} ? 1 : 0;
        my $keys = $d->{keys} ? $d->{keys} : '';
        $data .= "user:$user:$enable:$expire:$firstname:$lastname:$email:$comment:$keys:\n";
+
+       my $user_tokens = $d->{tokens};
+       foreach my $token (sort keys %$user_tokens) {
+           my $td = $user_tokens->{$token};
+           my $full_tokenid = join_tokenid($user, $token);
+           my $comment = $td->{comment} ? PVE::Tools::encode_text($td->{comment}) : '';
+           my $expire = int($td->{expire} || 0);
+           my $privsep = $td->{privsep} ? 1 : 0;
+           $data .= "token:$full_tokenid:$expire:$privsep:$comment:\n";
+       }
     }
 
     $data .= "\n";
 
-    foreach my $group (keys %{$cfg->{groups}}) {
+    foreach my $group (sort keys %{$cfg->{groups}}) {
        my $d = $cfg->{groups}->{$group};
-       my $list = join (',', keys %{$d->{users}});
+       my $list = join (',', sort keys %{$d->{users}});
        my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
        $data .= "group:$group:$list:$comment:\n";
     }
 
     $data .= "\n";
 
-    foreach my $pool (keys %{$cfg->{pools}}) {
+    foreach my $pool (sort keys %{$cfg->{pools}}) {
        my $d = $cfg->{pools}->{$pool};
-       my $vmlist = join (',', keys %{$d->{vms}});
-       my $storelist = join (',', keys %{$d->{storage}});
+       my $vmlist = join (',', sort keys %{$d->{vms}});
+       my $storelist = join (',', sort keys %{$d->{storage}});
        my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
        $data .= "pool:$pool:$comment:$vmlist:$storelist:\n";
     }
 
     $data .= "\n";
 
-    foreach my $role (keys %{$cfg->{roles}}) {
+    foreach my $role (sort keys %{$cfg->{roles}}) {
        next if $special_roles->{$role};
 
        my $d = $cfg->{roles}->{$role};
-       my $list = join (',', keys %$d);
+       my $list = join (',', sort keys %$d);
        $data .= "role:$role:$list:\n";
     }
 
     $data .= "\n";
 
-    foreach my $path (sort keys %{$cfg->{acl}}) {
-       my $d = $cfg->{acl}->{$path};
+    my $collect_rolelist_members = sub {
+       my ($acl_members, $result, $prefix, $exclude) = @_;
 
-       my $ra = {};
+       foreach my $member (keys %$acl_members) {
+           next if $exclude && $member eq $exclude;
 
-       foreach my $group (keys %{$d->{groups}}) {
            my $l0 = '';
            my $l1 = '';
-           foreach my $role (sort keys %{$d->{groups}->{$group}}) {
-               my $propagate = $d->{groups}->{$group}->{$role};
+           foreach my $role (sort keys %{$acl_members->{$member}}) {
+               my $propagate = $acl_members->{$member}->{$role};
                if ($propagate) {
                    $l1 .= ',' if $l1;
                    $l1 .= $role;
@@ -1046,52 +1278,118 @@ sub write_user_config {
                    $l0 .= $role;
                }
            }
-           $ra->{0}->{$l0}->{"\@$group"} = 1 if $l0;
-           $ra->{1}->{$l1}->{"\@$group"} = 1 if $l1;
+           $result->{0}->{$l0}->{"${prefix}${member}"} = 1 if $l0;
+           $result->{1}->{$l1}->{"${prefix}${member}"} = 1 if $l1;
        }
+    };
 
-       foreach my $user (keys %{$d->{users}}) {
-           # no need to save, because root is always 'Administrator'
-           next if $user eq 'root@pam';
+    foreach my $path (sort keys %{$cfg->{acl}}) {
+       my $d = $cfg->{acl}->{$path};
 
-           my $l0 = '';
-           my $l1 = '';
-           foreach my $role (sort keys %{$d->{users}->{$user}}) {
-               my $propagate = $d->{users}->{$user}->{$role};
-               if ($propagate) {
-                   $l1 .= ',' if $l1;
-                   $l1 .= $role;
-               } else {
-                   $l0 .= ',' if $l0;
-                   $l0 .= $role;
-               }
+       my $rolelist_members = {};
+
+       $collect_rolelist_members->($d->{'groups'}, $rolelist_members, '@');
+
+       # no need to save 'root@pam', it is always 'Administrator'
+       $collect_rolelist_members->($d->{'users'}, $rolelist_members, '', 'root@pam');
+
+       $collect_rolelist_members->($d->{'tokens'}, $rolelist_members, '');
+
+       foreach my $propagate (0,1) {
+           my $filtered = $rolelist_members->{$propagate};
+           foreach my $rolelist (sort keys %$filtered) {
+               my $uglist = join (',', sort keys %{$filtered->{$rolelist}});
+               $data .= "acl:$propagate:$path:$uglist:$rolelist:\n";
            }
-           $ra->{0}->{$l0}->{$user} = 1 if $l0;
-           $ra->{1}->{$l1}->{$user} = 1 if $l1;
+
        }
+    }
+
+    return $data;
+}
+
+# The TFA configuration in priv/tfa.cfg format contains one line per user of
+# the form:
+#     USER:TYPE:DATA
+# DATA is a base64 encoded json string and its format depends on the type.
+sub parse_priv_tfa_config {
+    my ($filename, $raw) = @_;
 
-       foreach my $rolelist (sort keys %{$ra->{0}}) {
-           my $uglist = join (',', keys %{$ra->{0}->{$rolelist}});
-           $data .= "acl:0:$path:$uglist:$rolelist:\n";
+    my $users = {};
+    my $cfg = { users => $users };
+
+    $raw = '' if !defined($raw);
+    while ($raw =~ /^\s*(.+?)\s*$/gm) {
+       my $line = $1;
+       my ($user, $type, $data) = split(/:/, $line, 3);
+
+       my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
+       if (!$realm) {
+           warn "user tfa config - ignore user '$user' - invalid user name\n";
+           next;
        }
-       foreach my $rolelist (sort keys %{$ra->{1}}) {
-           my $uglist = join (',', keys %{$ra->{1}->{$rolelist}});
-           $data .= "acl:1:$path:$uglist:$rolelist:\n";
+
+       $data = decode_json(decode_base64($data));
+
+       $users->{$user} = {
+           type => $type,
+           data => $data,
+       };
+    }
+
+    return $cfg;
+}
+
+sub write_priv_tfa_config {
+    my ($filename, $cfg) = @_;
+
+    my $output = '';
+
+    my $users = $cfg->{users};
+    foreach my $user (sort keys %$users) {
+       my $info = $users->{$user};
+       next if !%$info; # skip empty entries
+
+       $info = {%$info}; # copy to verify contents:
+
+       my $type = delete $info->{type};
+       my $data = delete $info->{data};
+
+       if (my @keys = keys %$info) {
+           die "invalid keys in TFA config for user $user: " . join(', ', @keys) . "\n";
        }
+
+       $data = encode_base64(encode_json($data), '');
+       $output .= "${user}:${type}:${data}\n";
     }
 
-    return $data;
+    return $output;
 }
 
 sub roles {
     my ($cfg, $user, $path) = @_;
 
     # NOTE: we do not consider pools here.
-    # You need to use $rpcenv->roles() instead if you want that.
+    # NOTE: for privsep tokens, this does not filter roles by those that the
+    # corresponding user has.
+    # Use $rpcenv->permission() for any actual permission checks!
 
     return 'Administrator' if $user eq 'root@pam'; # root can do anything
 
-    my $perm = {};
+    if (pve_verify_tokenid($user, 1)) {
+       my $tokenid = $user;
+       my ($username, $token) = split_tokenid($tokenid);
+
+       my $token_info = $cfg->{users}->{$username}->{tokens}->{$token};
+       return () if !$token_info;
+
+       my $user_roles = roles($cfg, $username, $path);
+
+       # return full user privileges
+       return $user_roles if !$token_info->{privsep};
+    }
+
+    my $roles = {};
 
     foreach my $p (sort keys %{$cfg->{acl}}) {
        my $final = ($path eq $p);
@@ -1102,6 +1400,21 @@ sub roles {
 
        #print "CHECKACL $path $p\n";
        #print "ACL $path = " . Dumper ($acl);
+       if (my $ri = $acl->{tokens}->{$user}) {
+           my $new;
+           foreach my $role (keys %$ri) {
+               my $propagate = $ri->{$role};
+               if ($final || $propagate) {
+                   #print "APPLY ROLE $p $user $role\n";
+                   $new = {} if !$new;
+                   $new->{$role} = $propagate;
+               }
+           }
+           if ($new) {
+               $roles = $new; # overwrite previous settings
+               next;
+           }
+       }
 
        if (my $ri = $acl->{users}->{$user}) {
            my $new;
@@ -1110,11 +1423,11 @@ sub roles {
                if ($final || $propagate) {
                    #print "APPLY ROLE $p $user $role\n";
                    $new = {} if !$new;
-                   $new->{$role} = 1;
+                   $new->{$role} = $propagate;
                }
            }
            if ($new) {
-               $perm = $new; # overwrite previous settings
+               $roles = $new; # overwrite previous settings
                next; # user privs always override group privs
            }
        }
@@ -1128,64 +1441,25 @@ sub roles {
                    if ($final || $propagate) {
                        #print "APPLY ROLE $p \@$g $role\n";
                        $new = {} if !$new;
-                       $new->{$role} = 1;
+                       $new->{$role} = $propagate;
                    }
                }
            }
        }
        if ($new) {
-           $perm = $new; # overwrite previous settings
+           $roles = $new; # overwrite previous settings
            next;
        }
     }
 
-    return ('NoAccess') if defined ($perm->{NoAccess});
-    #return () if defined ($perm->{NoAccess});
+    return { 'NoAccess' => $roles->{NoAccess} } if defined ($roles->{NoAccess});
+    #return () if defined ($roles->{NoAccess});
 
-    #print "permission $user $path = " . Dumper ($perm);
-
-    my @ra = keys %$perm;
+    #print "permission $user $path = " . Dumper ($roles);
 
     #print "roles $user $path = " . join (',', @ra) . "\n";
 
-    return @ra;
-}
-
-sub permission {
-    my ($cfg, $user, $path) = @_;
-
-    $user = PVE::Auth::Plugin::verify_username($user, 1);
-    return {} if !$user;
-
-    my @ra = roles($cfg, $user, $path);
-
-    my $privs = {};
-
-    foreach my $role (@ra) {
-       if (my $privset = $cfg->{roles}->{$role}) {
-           foreach my $p (keys %$privset) {
-               $privs->{$p} = 1;
-           }
-       }
-    }
-
-    #print "priviledges $user $path = " . Dumper ($privs);
-
-    return $privs;
-}
-
-sub check_permissions {
-    my ($username, $path, $privlist) = @_;
-
-    $path = normalize_path($path);
-    my $usercfg = cfs_read_file('user.cfg');
-    my $perm = permission($usercfg, $username, $path);
-
-    foreach my $priv (split_list($privlist)) {
-       return undef if !$perm->{$priv};
-    };
-
-    return 1;
+    return $roles;
 }
 
 sub remove_vm_access {
@@ -1265,6 +1539,123 @@ sub remove_vm_from_pool {
     lock_user_config($delVMfromPoolFn, "pool cleanup for VM $vmid failed");
 }
 
+my $USER_CONTROLLED_TFA_TYPES = {
+    u2f => 1,
+    oath => 1,
+};
+
+# Delete an entry by setting $data=undef in which case $type is ignored.
+# Otherwise both must be valid.
+sub user_set_tfa {
+    my ($userid, $realm, $type, $data, $cached_usercfg, $cached_domaincfg) = @_;
+
+    if (defined($data) && !defined($type)) {
+       # This is an internal usage error and should not happen
+       die "cannot set tfa data without a type\n";
+    }
+
+    my $user_cfg = $cached_usercfg || cfs_read_file('user.cfg');
+    my $user = $user_cfg->{users}->{$userid}
+       or die "user '$userid' not found\n";
+
+    my $domain_cfg = $cached_domaincfg || cfs_read_file('domains.cfg');
+    my $realm_cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+    my $realm_tfa = $realm_cfg->{tfa};
+    if (defined($realm_tfa)) {
+       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
+       # If the realm has a TFA setting, we're only allowed to use that.
+       if (defined($data)) {
+           my $required_type = $realm_tfa->{type};
+           if ($required_type ne $type) {
+               die "realm '$realm' only allows TFA of type '$required_type\n";
+           }
+
+           if (defined($data->{config})) {
+               # XXX: Is it enough if the type matches? Or should the configuration also match?
+           }
+
+           # realm-configured tfa always uses a simple key list, so use the user.cfg
+           $user->{keys} = $data->{keys};
+       } else {
+           die "realm '$realm' does not allow removing the 2nd factor\n";
+       }
+    } else {
+       # Without a realm-enforced TFA setting the user can add a u2f or totp entry by themselves.
+       # The 'yubico' type requires yubico server settings, which have to be configured on the
+       # realm, so this is not supported here:
+       die "domain '$realm' does not support TFA type '$type'\n"
+           if defined($data) && !$USER_CONTROLLED_TFA_TYPES->{$type};
+    }
+
+    # Custom TFA entries are stored in priv/tfa.cfg as they can be more complet: u2f uses a
+    # public key and a key handle, TOTP requires the usual totp settings...
+
+    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+    my $tfa = ($tfa_cfg->{users}->{$userid} //= {});
+
+    if (defined($data)) {
+       $tfa->{type} = $type;
+       $tfa->{data} = $data;
+       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+
+       $user->{keys} = "x!$type";
+    } else {
+       delete $tfa_cfg->{users}->{$userid};
+       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+
+       delete $user->{keys};
+    }
+
+    cfs_write_file('user.cfg', $user_cfg);
+}
+
+sub user_get_tfa {
+    my ($username, $realm) = @_;
+
+    my $user_cfg = cfs_read_file('user.cfg');
+    my $user = $user_cfg->{users}->{$username}
+       or die "user '$username' not found\n";
+
+    my $keys = $user->{keys};
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+    my $realm_cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+    my $realm_tfa = $realm_cfg->{tfa};
+    $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa)
+       if $realm_tfa;
+
+    if (!$keys) {
+       return if !$realm_tfa;
+       die "missing required 2nd keys\n";
+    }
+
+    # new style config starts with an 'x' and optionally contains a !<type> suffix
+    if ($keys !~ /^x(?:!.*)?$/) {
+       # old style config, find the type via the realm
+       return if !$realm_tfa;
+       return ($realm_tfa->{type}, {
+           keys => $keys,
+           config => $realm_tfa,
+       });
+    } else {
+       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+       my $tfa = $tfa_cfg->{users}->{$username};
+       return if !$tfa; # should not happen (user.cfg wasn't cleaned up?)
+
+       if ($realm_tfa) {
+           # if the realm has a tfa setting we need to verify the type:
+           die "auth domain '$realm' and user have mismatching TFA settings\n"
+               if $realm_tfa && $realm_tfa->{type} ne $tfa->{type};
+       }
+
+       return ($tfa->{type}, $tfa->{data});
+    }
+}
+
 # bash completion helpers
 
 register_standard_option('userid-completed',