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 3A15C757F0 for ; Thu, 24 Jun 2021 10:18:44 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id DE54313F8A for ; Thu, 24 Jun 2021 10:18:13 +0200 (CEST) Received: from dev7.proxmox.com (unknown [94.136.29.99]) by firstgate.proxmox.com (Proxmox) with ESMTP id B633B13E9A for ; Thu, 24 Jun 2021 10:18:10 +0200 (CEST) Received: by dev7.proxmox.com (Postfix, from userid 0) id 84FFC80E22; Thu, 24 Jun 2021 10:18:04 +0200 (CEST) From: Dietmar Maurer To: pve-devel@lists.proxmox.com Date: Thu, 24 Jun 2021 10:18:01 +0200 Message-Id: <20210624081802.2090614-4-dietmar@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20210624081802.2090614-1-dietmar@proxmox.com> References: <20210624081802.2090614-1-dietmar@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.604 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 PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH pve-access-control 3/4] api: implement openid API 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: Thu, 24 Jun 2021 08:18:44 -0000 This moves compute_api_permission() into RPCEnvironment.pm. --- src/PVE/API2/AccessControl.pm | 60 ++-------- src/PVE/API2/Makefile | 3 +- src/PVE/API2/OpenId.pm | 214 ++++++++++++++++++++++++++++++++++ src/PVE/RPCEnvironment.pm | 49 ++++++++ 4 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 src/PVE/API2/OpenId.pm diff --git a/src/PVE/API2/AccessControl.pm b/src/PVE/API2/AccessControl.pm index a77694b..6dec66c 100644 --- a/src/PVE/API2/AccessControl.pm +++ b/src/PVE/API2/AccessControl.pm @@ -19,9 +19,9 @@ use PVE::API2::User; use PVE::API2::Group; use PVE::API2::Role; use PVE::API2::ACL; +use PVE::API2::OpenId; use PVE::Auth::Plugin; use PVE::OTP; -use PVE::Tools; my $u2f_available = 0; eval { @@ -56,6 +56,11 @@ __PACKAGE__->register_method ({ path => 'domains', }); +__PACKAGE__->register_method ({ + subclass => "PVE::API2::OpenId", + path => 'openid', +}); + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -165,55 +170,6 @@ my $create_ticket = sub { }; }; -my $compute_api_permission = sub { - my ($rpcenv, $authuser) = @_; - - my $usercfg = $rpcenv->{user_cfg}; - - my $res = {}; - my $priv_re_map = { - vms => qr/VM\.|Permissions\.Modify/, - access => qr/(User|Group)\.|Permissions\.Modify/, - storage => qr/Datastore\.|Permissions\.Modify/, - nodes => qr/Sys\.|Permissions\.Modify/, - sdn => qr/SDN\.|Permissions\.Modify/, - dc => qr/Sys\.Audit|SDN\./, - }; - map { $res->{$_} = {} } keys %$priv_re_map; - - my $required_paths = ['/', '/nodes', '/access/groups', '/vms', '/storage', '/sdn']; - - my $checked_paths = {}; - foreach my $path (@$required_paths, keys %{$usercfg->{acl}}) { - next if $checked_paths->{$path}; - $checked_paths->{$path} = 1; - - my $path_perm = $rpcenv->permissions($authuser, $path); - - my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc'; - if ($toplevel eq 'pool') { - foreach my $priv (keys %$path_perm) { - if ($priv =~ m/^VM\./) { - $res->{vms}->{$priv} = 1; - } elsif ($priv =~ m/^Datastore\./) { - $res->{storage}->{$priv} = 1; - } elsif ($priv eq 'Permissions.Modify') { - $res->{storage}->{$priv} = 1; - $res->{vms}->{$priv} = 1; - } - } - } else { - my $priv_regex = $priv_re_map->{$toplevel} // next; - foreach my $priv (keys %$path_perm) { - next if $priv !~ m/^($priv_regex)/; - $res->{$toplevel}->{$priv} = 1; - } - } - } - - return $res; -}; - __PACKAGE__->register_method ({ name => 'get_ticket', path => 'ticket', @@ -314,7 +270,7 @@ __PACKAGE__->register_method ({ die PVE::Exception->new("authentication failure\n", code => 401); } - $res->{cap} = &$compute_api_permission($rpcenv, $username) + $res->{cap} = $rpcenv->compute_api_permission($username) if !defined($res->{NeedTFA}); my $clinfo = PVE::Cluster::get_clinfo(); @@ -659,7 +615,7 @@ __PACKAGE__->register_method({ return { ticket => PVE::AccessControl::assemble_ticket($authuser), - cap => &$compute_api_permission($rpcenv, $authuser), + cap => $rpcenv->compute_api_permission($authuser), } }}); diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile index 1bf8c05..4e49037 100644 --- a/src/PVE/API2/Makefile +++ b/src/PVE/API2/Makefile @@ -5,7 +5,8 @@ API2_SOURCES= \ ACL.pm \ Role.pm \ Group.pm \ - User.pm + User.pm \ + OpenId.pm .PHONY: install install: diff --git a/src/PVE/API2/OpenId.pm b/src/PVE/API2/OpenId.pm new file mode 100644 index 0000000..db9f9eb --- /dev/null +++ b/src/PVE/API2/OpenId.pm @@ -0,0 +1,214 @@ +package PVE::API2::OpenId; + +use strict; +use warnings; + +use PVE::Tools qw(extract_param); +use PVE::RS::OpenId; + +use PVE::Exception qw(raise raise_perm_exc raise_param_exc); +use PVE::SafeSyslog; +use PVE::RPCEnvironment; +use PVE::Cluster qw(cfs_read_file); +use PVE::AccessControl; +use PVE::JSONSchema qw(get_standard_option); + +use PVE::RESTHandler; + +use base qw(PVE::RESTHandler); + +my $openid_state_path = "/var/lib/pve-manager"; + +my $lookup_openid_auth = sub { + my ($realm, $redirect_url) = @_; + + my $cfg = cfs_read_file('domains.cfg'); + my $ids = $cfg->{ids}; + + die "authentication domain '$realm' does not exist\n" if !$ids->{$realm}; + + my $config = $ids->{$realm}; + die "wrong realm type ($config->{type} != openid)" if $config->{type} ne "openid"; + + my $openid_config = { + issuer_url => $config->{'issuer-url'}, + client_id => $config->{'client-id'}, + client_key => $config->{'client-key'}, + }; + + my $openid = PVE::RS::OpenId->discover($openid_config, $redirect_url); + return ($config, $openid); +}; + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + description => "Directory index.", + permissions => { + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + subdir => { type => 'string' }, + }, + }, + links => [ { rel => 'child', href => "{subdir}" } ], + }, + code => sub { + my ($param) = @_; + + return [ + { subdir => 'auth-url' }, + { subdir => 'login' }, + ]; + }}); + +__PACKAGE__->register_method ({ + name => 'auth_url', + path => 'auth-url', + method => 'POST', + protected => 1, + description => "Get the OpenId Authorization Url for the specified realm.", + parameters => { + additionalProperties => 0, + properties => { + realm => get_standard_option('realm'), + 'redirect-url' => { + description => "Redirection Url. The client should set this to the used server url (location.origin).", + type => 'string', + maxLength => 255, + }, + }, + }, + returns => { + type => "string", + description => "Redirection URL.", + }, + permissions => { user => 'world' }, + code => sub { + my ($param) = @_; + + my $realm = extract_param($param, 'realm'); + my $redirect_url = extract_param($param, 'redirect-url'); + + my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); + my $url = $openid->authorize_url($openid_state_path , $realm); + + return $url; + }}); + +__PACKAGE__->register_method ({ + name => 'login', + path => 'login', + method => 'POST', + protected => 1, + description => " Verify OpenID authorization code and create a ticket.", + parameters => { + additionalProperties => 0, + properties => { + 'state' => { + description => "OpenId state.", + type => 'string', + maxLength => 1024, + }, + code => { + description => "OpenId authorization code.", + type => 'string', + maxLength => 1024, + }, + 'redirect-url' => { + description => "Redirection Url. The client should set this to the used server url (location.origin).", + type => 'string', + maxLength => 255, + }, + }, + }, + returns => { + properties => { + username => { type => 'string' }, + ticket => { type => 'string' }, + CSRFPreventionToken => { type => 'string' }, + cap => { type => 'object' }, # computed api permissions + clustername => { type => 'string', optional => 1 }, + }, + }, + permissions => { user => 'world' }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + + my $res; + eval { + use Data::Dumper; + + # fixme /tmp + my ($realm, $private_auth_state) = PVE::RS::OpenId::verify_public_auth_state( + $openid_state_path, $param->{'state'}); + + my $redirect_url = extract_param($param, 'redirect-url'); + + my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); + + my $info = $openid->verify_authorization_code($param->{code}, $private_auth_state); + my $subject = $info->{'sub'}; + + die "missing openid claim 'sub'" if !defined($subject); + + my $unique_name = $subject; # default + if (defined(my $user_attr = $config->{'user-attr'})) { + if ($user_attr eq 'subject') { + $unique_name = $subject; + } elsif ($user_attr eq 'username') { + my $username = $info->{'preferred_username'}; + die "missing claim 'preferred_username'" if !defined($username); + $unique_name = $username; + } elsif ($user_attr eq 'email') { + my $email = $info->{'email'}; + die "missing claim 'email'" if !defined($email); + $unique_name = $email; + } else { + die "got unexpected value for user_attr '${user_attr}'"; + } + } + + my $username = "${unique_name}\@${realm}"; + + # test if user exists and is enabled + $rpcenv->check_user_enabled($username); + + my $ticket = PVE::AccessControl::assemble_ticket($username); + my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username); + my $cap = $rpcenv->compute_api_permission($username); + + $res = { + ticket => $ticket, + username => $username, + CSRFPreventionToken => $csrftoken, + cap => $cap, + }; + + my $clinfo = PVE::Cluster::get_clinfo(); + if ($clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1)) { + $res->{clustername} = $clinfo->{cluster}->{name}; + } + }; + if (my $err = $@) { + my $clientip = $rpcenv->get_client_ip() || ''; + syslog('err', "openid authentication failure; rhost=$clientip msg=$err"); + # do not return any info to prevent user enumeration attacks + die PVE::Exception->new("authentication failure\n", code => 401); + } + + PVE::Cluster::log_msg('info', 'root@pam', "successful openid auth for user '$res->{username}'"); + + return $res; + }}); diff --git a/src/PVE/RPCEnvironment.pm b/src/PVE/RPCEnvironment.pm index 2e5a33b..8aae094 100644 --- a/src/PVE/RPCEnvironment.pm +++ b/src/PVE/RPCEnvironment.pm @@ -126,6 +126,55 @@ sub permissions { return &$compile_acl_path($self, $user, $path); } +sub compute_api_permission { + my ($self, $authuser) = @_; + + my $usercfg = $self->{user_cfg}; + + my $res = {}; + my $priv_re_map = { + vms => qr/VM\.|Permissions\.Modify/, + access => qr/(User|Group)\.|Permissions\.Modify/, + storage => qr/Datastore\.|Permissions\.Modify/, + nodes => qr/Sys\.|Permissions\.Modify/, + sdn => qr/SDN\.|Permissions\.Modify/, + dc => qr/Sys\.Audit|SDN\./, + }; + map { $res->{$_} = {} } keys %$priv_re_map; + + my $required_paths = ['/', '/nodes', '/access/groups', '/vms', '/storage', '/sdn']; + + my $checked_paths = {}; + foreach my $path (@$required_paths, keys %{$usercfg->{acl}}) { + next if $checked_paths->{$path}; + $checked_paths->{$path} = 1; + + my $path_perm = $self->permissions($authuser, $path); + + my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc'; + if ($toplevel eq 'pool') { + foreach my $priv (keys %$path_perm) { + if ($priv =~ m/^VM\./) { + $res->{vms}->{$priv} = 1; + } elsif ($priv =~ m/^Datastore\./) { + $res->{storage}->{$priv} = 1; + } elsif ($priv eq 'Permissions.Modify') { + $res->{storage}->{$priv} = 1; + $res->{vms}->{$priv} = 1; + } + } + } else { + my $priv_regex = $priv_re_map->{$toplevel} // next; + foreach my $priv (keys %$path_perm) { + next if $priv !~ m/^($priv_regex)/; + $res->{$toplevel}->{$priv} = 1; + } + } + } + + return $res; +} + sub get_effective_permissions { my ($self, $user) = @_; -- 2.30.2