On 4/2/19 12:21 PM, Wolfgang Bumiller wrote: > Adds a priv/tfa.cfg file usable in place of user.cfg. > (Otherwise the user.cfg can potentially grow too big with > u2f keys.) > > Also contains some preparation code for u2f and > user-opt-in totp. > > Signed-off-by: Wolfgang Bumiller <w.bumil...@proxmox.com> > --- > PVE/AccessControl.pm | 198 > ++++++++++++++++++++++++++++++++++++++++++++++++--- > 1 file changed, 189 insertions(+), 9 deletions(-) > > diff --git a/PVE/AccessControl.pm b/PVE/AccessControl.pm > index 51ef3ee..655f306 100644 > --- a/PVE/AccessControl.pm > +++ b/PVE/AccessControl.pm > @@ -11,6 +11,7 @@ use MIME::Base64; > use Digest::SHA; > use IO::File; > use File::stat; > +use JSON; > > use PVE::OTP; > use PVE::Ticket; > @@ -55,6 +56,9 @@ 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(@_); > @@ -446,9 +450,7 @@ sub check_user_enabled { > } > > sub verify_one_time_pw { > - my ($usercfg, $username, $tfa_cfg, $otp) = @_; > - > - my $type = $tfa_cfg->{type}; > + my ($type, $username, $keys, $tfa_cfg, $otp) = @_; > > die "missing one time password for two-factor authentication '$type'\n" > if !$otp; > > @@ -456,11 +458,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"; > @@ -494,12 +494,22 @@ sub authenticate_user { > 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 $u2f; > + > + 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: > + $u2f = $tfa_data if !exists $tfa_data->{challenge}; > + } else { > + my $keys = $tfa_data->{keys}; > + my $tfa_cfg = $tfa_data->{config}; > + verify_one_time_pw($type, $username, $keys, $tfa_cfg, $otp); > + } > } > > - return $username; > + return wantarray ? ($username, $u2f) : $username;
I do not really like the coupling to $u2f here, whereas the rest of the method has a more generally "$type" handling, maybe returning a hash ref with arbitrary TFA data, which for u2f looks something like: { u2f => $u2f } but as this is module internal we can always change this later on > } > > sub domain_set_password { > @@ -1083,6 +1093,64 @@ sub write_user_config { > 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) = @_; > + > + 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; > + } > + > + $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 $output; > +} > + > sub roles { > my ($cfg, $user, $path) = @_; > > @@ -1265,6 +1333,118 @@ sub remove_vm_from_pool { > lock_user_config($delVMfromPoolFn, "pool cleanup for VM $vmid failed"); > } > > +my $CUSTOM_TFA_TYPES = { hmm, maybe USER_CONTROLED_TFA_TYPES as this it was it says, or? could be fixed up, though. > + 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) && !$CUSTOM_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'; > + } 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}; > + return if !$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 ne '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', > _______________________________________________ pve-devel mailing list pve-devel@pve.proxmox.com https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel