]> git.proxmox.com Git - pve-access-control.git/blob - src/PVE/Auth/LDAP.pm
1b63954cdf91dce24bb27ca384fa02ea65391cfa
[pve-access-control.git] / src / PVE / Auth / LDAP.pm
1 package PVE::Auth::LDAP;
2
3 use strict;
4 use warnings;
5
6 use PVE::Auth::Plugin;
7 use PVE::JSONSchema;
8 use PVE::LDAP;
9 use PVE::Tools;
10
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 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 },
32 bind_dn => {
33 description => "LDAP bind domain name",
34 type => 'string',
35 optional => 1,
36 maxLength => 256,
37 },
38 password => {
39 description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
40 type => 'string',
41 optional => 1,
42 },
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 },
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',
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 },
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 },
122 mode => {
123 description => "LDAP protocol mode.",
124 type => 'string',
125 enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
126 optional => 1,
127 default => 'ldap',
128 },
129 'case-sensitive' => {
130 description => "username is case-sensitive",
131 type => 'boolean',
132 optional => 1,
133 default => 1,
134 },
135 };
136 }
137
138 sub options {
139 return {
140 server1 => {},
141 server2 => { optional => 1 },
142 base_dn => {},
143 bind_dn => { optional => 1 },
144 password => { optional => 1 },
145 user_attr => {},
146 port => { optional => 1 },
147 secure => { optional => 1 },
148 sslversion => { optional => 1 },
149 default => { optional => 1 },
150 comment => { optional => 1 },
151 tfa => { optional => 1 },
152 verify => { optional => 1 },
153 capath => { optional => 1 },
154 cert => { optional => 1 },
155 certkey => { optional => 1 },
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 },
163 'sync-defaults-options' => { optional => 1 },
164 mode => { optional => 1 },
165 'case-sensitive' => { optional => 1 },
166 };
167 }
168
169 my $valid_sync_attributes = {
170 username => 1,
171 enable => 1,
172 expire => 1,
173 firstname => 1,
174 lastname => 1,
175 email => 1,
176 comment => 1,
177 keys => 1,
178 };
179
180 my sub verify_sync_attribute {
181 my ($attr, $value) = @_;
182
183 die "cannot map to invalid user sync attribute '$attr'\n" if !$valid_sync_attributes->{$attr};
184
185 # The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username
186 if ($attr eq 'username') {
187 die "value '$value' does not look like a valid user name\n"
188 if $value !~ m/${PVE::Auth::Plugin::user_regex}/;
189 return;
190 }
191
192 return if $attr eq 'enable'; # for backwards compat, don't parse/validate
193
194 my $schema = PVE::JSONSchema::get_standard_option("user-$attr");
195 PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n");
196 }
197
198 sub get_scheme_and_port {
199 my ($class, $config) = @_;
200
201 my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap');
202
203 my $default_port = $scheme eq 'ldaps' ? 636 : 389;
204 my $port = $config->{port} // $default_port;
205
206 return ($scheme, $port);
207 }
208
209 sub connect_and_bind {
210 my ($class, $config, $realm, $param) = @_;
211
212 my $servers = [$config->{server1}];
213 push @$servers, $config->{server2} if $config->{server2};
214
215 my ($scheme, $port) = $class->get_scheme_and_port($config);
216
217 my %ldap_args;
218 if ($config->{verify}) {
219 $ldap_args{verify} = 'require';
220 $ldap_args{clientcert} = $config->{cert} if $config->{cert};
221 $ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
222 if (defined(my $capath = $config->{capath})) {
223 if (-d $capath) {
224 $ldap_args{capath} = $capath;
225 } else {
226 $ldap_args{cafile} = $capath;
227 }
228 }
229 } else {
230 $ldap_args{verify} = 'none';
231 }
232
233 if ($scheme ne 'ldap') {
234 $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
235 }
236
237 my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
238
239 if ($config->{bind_dn}) {
240 my $bind_dn = $config->{bind_dn};
241 my $bind_pass = $param->{password} || ldap_get_credentials($realm);
242 die "missing password for realm $realm\n" if !defined($bind_pass);
243 PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass);
244 } elsif ($config->{cert} && $config->{certkey}) {
245 warn "skipping anonymous bind with clientcert\n";
246 } else {
247 PVE::LDAP::ldap_bind($ldap);
248 }
249
250 if (!$config->{base_dn}) {
251 my $root = $ldap->root_dse(attrs => [ 'defaultNamingContext' ]);
252 $config->{base_dn} = $root->get_value('defaultNamingContext');
253 }
254
255 return $ldap;
256 }
257
258 # returns:
259 # {
260 # 'username@realm' => {
261 # 'attr1' => 'value1',
262 # 'attr2' => 'value2',
263 # ...
264 # },
265 # ...
266 # }
267 #
268 # or in list context:
269 # (
270 # {
271 # 'username@realm' => {
272 # 'attr1' => 'value1',
273 # 'attr2' => 'value2',
274 # ...
275 # },
276 # ...
277 # },
278 # {
279 # 'uid=username,dc=....' => 'username@realm',
280 # ...
281 # }
282 # )
283 # the map of dn->username is needed for group membership sync
284 sub get_users {
285 my ($class, $config, $realm) = @_;
286
287 my $ldap = $class->connect_and_bind($config, $realm);
288
289 my $user_name_attr = $config->{user_attr} // 'uid';
290 my $ldap_attribute_map = {
291 $user_name_attr => 'username',
292 enable => 'enable',
293 expire => 'expire',
294 firstname => 'firstname',
295 lastname => 'lastname',
296 email => 'email',
297 comment => 'comment',
298 keys => 'keys',
299 };
300
301 foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) {
302 my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/);
303 if (!$valid_sync_attributes->{$ours}) {
304 warn "bad 'sync_attributes': cannot map to invalid attribute '$ours'\n";
305 next;
306 }
307 $ldap_attribute_map->{$ldap} = $ours;
308 }
309
310 my $filter = $config->{filter};
311 my $basedn = $config->{base_dn};
312
313 $config->{user_classes} //= 'inetorgperson, posixaccount, person, user';
314 my $classes = [PVE::Tools::split_list($config->{user_classes})];
315
316 my $users = PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes);
317
318 my $ret = {};
319 my $dnmap = {};
320
321 foreach my $user (@$users) {
322 my $user_attributes = $user->{attributes};
323 my $userid = $user_attributes->{$user_name_attr}->[0];
324 my $username = "$userid\@$realm";
325
326 # we cannot sync usernames that do not meet our criteria
327 eval { PVE::Auth::Plugin::verify_username($username) };
328 if (my $err = $@) {
329 warn "$err";
330 next;
331 }
332
333 $ret->{$username} = {};
334
335 foreach my $attr (keys %$user_attributes) {
336 if (my $ours = $ldap_attribute_map->{$attr}) {
337 my $value = $user_attributes->{$attr}->[0];
338 eval { verify_sync_attribute($ours, $value) };
339 if (my $err = $@) {
340 warn "skipping attribute mapping '$attr'->'$ours' for user '$username' - $err";
341 next;
342 }
343 $ret->{$username}->{$ours} = $value;
344 }
345 }
346
347 if (wantarray) {
348 my $dn = $user->{dn};
349 $dnmap->{lc($dn)} = $username;
350 }
351 }
352
353 return wantarray ? ($ret, $dnmap) : $ret;
354 }
355
356 # needs a map for dn -> username, we get this from the get_users call
357 # otherwise we cannot determine the group membership
358 sub get_groups {
359 my ($class, $config, $realm, $dnmap) = @_;
360
361 my $filter = $config->{group_filter};
362 my $basedn = $config->{group_dn} // $config->{base_dn};
363 my $attr = $config->{group_name_attr};
364 $config->{group_classes} //= 'groupOfNames, group, univentionGroup, ipausergroup';
365 my $classes = [PVE::Tools::split_list($config->{group_classes})];
366
367 my $ldap = $class->connect_and_bind($config, $realm);
368
369 my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr);
370
371 my $ret = {};
372
373 foreach my $group (@$groups) {
374 my $name = $group->{name};
375 if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/){
376 $name = PVE::Tools::trim($1);
377 }
378 if ($name) {
379 $name .= "-$realm";
380
381 # we cannot sync groups that do not meet our criteria
382 eval { PVE::AccessControl::verify_groupname($name) };
383 if (my $err = $@) {
384 warn "$err";
385 next;
386 }
387
388 $ret->{$name} = { users => {} };
389 foreach my $member (@{$group->{members}}) {
390 if (my $user = $dnmap->{lc($member)}) {
391 $ret->{$name}->{users}->{$user} = 1;
392 }
393 }
394 }
395 }
396
397 return $ret;
398 }
399
400 sub authenticate_user {
401 my ($class, $config, $realm, $username, $password) = @_;
402
403 my $ldap = $class->connect_and_bind($config, $realm);
404
405 my $user_dn = PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn});
406 PVE::LDAP::auth_user_dn($ldap, $user_dn, $password);
407
408 $ldap->unbind();
409 return 1;
410 }
411
412 my $ldap_pw_dir = "/etc/pve/priv/realm";
413
414 sub ldap_cred_file_name {
415 my ($realmid) = @_;
416 return "${ldap_pw_dir}/${realmid}.pw";
417 }
418
419 sub get_cred_file {
420 my ($realmid) = @_;
421
422 my $cred_file = ldap_cred_file_name($realmid);
423 if (-e $cred_file) {
424 return $cred_file;
425 } elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") {
426 # FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x
427 return "/etc/pve/priv/ldap/${realmid}.pw";
428 }
429
430 return $cred_file;
431 }
432
433 sub ldap_set_credentials {
434 my ($password, $realmid) = @_;
435
436 my $cred_file = ldap_cred_file_name($realmid);
437 mkdir $ldap_pw_dir;
438
439 PVE::Tools::file_set_contents($cred_file, $password);
440
441 return $cred_file;
442 }
443
444 sub ldap_get_credentials {
445 my ($realmid) = @_;
446
447 if (my $cred_file = get_cred_file($realmid)) {
448 return PVE::Tools::file_read_firstline($cred_file);
449 }
450 return undef;
451 }
452
453 sub ldap_delete_credentials {
454 my ($realmid) = @_;
455
456 if (my $cred_file = get_cred_file($realmid)) {
457 return if ! -e $cred_file; # nothing to do
458 unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
459 }
460 }
461
462 sub on_add_hook {
463 my ($class, $realm, $config, %param) = @_;
464
465 if (defined($param{password})) {
466 ldap_set_credentials($param{password}, $realm);
467 } else {
468 ldap_delete_credentials($realm);
469 }
470 }
471
472 sub on_update_hook {
473 my ($class, $realm, $config, %param) = @_;
474
475 return if !exists($param{password});
476
477 if (defined($param{password})) {
478 ldap_set_credentials($param{password}, $realm);
479 } else {
480 ldap_delete_credentials($realm);
481 }
482 }
483
484 sub on_delete_hook {
485 my ($class, $realm, $config) = @_;
486
487 ldap_delete_credentials($realm);
488 }
489
490 sub check_connection {
491 my ($class, $realm, $config, %param) = @_;
492
493 $class->connect_and_bind($config, $realm, \%param);
494 }
495
496 1;