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