]> git.proxmox.com Git - pve-access-control.git/blob - src/PVE/Auth/LDAP.pm
bump version to 8.1.3
[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 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 },
33 bind_dn => {
34 description => "LDAP bind domain name",
35 type => 'string',
36 pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
37 optional => 1,
38 maxLength => 256,
39 },
40 password => {
41 description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
42 type => 'string',
43 optional => 1,
44 },
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 },
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 },
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 },
125 mode => {
126 description => "LDAP protocol mode.",
127 type => 'string',
128 enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
129 optional => 1,
130 default => 'ldap',
131 },
132 'case-sensitive' => {
133 description => "username is case-sensitive",
134 type => 'boolean',
135 optional => 1,
136 default => 1,
137 }
138 };
139 }
140
141 sub options {
142 return {
143 server1 => {},
144 server2 => { optional => 1 },
145 base_dn => {},
146 bind_dn => { optional => 1 },
147 password => { optional => 1 },
148 user_attr => {},
149 port => { optional => 1 },
150 secure => { optional => 1 },
151 sslversion => { optional => 1 },
152 default => { optional => 1 },
153 comment => { optional => 1 },
154 tfa => { optional => 1 },
155 verify => { optional => 1 },
156 capath => { optional => 1 },
157 cert => { optional => 1 },
158 certkey => { optional => 1 },
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 },
166 'sync-defaults-options' => { optional => 1 },
167 mode => { optional => 1 },
168 'case-sensitive' => { optional => 1 },
169 };
170 }
171
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
183 sub connect_and_bind {
184 my ($class, $config, $realm) = @_;
185
186 my $servers = [$config->{server1}];
187 push @$servers, $config->{server2} if $config->{server2};
188
189 my ($scheme, $port) = $class->get_scheme_and_port($config);
190
191 my %ldap_args;
192 if ($config->{verify}) {
193 $ldap_args{verify} = 'require';
194 $ldap_args{clientcert} = $config->{cert} if $config->{cert};
195 $ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
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
207 if ($scheme ne 'ldap') {
208 $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
209 }
210
211 my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
212
213 if ($config->{bind_dn}) {
214 my $bind_dn = $config->{bind_dn};
215 my $bind_pass = ldap_get_credentials($realm);
216 die "missing password for realm $realm\n" if !defined($bind_pass);
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);
222 }
223
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
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->{lc($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->{lc($member)}) {
355 $ret->{$name}->{users}->{$user} = 1;
356 }
357 }
358 }
359 }
360
361 return $ret;
362 }
363
364 sub authenticate_user {
365 my ($class, $config, $realm, $username, $password) = @_;
366
367 my $ldap = $class->connect_and_bind($config, $realm);
368
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);
371
372 $ldap->unbind();
373 return 1;
374 }
375
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)) {
421 return if ! -e $cred_file; # nothing to do
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
454 1;