package PVE::Auth::LDAP; use strict; use warnings; 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', pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*', 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', pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*', 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', pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*', 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 }, }; } 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) = @_; 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 = 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', }; foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) { my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/); $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}) { $ret->{$username}->{$ours} = $user_attributes->{$attr}->[0]; } } if (wantarray) { my $dn = $user->{dn}; $dnmap->{$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->{$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); } 1;