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