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