diff --git a/src/PVE/Auth/LDAP.pm b/src/PVE/Auth/LDAP.pm new file mode 100644 index 0000000..6a73ee8 --- /dev/null +++ b/src/PVE/Auth/LDAP.pm @@ -0,0 +1,498 @@ +package PVE::Auth::LDAP; + +use strict; +use warnings; + +use Encode; + +use PVE::Auth::Plugin; +use PVE::JSONSchema; +use PVE::LDAP; +use PVE::Tools; + +use base qw(PVE::Auth::Plugin); + +sub type { + return 'ldap'; +} + +sub properties { + return { + base_dn => { + description => "LDAP base domain name", + type => 'string', + optional => 1, + maxLength => 256, + }, + user_attr => { + description => "LDAP user attribute name", + type => 'string', + pattern => '\S{2,}', + optional => 1, + maxLength => 256, + }, + bind_dn => { + description => "LDAP bind domain name", + type => 'string', + optional => 1, + maxLength => 256, + }, + password => { + description => + "LDAP bind password. Will be stored in '/etc/pve/priv/realm/.pw'.", + type => 'string', + optional => 1, + }, + verify => { + description => "Verify the server's SSL certificate", + type => 'boolean', + optional => 1, + default => 0, + }, + capath => { + description => "Path to the CA certificate store", + type => 'string', + optional => 1, + default => '/etc/ssl/certs', + }, + cert => { + description => "Path to the client certificate", + type => 'string', + optional => 1, + }, + certkey => { + description => "Path to the client certificate key", + type => 'string', + optional => 1, + }, + filter => { + description => "LDAP filter for user sync.", + type => 'string', + optional => 1, + maxLength => 2048, + }, + sync_attributes => { + description => "Comma separated list of key=value pairs for specifying" + . " which LDAP attributes map to which PVE user field. For example," + . " to map the LDAP attribute 'mail' to PVEs 'email', write " + . " 'email=mail'. By default, each PVE user field is represented " + . " by an LDAP attribute of the same name.", + optional => 1, + type => 'string', + pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*', + }, + user_classes => { + description => "The objectclasses for users.", + type => 'string', + default => 'inetorgperson, posixaccount, person, user', + format => 'ldap-simple-attr-list', + optional => 1, + }, + group_dn => { + description => "LDAP base domain name for group sync. If not set, the" + . " base_dn will be used.", + type => 'string', + optional => 1, + maxLength => 256, + }, + group_name_attr => { + description => "LDAP attribute representing a groups name. If not set" + . " or found, the first value of the DN will be used as name.", + type => 'string', + format => 'ldap-simple-attr', + optional => 1, + maxLength => 256, + }, + group_filter => { + description => "LDAP filter for group sync.", + type => 'string', + optional => 1, + maxLength => 2048, + }, + group_classes => { + description => "The objectclasses for groups.", + type => 'string', + default => 'groupOfNames, group, univentionGroup, ipausergroup', + format => 'ldap-simple-attr-list', + optional => 1, + }, + 'sync-defaults-options' => { + description => "The default options for behavior of synchronizations.", + type => 'string', + format => 'realm-sync-options', + optional => 1, + }, + mode => { + description => "LDAP protocol mode.", + type => 'string', + enum => ['ldap', 'ldaps', 'ldap+starttls'], + optional => 1, + default => 'ldap', + }, + 'case-sensitive' => { + description => "username is case-sensitive", + type => 'boolean', + optional => 1, + default => 1, + }, + }; +} + +sub options { + return { + server1 => {}, + server2 => { optional => 1 }, + base_dn => {}, + bind_dn => { optional => 1 }, + password => { optional => 1 }, + user_attr => {}, + port => { optional => 1 }, + secure => { optional => 1 }, + sslversion => { optional => 1 }, + default => { optional => 1 }, + comment => { optional => 1 }, + tfa => { optional => 1 }, + verify => { optional => 1 }, + capath => { optional => 1 }, + cert => { optional => 1 }, + certkey => { optional => 1 }, + filter => { optional => 1 }, + sync_attributes => { optional => 1 }, + user_classes => { optional => 1 }, + group_dn => { optional => 1 }, + group_name_attr => { optional => 1 }, + group_filter => { optional => 1 }, + group_classes => { optional => 1 }, + 'sync-defaults-options' => { optional => 1 }, + mode => { optional => 1 }, + 'case-sensitive' => { optional => 1 }, + }; +} + +my sub verify_sync_attribute_value { + my ($attr, $value) = @_; + + # The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username + if ($attr eq 'username') { + die "value '$value' does not look like a valid user name\n" + if $value !~ m/${PVE::Auth::Plugin::user_regex}/; + return; + } + + return if $attr eq 'enable'; # for backwards compat, don't parse/validate + + if (my $schema = PVE::JSONSchema::get_standard_option("user-$attr")) { + PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n"); + } else { + die "internal error: no schema for attribute '$attr' with value '$value' available!\n"; + } +} + +sub get_scheme_and_port { + my ($class, $config) = @_; + + my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap'); + + my $default_port = $scheme eq 'ldaps' ? 636 : 389; + my $port = $config->{port} // $default_port; + + return ($scheme, $port); +} + +sub connect_and_bind { + my ($class, $config, $realm, $param) = @_; + + my $servers = [$config->{server1}]; + push @$servers, $config->{server2} if $config->{server2}; + + my ($scheme, $port) = $class->get_scheme_and_port($config); + + my %ldap_args; + if ($config->{verify}) { + $ldap_args{verify} = 'require'; + $ldap_args{clientcert} = $config->{cert} if $config->{cert}; + $ldap_args{clientkey} = $config->{certkey} if $config->{certkey}; + if (defined(my $capath = $config->{capath})) { + if (-d $capath) { + $ldap_args{capath} = $capath; + } else { + $ldap_args{cafile} = $capath; + } + } + } else { + $ldap_args{verify} = 'none'; + } + + if ($scheme ne 'ldap') { + $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2'; + } + + my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args); + + if ($config->{bind_dn}) { + my $bind_dn = $config->{bind_dn}; + my $bind_pass = $param->{password} || ldap_get_credentials($realm); + die "missing password for realm $realm\n" if !defined($bind_pass); + PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass); + } elsif ($config->{cert} && $config->{certkey}) { + warn "skipping anonymous bind with clientcert\n"; + } else { + PVE::LDAP::ldap_bind($ldap); + } + + if (!$config->{base_dn}) { + my $root = $ldap->root_dse(attrs => ['defaultNamingContext']); + $config->{base_dn} = $root->get_value('defaultNamingContext'); + } + + return $ldap; +} + +# returns: +# { +# 'username@realm' => { +# 'attr1' => 'value1', +# 'attr2' => 'value2', +# ... +# }, +# ... +# } +# +# or in list context: +# ( +# { +# 'username@realm' => { +# 'attr1' => 'value1', +# 'attr2' => 'value2', +# ... +# }, +# ... +# }, +# { +# 'uid=username,dc=....' => 'username@realm', +# ... +# } +# ) +# the map of dn->username is needed for group membership sync +sub get_users { + my ($class, $config, $realm) = @_; + + my $ldap = $class->connect_and_bind($config, $realm); + + my $user_name_attr = $config->{user_attr} // 'uid'; + my $ldap_attribute_map = { + $user_name_attr => 'username', + enable => 'enable', + expire => 'expire', + firstname => 'firstname', + lastname => 'lastname', + email => 'email', + comment => 'comment', + keys => 'keys', + # NOTE: also ensure verify_sync_attribute_value can handle any new/changed attribute name + }; + # build on the fly as this is small and only called once per realm in a ldap-sync anyway + my $valid_sync_attributes = { map { $_ => 1 } values $ldap_attribute_map->%* }; + + foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) { + my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/); + if (!$valid_sync_attributes->{$ours}) { + warn "skipping bad 'sync_attributes' entry – '$ours' is not a valid target attribute\n"; + next; + } + $ldap_attribute_map->{$ldap} = $ours; + } + + my $filter = $config->{filter}; + my $basedn = $config->{base_dn}; + + $config->{user_classes} //= 'inetorgperson, posixaccount, person, user'; + my $classes = [PVE::Tools::split_list($config->{user_classes})]; + + my $users = + PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes); + + my $ret = {}; + my $dnmap = {}; + + foreach my $user (@$users) { + my $user_attributes = $user->{attributes}; + my $userid = $user_attributes->{$user_name_attr}->[0]; + my $username = "$userid\@$realm"; + + # we cannot sync usernames that do not meet our criteria + eval { PVE::Auth::Plugin::verify_username($username) }; + if (my $err = $@) { + warn "$err"; + next; + } + + $ret->{$username} = {}; + + foreach my $attr (keys %$user_attributes) { + if (my $ours = $ldap_attribute_map->{$attr}) { + my $value = $user_attributes->{$attr}->[0]; + # Net::LDAP returns octets; DirectoryString-syntax attributes (sn, givenName, mail, + # description, ...) are UTF-8 on the wire, so mark them as such before we hand them + # off to the rest of the stack, which assumes character strings. + $value = Encode::decode('UTF-8', $value) if defined($value); + eval { verify_sync_attribute_value($ours, $value) }; + if (my $err = $@) { + warn "skipping attribute mapping '$attr'->'$ours' for user '$username' - $err"; + next; + } + $ret->{$username}->{$ours} = $value; + } + } + + if (wantarray) { + my $dn = $user->{dn}; + $dnmap->{ lc($dn) } = $username; + } + } + + return wantarray ? ($ret, $dnmap) : $ret; +} + +# needs a map for dn -> username, we get this from the get_users call +# otherwise we cannot determine the group membership +sub get_groups { + my ($class, $config, $realm, $dnmap) = @_; + + my $filter = $config->{group_filter}; + my $basedn = $config->{group_dn} // $config->{base_dn}; + my $attr = $config->{group_name_attr}; + $config->{group_classes} //= 'groupOfNames, group, univentionGroup, ipausergroup'; + my $classes = [PVE::Tools::split_list($config->{group_classes})]; + + my $ldap = $class->connect_and_bind($config, $realm); + + my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr); + + my $ret = {}; + + foreach my $group (@$groups) { + my $name = $group->{name}; + if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/) { + $name = PVE::Tools::trim($1); + } + if ($name) { + $name .= "-$realm"; + + # we cannot sync groups that do not meet our criteria + eval { PVE::AccessControl::verify_groupname($name) }; + if (my $err = $@) { + warn "$err"; + next; + } + + $ret->{$name} = { users => {} }; + foreach my $member (@{ $group->{members} }) { + if (my $user = $dnmap->{ lc($member) }) { + $ret->{$name}->{users}->{$user} = 1; + } + } + } + } + + return $ret; +} + +sub authenticate_user { + my ($class, $config, $realm, $username, $password) = @_; + + my $ldap = $class->connect_and_bind($config, $realm); + + my $user_dn = + PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn}); + PVE::LDAP::auth_user_dn($ldap, $user_dn, $password); + + $ldap->unbind(); + return 1; +} + +my $ldap_pw_dir = "/etc/pve/priv/realm"; + +sub ldap_cred_file_name { + my ($realmid) = @_; + return "${ldap_pw_dir}/${realmid}.pw"; +} + +sub get_cred_file { + my ($realmid) = @_; + + my $cred_file = ldap_cred_file_name($realmid); + if (-e $cred_file) { + return $cred_file; + } elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") { + # FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x + return "/etc/pve/priv/ldap/${realmid}.pw"; + } + + return $cred_file; +} + +sub ldap_set_credentials { + my ($password, $realmid) = @_; + + my $cred_file = ldap_cred_file_name($realmid); + mkdir $ldap_pw_dir; + + PVE::Tools::file_set_contents($cred_file, $password); + + return $cred_file; +} + +sub ldap_get_credentials { + my ($realmid) = @_; + + if (my $cred_file = get_cred_file($realmid)) { + return PVE::Tools::file_read_firstline($cred_file); + } + return undef; +} + +sub ldap_delete_credentials { + my ($realmid) = @_; + + if (my $cred_file = get_cred_file($realmid)) { + return if !-e $cred_file; # nothing to do + unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n"; + } +} + +sub on_add_hook { + my ($class, $realm, $config, %param) = @_; + + if (defined($param{password})) { + ldap_set_credentials($param{password}, $realm); + } else { + ldap_delete_credentials($realm); + } +} + +sub on_update_hook { + my ($class, $realm, $config, %param) = @_; + + return if !exists($param{password}); + + if (defined($param{password})) { + ldap_set_credentials($param{password}, $realm); + } else { + ldap_delete_credentials($realm); + } +} + +sub on_delete_hook { + my ($class, $realm, $config) = @_; + + ldap_delete_credentials($realm); +} + +sub check_connection { + my ($class, $realm, $config, %param) = @_; + + $class->connect_and_bind($config, $realm, \%param); +} + +1; diff --git a/src/test/realm_sync_test.pl b/src/test/realm_sync_test.pl new file mode 100644 index 0000000..10520db --- /dev/null +++ b/src/test/realm_sync_test.pl @@ -0,0 +1,359 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::MockModule; +use Test::More; +use Storable qw(dclone); + +use PVE::AccessControl; +use PVE::API2::Domains; + +my $domainscfg = { + ids => { + "pam" => { type => 'pam' }, + "pve" => { type => 'pve' }, + "syncedrealm" => { type => 'ldap' }, + }, +}; + +my $initialusercfg = { + users => { + 'root@pam' => { username => 'root' }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user3@syncedrealm' => { + username => 'user3', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { users => {} }, + 'group2-syncedrealm' => { users => {} }, + }, + acl_root => { + users => { + 'user3@syncedrealm' => {}, + }, + groups => {}, + }, +}; + +my $sync_response = { + user => [ + { + attributes => { 'uid' => ['user1'] }, + dn => 'uid=user1,dc=syncedrealm', + }, + { + attributes => { 'uid' => ['user2'] }, + dn => 'uid=user2,dc=syncedrealm', + }, + { + attributes => { 'uid' => ['user4'] }, + dn => 'uid=user4,dc=syncedrealm', + }, + ], + groups => [ + { + dn => 'dc=group1,dc=syncedrealm', + members => [ + 'uid=user1,dc=syncedrealm', + ], + }, + { + dn => 'dc=group3,dc=syncedrealm', + members => [ + 'uid=nonexisting,dc=syncedrealm', + ], + }, + ], +}; + +my $returned_user_cfg = {}; + +# mocking all cluster and ldap operations +my $pve_cluster_module = Test::MockModule->new('PVE::Cluster'); +$pve_cluster_module->mock( + cfs_update => sub { }, + cfs_read_file => sub { + my ($filename) = @_; + if ($filename eq 'domains.cfg') { return dclone($domainscfg); } + if ($filename eq 'user.cfg') { return dclone($initialusercfg); } + die "unexpected cfs_read_file"; + }, + cfs_write_file => sub { + my ($filename, $data) = @_; + if ($filename eq 'user.cfg') { + $returned_user_cfg = $data; + return; + } + die "unexpected cfs_read_file"; + }, + cfs_lock_file => sub { + my ($filename, $timeout, $code) = @_; + return $code->(); + }, +); + +my $pve_api_domains = Test::MockModule->new('PVE::API2::Domains'); +$pve_api_domains->mock( + cfs_read_file => sub { PVE::Cluster::cfs_read_file(@_); }, + cfs_write_file => sub { PVE::Cluster::cfs_write_file(@_); }, +); + +my $pve_accesscontrol = Test::MockModule->new('PVE::AccessControl'); +$pve_accesscontrol->mock( + cfs_lock_file => sub { PVE::Cluster::cfs_lock_file(@_); }, +); + +my $pve_rpcenvironment = Test::MockModule->new('PVE::RPCEnvironment'); +$pve_rpcenvironment->mock( + get => sub { return bless {}, 'PVE::RPCEnvironment'; }, + get_user => sub { return 'root@pam'; }, + fork_worker => sub { + my ($class, $workertype, $id, $user, $code) = @_; + + return $code->(); + }, +); + +my $pve_ldap_module = Test::MockModule->new('PVE::LDAP'); +$pve_ldap_module->mock( + ldap_connect => sub { return {}; }, + ldap_bind => sub { }, + query_users => sub { + return $sync_response->{user}; + }, + query_groups => sub { + return $sync_response->{groups}; + }, +); + +my $pve_auth_ldap = Test::MockModule->new('PVE::Auth::LDAP'); +$pve_auth_ldap->mock( + connect_and_bind => sub { return {}; }, +); + +my $tests = [ + [ + "non-full without purge", + { + realm => 'syncedrealm', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root' }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user3@syncedrealm' => { + username => 'user3', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group2-syncedrealm' => { users => {} }, + 'group3-syncedrealm' => { users => {} }, + }, + acl_root => { + users => { + 'user3@syncedrealm' => {}, + }, + groups => {}, + }, + }, + ], + [ + "full without purge", + { + realm => 'syncedrealm', + 'remove-vanished' => 'entry;properties', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root' }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group3-syncedrealm' => { users => {} }, + }, + acl_root => { + users => { + 'user3@syncedrealm' => {}, + }, + groups => {}, + }, + }, + ], + [ + "non-full with purge", + { + realm => 'syncedrealm', + 'remove-vanished' => 'acl', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root' }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user3@syncedrealm' => { + username => 'user3', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group2-syncedrealm' => { users => {} }, + 'group3-syncedrealm' => { users => {} }, + }, + acl_root => { + users => {}, + groups => {}, + }, + }, + ], + [ + "full with purge", + { + realm => 'syncedrealm', + 'remove-vanished' => 'acl;entry;properties', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root' }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group3-syncedrealm' => { users => {} }, + }, + acl_root => { + users => {}, + groups => {}, + }, + }, + ], + [ + "don't delete properties, but users and acls", + { + realm => 'syncedrealm', + 'remove-vanished' => 'acl;entry', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root' }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group3-syncedrealm' => { users => {} }, + }, + acl_root => { + users => {}, + groups => {}, + }, + }, + ], +]; + +for my $test (@$tests) { + my $name = $test->[0]; + my $parameters = $test->[1]; + my $expected = $test->[2]; + $returned_user_cfg = {}; + PVE::API2::Domains->sync($parameters); + is_deeply($returned_user_cfg, $expected, $name); +} + +done_testing();