[pve-devel] [PATCH pve-access-control 3/4] api: implement openid API

Dietmar Maurer dietmar at proxmox.com
Thu Jun 24 10:18:01 CEST 2021


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 at 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





More information about the pve-devel mailing list