]> git.proxmox.com Git - pve-access-control.git/blame - src/PVE/Auth/LDAP.pm
bump version to 8.1.4
[pve-access-control.git] / src / PVE / Auth / LDAP.pm
CommitLineData
5bb4e06a
DM
1package PVE::Auth::LDAP;
2
3use strict;
7c410d63
DM
4use warnings;
5
5bb4e06a 6use PVE::Auth::Plugin;
d29d2d4a 7use PVE::JSONSchema;
d9e93d2e 8use PVE::LDAP;
d29d2d4a
TL
9use PVE::Tools;
10
5bb4e06a
DM
11use base qw(PVE::Auth::Plugin);
12
13sub type {
14 return 'ldap';
15}
16
17sub properties {
18 return {
19 base_dn => {
20 description => "LDAP base domain name",
21 type => 'string',
5bb4e06a
DM
22 optional => 1,
23 maxLength => 256,
24 },
25 user_attr => {
26 description => "LDAP user attribute name",
27 type => 'string',
28 pattern => '\S{2,}',
29 optional => 1,
30 maxLength => 256,
31 },
b5040b42
WB
32 bind_dn => {
33 description => "LDAP bind domain name",
34 type => 'string',
b5040b42
WB
35 optional => 1,
36 maxLength => 256,
37 },
782b702d
DC
38 password => {
39 description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
40 type => 'string',
41 optional => 1,
42 },
e03c2aef
WB
43 verify => {
44 description => "Verify the server's SSL certificate",
45 type => 'boolean',
46 optional => 1,
47 default => 0,
48 },
49 capath => {
50 description => "Path to the CA certificate store",
51 type => 'string',
52 optional => 1,
53 default => '/etc/ssl/certs',
54 },
55 cert => {
56 description => "Path to the client certificate",
57 type => 'string',
58 optional => 1,
59 },
60 certkey => {
61 description => "Path to the client certificate key",
62 type => 'string',
63 optional => 1,
64 },
eba326d2
DC
65 filter => {
66 description => "LDAP filter for user sync.",
67 type => 'string',
68 optional => 1,
69 maxLength => 2048,
70 },
71 sync_attributes => {
72 description => "Comma separated list of key=value pairs for specifying"
73 ." which LDAP attributes map to which PVE user field. For example,"
74 ." to map the LDAP attribute 'mail' to PVEs 'email', write "
75 ." 'email=mail'. By default, each PVE user field is represented "
76 ." by an LDAP attribute of the same name.",
77 optional => 1,
78 type => 'string',
79 pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
80 },
81 user_classes => {
82 description => "The objectclasses for users.",
83 type => 'string',
84 default => 'inetorgperson, posixaccount, person, user',
85 format => 'ldap-simple-attr-list',
86 optional => 1,
87 },
88 group_dn => {
89 description => "LDAP base domain name for group sync. If not set, the"
90 ." base_dn will be used.",
91 type => 'string',
eba326d2
DC
92 optional => 1,
93 maxLength => 256,
94 },
95 group_name_attr => {
96 description => "LDAP attribute representing a groups name. If not set"
97 ." or found, the first value of the DN will be used as name.",
98 type => 'string',
99 format => 'ldap-simple-attr',
100 optional => 1,
101 maxLength => 256,
102 },
103 group_filter => {
104 description => "LDAP filter for group sync.",
105 type => 'string',
106 optional => 1,
107 maxLength => 2048,
108 },
109 group_classes => {
110 description => "The objectclasses for groups.",
111 type => 'string',
112 default => 'groupOfNames, group, univentionGroup, ipausergroup',
113 format => 'ldap-simple-attr-list',
114 optional => 1,
115 },
d29d2d4a
TL
116 'sync-defaults-options' => {
117 description => "The default options for behavior of synchronizations.",
118 type => 'string',
119 format => 'realm-sync-options',
120 optional => 1,
121 },
72a9742b
DC
122 mode => {
123 description => "LDAP protocol mode.",
124 type => 'string',
125 enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
126 optional => 1,
127 default => 'ldap',
128 },
eb41d200
WL
129 'case-sensitive' => {
130 description => "username is case-sensitive",
131 type => 'boolean',
132 optional => 1,
133 default => 1,
fbb1fa44 134 },
5bb4e06a
DM
135 };
136}
137
138sub options {
139 return {
140 server1 => {},
141 server2 => { optional => 1 },
142 base_dn => {},
b5040b42 143 bind_dn => { optional => 1 },
782b702d 144 password => { optional => 1 },
5bb4e06a
DM
145 user_attr => {},
146 port => { optional => 1 },
147 secure => { optional => 1 },
07dd90d7 148 sslversion => { optional => 1 },
5bb4e06a
DM
149 default => { optional => 1 },
150 comment => { optional => 1 },
96f8ebd6 151 tfa => { optional => 1 },
e03c2aef
WB
152 verify => { optional => 1 },
153 capath => { optional => 1 },
154 cert => { optional => 1 },
155 certkey => { optional => 1 },
eba326d2
DC
156 filter => { optional => 1 },
157 sync_attributes => { optional => 1 },
158 user_classes => { optional => 1 },
159 group_dn => { optional => 1 },
160 group_name_attr => { optional => 1 },
161 group_filter => { optional => 1 },
162 group_classes => { optional => 1 },
d29d2d4a 163 'sync-defaults-options' => { optional => 1 },
72a9742b 164 mode => { optional => 1 },
eb41d200 165 'case-sensitive' => { optional => 1 },
5bb4e06a
DM
166 };
167}
168
7abb20a1 169my sub verify_sync_attribute_value {
cb93636b
FE
170 my ($attr, $value) = @_;
171
cb93636b
FE
172 # The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username
173 if ($attr eq 'username') {
174 die "value '$value' does not look like a valid user name\n"
175 if $value !~ m/${PVE::Auth::Plugin::user_regex}/;
176 return;
177 }
178
179 return if $attr eq 'enable'; # for backwards compat, don't parse/validate
180
793039db
TL
181 if (my $schema = PVE::JSONSchema::get_standard_option("user-$attr")) {
182 PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n");
183 } else {
184 die "internal error: no schema for attribute '$attr' with value '$value' available!\n";
185 }
cb93636b
FE
186}
187
72a9742b
DC
188sub get_scheme_and_port {
189 my ($class, $config) = @_;
190
191 my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap');
192
193 my $default_port = $scheme eq 'ldaps' ? 636 : 389;
194 my $port = $config->{port} // $default_port;
195
196 return ($scheme, $port);
197}
198
30aad017 199sub connect_and_bind {
fbb1fa44 200 my ($class, $config, $realm, $param) = @_;
d9e93d2e
DC
201
202 my $servers = [$config->{server1}];
203 push @$servers, $config->{server2} if $config->{server2};
5bb4e06a 204
72a9742b 205 my ($scheme, $port) = $class->get_scheme_and_port($config);
5bb4e06a 206
e03c2aef
WB
207 my %ldap_args;
208 if ($config->{verify}) {
209 $ldap_args{verify} = 'require';
d9e93d2e
DC
210 $ldap_args{clientcert} = $config->{cert} if $config->{cert};
211 $ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
e03c2aef
WB
212 if (defined(my $capath = $config->{capath})) {
213 if (-d $capath) {
214 $ldap_args{capath} = $capath;
215 } else {
216 $ldap_args{cafile} = $capath;
217 }
218 }
219 } else {
220 $ldap_args{verify} = 'none';
221 }
222
72a9742b 223 if ($scheme ne 'ldap') {
3b7eaef1 224 $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
07dd90d7
AD
225 }
226
d9e93d2e
DC
227 my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
228
d9e93d2e 229 if ($config->{bind_dn}) {
de8c5e6c 230 my $bind_dn = $config->{bind_dn};
fbb1fa44 231 my $bind_pass = $param->{password} || ldap_get_credentials($realm);
b5040b42 232 die "missing password for realm $realm\n" if !defined($bind_pass);
de8c5e6c
DC
233 PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass);
234 } elsif ($config->{cert} && $config->{certkey}) {
235 warn "skipping anonymous bind with clientcert\n";
236 } else {
237 PVE::LDAP::ldap_bind($ldap);
b5040b42
WB
238 }
239
30aad017
DC
240 if (!$config->{base_dn}) {
241 my $root = $ldap->root_dse(attrs => [ 'defaultNamingContext' ]);
242 $config->{base_dn} = $root->get_value('defaultNamingContext');
243 }
244
245 return $ldap;
246}
247
2c6e956e
DC
248# returns:
249# {
250# 'username@realm' => {
251# 'attr1' => 'value1',
252# 'attr2' => 'value2',
253# ...
254# },
255# ...
256# }
257#
258# or in list context:
259# (
260# {
261# 'username@realm' => {
262# 'attr1' => 'value1',
263# 'attr2' => 'value2',
264# ...
265# },
266# ...
267# },
268# {
269# 'uid=username,dc=....' => 'username@realm',
270# ...
271# }
272# )
273# the map of dn->username is needed for group membership sync
274sub get_users {
275 my ($class, $config, $realm) = @_;
276
277 my $ldap = $class->connect_and_bind($config, $realm);
278
279 my $user_name_attr = $config->{user_attr} // 'uid';
280 my $ldap_attribute_map = {
281 $user_name_attr => 'username',
282 enable => 'enable',
283 expire => 'expire',
284 firstname => 'firstname',
285 lastname => 'lastname',
286 email => 'email',
287 comment => 'comment',
288 keys => 'keys',
793039db 289 # NOTE: also ensure verify_sync_attribute_value can handle any new/changed attribute name
2c6e956e 290 };
7abb20a1 291 # build on the fly as this is small and only called once per realm in a ldap-sync anyway
8c3bf412 292 my $valid_sync_attributes = { map { $_ => 1 } values $ldap_attribute_map->%* };
2c6e956e
DC
293
294 foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) {
295 my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/);
cb93636b 296 if (!$valid_sync_attributes->{$ours}) {
7abb20a1 297 warn "skipping bad 'sync_attributes' entry – '$ours' is not a valid target attribute\n";
cb93636b
FE
298 next;
299 }
2c6e956e
DC
300 $ldap_attribute_map->{$ldap} = $ours;
301 }
302
303 my $filter = $config->{filter};
304 my $basedn = $config->{base_dn};
305
306 $config->{user_classes} //= 'inetorgperson, posixaccount, person, user';
307 my $classes = [PVE::Tools::split_list($config->{user_classes})];
308
309 my $users = PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes);
310
311 my $ret = {};
312 my $dnmap = {};
313
314 foreach my $user (@$users) {
315 my $user_attributes = $user->{attributes};
316 my $userid = $user_attributes->{$user_name_attr}->[0];
317 my $username = "$userid\@$realm";
318
319 # we cannot sync usernames that do not meet our criteria
320 eval { PVE::Auth::Plugin::verify_username($username) };
321 if (my $err = $@) {
322 warn "$err";
323 next;
324 }
325
326 $ret->{$username} = {};
327
328 foreach my $attr (keys %$user_attributes) {
329 if (my $ours = $ldap_attribute_map->{$attr}) {
cb93636b 330 my $value = $user_attributes->{$attr}->[0];
7abb20a1 331 eval { verify_sync_attribute_value($ours, $value) };
cb93636b
FE
332 if (my $err = $@) {
333 warn "skipping attribute mapping '$attr'->'$ours' for user '$username' - $err";
334 next;
335 }
336 $ret->{$username}->{$ours} = $value;
2c6e956e
DC
337 }
338 }
339
340 if (wantarray) {
341 my $dn = $user->{dn};
931e5bc1 342 $dnmap->{lc($dn)} = $username;
2c6e956e
DC
343 }
344 }
345
346 return wantarray ? ($ret, $dnmap) : $ret;
347}
348
349# needs a map for dn -> username, we get this from the get_users call
350# otherwise we cannot determine the group membership
351sub get_groups {
352 my ($class, $config, $realm, $dnmap) = @_;
353
354 my $filter = $config->{group_filter};
355 my $basedn = $config->{group_dn} // $config->{base_dn};
356 my $attr = $config->{group_name_attr};
357 $config->{group_classes} //= 'groupOfNames, group, univentionGroup, ipausergroup';
358 my $classes = [PVE::Tools::split_list($config->{group_classes})];
359
360 my $ldap = $class->connect_and_bind($config, $realm);
361
362 my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr);
363
364 my $ret = {};
365
366 foreach my $group (@$groups) {
367 my $name = $group->{name};
368 if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/){
369 $name = PVE::Tools::trim($1);
370 }
371 if ($name) {
372 $name .= "-$realm";
373
374 # we cannot sync groups that do not meet our criteria
375 eval { PVE::AccessControl::verify_groupname($name) };
376 if (my $err = $@) {
377 warn "$err";
378 next;
379 }
380
381 $ret->{$name} = { users => {} };
382 foreach my $member (@{$group->{members}}) {
931e5bc1 383 if (my $user = $dnmap->{lc($member)}) {
2c6e956e
DC
384 $ret->{$name}->{users}->{$user} = 1;
385 }
386 }
387 }
388 }
389
390 return $ret;
391}
392
30aad017
DC
393sub authenticate_user {
394 my ($class, $config, $realm, $username, $password) = @_;
395
396 my $ldap = $class->connect_and_bind($config, $realm);
397
d9e93d2e
DC
398 my $user_dn = PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn});
399 PVE::LDAP::auth_user_dn($ldap, $user_dn, $password);
5bb4e06a
DM
400
401 $ldap->unbind();
d9e93d2e 402 return 1;
5bb4e06a
DM
403}
404
782b702d
DC
405my $ldap_pw_dir = "/etc/pve/priv/realm";
406
407sub ldap_cred_file_name {
408 my ($realmid) = @_;
409 return "${ldap_pw_dir}/${realmid}.pw";
410}
411
412sub get_cred_file {
413 my ($realmid) = @_;
414
415 my $cred_file = ldap_cred_file_name($realmid);
416 if (-e $cred_file) {
417 return $cred_file;
418 } elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") {
419 # FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x
420 return "/etc/pve/priv/ldap/${realmid}.pw";
421 }
422
423 return $cred_file;
424}
425
426sub ldap_set_credentials {
427 my ($password, $realmid) = @_;
428
429 my $cred_file = ldap_cred_file_name($realmid);
430 mkdir $ldap_pw_dir;
431
432 PVE::Tools::file_set_contents($cred_file, $password);
433
434 return $cred_file;
435}
436
437sub ldap_get_credentials {
438 my ($realmid) = @_;
439
440 if (my $cred_file = get_cred_file($realmid)) {
441 return PVE::Tools::file_read_firstline($cred_file);
442 }
443 return undef;
444}
445
446sub ldap_delete_credentials {
447 my ($realmid) = @_;
448
449 if (my $cred_file = get_cred_file($realmid)) {
eeabad5a 450 return if ! -e $cred_file; # nothing to do
782b702d
DC
451 unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
452 }
453}
454
455sub on_add_hook {
456 my ($class, $realm, $config, %param) = @_;
457
458 if (defined($param{password})) {
459 ldap_set_credentials($param{password}, $realm);
460 } else {
461 ldap_delete_credentials($realm);
462 }
463}
464
465sub on_update_hook {
466 my ($class, $realm, $config, %param) = @_;
467
468 return if !exists($param{password});
469
470 if (defined($param{password})) {
471 ldap_set_credentials($param{password}, $realm);
472 } else {
473 ldap_delete_credentials($realm);
474 }
475}
476
477sub on_delete_hook {
478 my ($class, $realm, $config) = @_;
479
480 ldap_delete_credentials($realm);
481}
482
fbb1fa44
CH
483sub check_connection {
484 my ($class, $realm, $config, %param) = @_;
485
486 $class->connect_and_bind($config, $realm, \%param);
487}
488
5bb4e06a 4891;