From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: 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 C7C927D720 for ; Tue, 9 Nov 2021 12:27:53 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id D9506B08B for ; Tue, 9 Nov 2021 12:27:37 +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)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id 991D5B003 for ; Tue, 9 Nov 2021 12:27:28 +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 729F542669 for ; Tue, 9 Nov 2021 12:27:28 +0100 (CET) From: Wolfgang Bumiller To: pve-devel@lists.proxmox.com Date: Tue, 9 Nov 2021 12:27:01 +0100 Message-Id: <20211109112721.130935-13-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20211109112721.130935-1-w.bumiller@proxmox.com> References: <20211109112721.130935-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.511 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. [tfa.pm, accesscontrol.pm] Subject: [pve-devel] [PATCH access-control 06/10] add pbs-style TFA API implementation X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 09 Nov 2021 11:27:54 -0000 implements the same api paths as in pbs by forwarding the api methods to the rust implementation after performing the product-specific checks Signed-off-by: Wolfgang Bumiller --- src/PVE/API2/TFA.pm | 332 +++++++++++++++++++++++++++++++++------ src/PVE/AccessControl.pm | 15 ++ 2 files changed, 301 insertions(+), 46 deletions(-) diff --git a/src/PVE/API2/TFA.pm b/src/PVE/API2/TFA.pm index 76daef9..1888699 100644 --- a/src/PVE/API2/TFA.pm +++ b/src/PVE/API2/TFA.pm @@ -4,7 +4,7 @@ use strict; use warnings; use PVE::AccessControl; -use PVE::Cluster qw(cfs_read_file); +use PVE::Cluster qw(cfs_read_file cfs_write_file); use PVE::JSONSchema qw(get_standard_option); use PVE::Exception qw(raise raise_perm_exc raise_param_exc); use PVE::RPCEnvironment; @@ -15,6 +15,110 @@ use PVE::RESTHandler; use base qw(PVE::RESTHandler); +my $OPTIONAL_PASSWORD_SCHEMA = { + description => "The current password.", + type => 'string', + optional => 1, # Only required if not root@pam + minLength => 5, + maxLength => 64 +}; + +my $TFA_TYPE_SCHEMA = { + type => 'string', + description => 'TFA Entry Type.', + enum => [qw(totp u2f webauthn recovery yubico)], +}; + +my %TFA_INFO_PROPERTIES = ( + id => { + type => 'string', + description => 'The id used to reference this entry.', + }, + description => { + type => 'string', + description => 'User chosen description for this entry.', + }, + created => { + type => 'integer', + description => 'Creation time of this entry as unix epoch.', + }, + enable => { + type => 'boolean', + description => 'Whether this TFA entry is currently enabled.', + optional => 1, + default => 1, + }, +); + +my $TYPED_TFA_ENTRY_SCHEMA = { + type => 'object', + description => 'TFA Entry.', + properties => { + type => $TFA_TYPE_SCHEMA, + %TFA_INFO_PROPERTIES, + }, +}; + +my $TFA_ID_SCHEMA = { + type => 'string', + description => 'A TFA entry id.', +}; + +my $TFA_UPDATE_INFO_SCHEMA = { + type => 'object', + properties => { + id => { + type => 'string', + description => 'The id of a newly added TFA entry.', + }, + challenge => { + type => 'string', + optional => 1, + description => + 'When adding u2f entries, this contains a challenge the user must respond to in order' + .' to finish the registration.' + }, + recovery => { + type => 'array', + optional => 1, + description => + 'When adding recovery codes, this contains the list of codes to be displayed to' + .' the user', + items => { + type => 'string', + description => 'A recovery entry.' + }, + }, + }, +}; + +# Only root may modify root, regular users need to specify their password. +# +# Returns the userid returned from `verify_username`. +# Or ($userid, $realm) in list context. +my sub root_permission_check : prototype($$$$) { + my ($rpcenv, $authuser, $userid, $password) = @_; + + ($userid, my $ruid, my $realm) = PVE::AccessControl::verify_username($userid); + $rpcenv->check_user_exist($userid); + + raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam'; + + # Regular users need to confirm their password to change TFA settings. + if ($authuser ne 'root@pam') { + raise_param_exc({ 'password' => 'password is required to modify TFA data' }) + if !defined($password); + + my $domain_cfg = cfs_read_file('domains.cfg'); + my $cfg = $domain_cfg->{ids}->{$realm}; + 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); + } + + return wantarray ? ($userid, $realm) : $userid; +} + ### OLD API __PACKAGE__->register_method({ @@ -89,47 +193,6 @@ __PACKAGE__->register_method({ ### END OLD API -my $TFA_TYPE_SCHEMA = { - type => 'string', - description => 'TFA Entry Type.', - enum => [qw(totp u2f webauthn recovery yubico)], -}; - -my %TFA_INFO_PROPERTIES = ( - id => { - type => 'string', - description => 'The id used to reference this entry.', - }, - description => { - type => 'string', - description => 'User chosen description for this entry.', - }, - created => { - type => 'integer', - description => 'Creation time of this entry as unix epoch.', - }, - enable => { - type => 'boolean', - description => 'Whether this TFA entry is currently enabled.', - optional => 1, - default => 1, - }, -); - -my $TYPED_TFA_ENTRY_SCHEMA = { - type => 'object', - description => 'TFA Entry.', - properties => { - type => $TFA_TYPE_SCHEMA, - %TFA_INFO_PROPERTIES, - }, -}; - -my $TFA_ID_SCHEMA = { - type => 'string', - description => 'A TFA entry id.', -}; - __PACKAGE__->register_method ({ name => 'list_user_tfa', path => '{userid}', @@ -174,7 +237,7 @@ __PACKAGE__->register_method ({ }, protected => 1, # else we can't access shadow files allowtoken => 0, # we don't want tokens to change the regular user's TFA settings - description => 'A requested TFA entry if present.', + description => 'Fetch a requested TFA entry if present.', parameters => { additionalProperties => 0, properties => { @@ -188,7 +251,51 @@ __PACKAGE__->register_method ({ code => sub { my ($param) = @_; my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); - return $tfa_cfg->api_get_tfa_entry($param->{userid}, $param->{id}); + my $id = $param->{id}; + my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id); + raise("No such tfa entry '$id'", 404) if !$entry; + return $entry; + }}); + +__PACKAGE__->register_method ({ + name => 'delete_tfa', + path => '{userid}/{id}', + method => 'DELETE', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['userid-group', ['User.Modify']], + ], + }, + protected => 1, # else we can't access shadow files + allowtoken => 0, # we don't want tokens to change the regular user's TFA settings + description => 'Delete a TFA entry by ID.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + id => $TFA_ID_SCHEMA, + password => $OPTIONAL_PASSWORD_SCHEMA, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + PVE::AccessControl::assert_new_tfa_config_available(); + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $userid = + root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password}); + + return PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + $tfa_cfg->api_delete_tfa($userid, $param->{id}); + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + }); }}); __PACKAGE__->register_method ({ @@ -228,12 +335,145 @@ __PACKAGE__->register_method ({ my $rpcenv = PVE::RPCEnvironment::get(); my $authuser = $rpcenv->get_user(); - - my $top_level_allowed = ($authuser eq 'root@pam'); my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); return $tfa_cfg->api_list_tfa($authuser, $top_level_allowed); }}); +__PACKAGE__->register_method ({ + name => 'add_tfa_entry', + path => '{userid}', + method => 'POST', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['userid-group', ['User.Modify']], + ], + }, + protected => 1, # else we can't access shadow files + allowtoken => 0, # we don't want tokens to change the regular user's TFA settings + description => 'Add a TFA entry for a user.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + type => $TFA_TYPE_SCHEMA, + description => { + type => 'string', + description => 'A description to distinguish multiple entries from one another', + maxLength => 255, + optional => 1, + }, + totp => { + type => 'string', + description => "A totp URI.", + optional => 1, + }, + value => { + type => 'string', + description => + 'The current value for the provided totp URI, or a Webauthn/U2F' + .' challenge response', + optional => 1, + }, + challenge => { + type => 'string', + description => 'When responding to a u2f challenge: the original challenge string', + optional => 1, + }, + password => $OPTIONAL_PASSWORD_SCHEMA, + }, + }, + returns => $TFA_UPDATE_INFO_SCHEMA, + code => sub { + my ($param) = @_; + + PVE::AccessControl::assert_new_tfa_config_available(); + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $userid = + root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password}); + + return PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + PVE::AccessControl::configure_u2f_and_wa($tfa_cfg); + + my $response = $tfa_cfg->api_add_tfa_entry( + $userid, + $param->{description}, + $param->{totp}, + $param->{value}, + $param->{challenge}, + $param->{type}, + ); + + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + + return $response; + }); + }}); + +__PACKAGE__->register_method ({ + name => 'update_tfa_entry', + path => '{userid}/{id}', + method => 'PUT', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['userid-group', ['User.Modify']], + ], + }, + protected => 1, # else we can't access shadow files + allowtoken => 0, # we don't want tokens to change the regular user's TFA settings + description => 'Add a TFA entry for a user.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + id => $TFA_ID_SCHEMA, + description => { + type => 'string', + description => 'A description to distinguish multiple entries from one another', + maxLength => 255, + optional => 1, + }, + enable => { + type => 'boolean', + description => 'Whether the entry should be enabled for login.', + optional => 1, + }, + password => $OPTIONAL_PASSWORD_SCHEMA, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + PVE::AccessControl::assert_new_tfa_config_available(); + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $userid = + root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password}); + + PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + + $tfa_cfg->api_update_tfa_entry( + $userid, + $param->{id}, + $param->{description}, + $param->{enable}, + ); + + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + }); + }}); + 1; diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm index c3d3d16..fd80368 100644 --- a/src/PVE/AccessControl.pm +++ b/src/PVE/AccessControl.pm @@ -77,6 +77,17 @@ sub lock_user_config { } } +sub lock_tfa_config { + my ($code, $errmsg) = @_; + + my $res = cfs_lock_file("priv/tfa.cfg", undef, $code); + if (my $err = $@) { + $errmsg ? die "$errmsg: $err" : die $err; + } + + return $res; +} + my $cache_read_key = sub { my ($type) = @_; @@ -1720,6 +1731,10 @@ my $USER_CONTROLLED_TFA_TYPES = { oath => 1, }; +sub assert_new_tfa_config_available() { + # FIXME: Assert cluster-wide new-tfa-config support! +} + sub user_get_tfa : prototype($$$) { my ($username, $realm, $new_format) = @_; -- 2.30.2