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 (!length($name // '') && $group->{dn} =~ m/^[^=]+=([^,]+),/) { $name = PVE::Tools::trim($1); } if (length($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) = @_; # an empty password would merely trigger an anonymous bind on some LDAP # servers, never let it pose as the user die "no password\n" if !$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;