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