]>
Commit | Line | Data |
---|---|---|
5bb4e06a DM |
1 | package PVE::Auth::LDAP; |
2 | ||
3 | use strict; | |
7c410d63 DM |
4 | use warnings; |
5 | ||
5bb4e06a | 6 | use PVE::Auth::Plugin; |
d29d2d4a | 7 | use PVE::JSONSchema; |
d9e93d2e | 8 | use PVE::LDAP; |
d29d2d4a TL |
9 | use PVE::Tools; |
10 | ||
5bb4e06a DM |
11 | use base qw(PVE::Auth::Plugin); |
12 | ||
13 | sub type { | |
14 | return 'ldap'; | |
15 | } | |
16 | ||
17 | sub 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 | ||
138 | sub 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 | 169 | my 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 |
188 | sub 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 | 199 | sub 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 | |
274 | sub 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 | |
351 | sub 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 |
393 | sub 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 |
405 | my $ldap_pw_dir = "/etc/pve/priv/realm"; |
406 | ||
407 | sub ldap_cred_file_name { | |
408 | my ($realmid) = @_; | |
409 | return "${ldap_pw_dir}/${realmid}.pw"; | |
410 | } | |
411 | ||
412 | sub 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 | ||
426 | sub 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 | ||
437 | sub 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 | ||
446 | sub 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 | ||
455 | sub 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 | ||
465 | sub 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 | ||
477 | sub on_delete_hook { | |
478 | my ($class, $realm, $config) = @_; | |
479 | ||
480 | ldap_delete_credentials($realm); | |
481 | } | |
482 | ||
fbb1fa44 CH |
483 | sub check_connection { |
484 | my ($class, $realm, $config, %param) = @_; | |
485 | ||
486 | $class->connect_and_bind($config, $realm, \%param); | |
487 | } | |
488 | ||
5bb4e06a | 489 | 1; |