]> git.proxmox.com Git - pve-access-control.git/blame - src/PVE/Auth/LDAP.pm
ldap: Allow quoted values for DN attribute values
[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
1aa2355a
CH
13our $dn_regex = qr!\w+=("[\w ,+/<>;=]+"|[^ ,+"/<>;=]+)(,\s*\w+=("[\w ,+/<>;=]+"|[^ ,+"/<>;=]+))*!;
14
5bb4e06a
DM
15sub type {
16 return 'ldap';
17}
18
19sub properties {
20 return {
21 base_dn => {
22 description => "LDAP base domain name",
23 type => 'string',
1aa2355a 24 pattern => $dn_regex,
5bb4e06a
DM
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 },
b5040b42
WB
35 bind_dn => {
36 description => "LDAP bind domain name",
37 type => 'string',
1aa2355a 38 pattern => $dn_regex,
b5040b42
WB
39 optional => 1,
40 maxLength => 256,
41 },
782b702d
DC
42 password => {
43 description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
44 type => 'string',
45 optional => 1,
46 },
e03c2aef
WB
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 },
eba326d2
DC
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',
1aa2355a 96 pattern => $dn_regex,
eba326d2
DC
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 },
d29d2d4a
TL
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 },
72a9742b
DC
127 mode => {
128 description => "LDAP protocol mode.",
129 type => 'string',
130 enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
131 optional => 1,
132 default => 'ldap',
133 },
eb41d200
WL
134 'case-sensitive' => {
135 description => "username is case-sensitive",
136 type => 'boolean',
137 optional => 1,
138 default => 1,
139 }
5bb4e06a
DM
140 };
141}
142
143sub options {
144 return {
145 server1 => {},
146 server2 => { optional => 1 },
147 base_dn => {},
b5040b42 148 bind_dn => { optional => 1 },
782b702d 149 password => { optional => 1 },
5bb4e06a
DM
150 user_attr => {},
151 port => { optional => 1 },
152 secure => { optional => 1 },
07dd90d7 153 sslversion => { optional => 1 },
5bb4e06a
DM
154 default => { optional => 1 },
155 comment => { optional => 1 },
96f8ebd6 156 tfa => { optional => 1 },
e03c2aef
WB
157 verify => { optional => 1 },
158 capath => { optional => 1 },
159 cert => { optional => 1 },
160 certkey => { optional => 1 },
eba326d2
DC
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 },
d29d2d4a 168 'sync-defaults-options' => { optional => 1 },
72a9742b 169 mode => { optional => 1 },
eb41d200 170 'case-sensitive' => { optional => 1 },
5bb4e06a
DM
171 };
172}
173
72a9742b
DC
174sub 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
30aad017
DC
185sub connect_and_bind {
186 my ($class, $config, $realm) = @_;
d9e93d2e
DC
187
188 my $servers = [$config->{server1}];
189 push @$servers, $config->{server2} if $config->{server2};
5bb4e06a 190
72a9742b 191 my ($scheme, $port) = $class->get_scheme_and_port($config);
5bb4e06a 192
e03c2aef
WB
193 my %ldap_args;
194 if ($config->{verify}) {
195 $ldap_args{verify} = 'require';
d9e93d2e
DC
196 $ldap_args{clientcert} = $config->{cert} if $config->{cert};
197 $ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
e03c2aef
WB
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
72a9742b 209 if ($scheme ne 'ldap') {
3b7eaef1 210 $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
07dd90d7
AD
211 }
212
d9e93d2e
DC
213 my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
214
d9e93d2e 215 if ($config->{bind_dn}) {
de8c5e6c
DC
216 my $bind_dn = $config->{bind_dn};
217 my $bind_pass = ldap_get_credentials($realm);
b5040b42 218 die "missing password for realm $realm\n" if !defined($bind_pass);
de8c5e6c
DC
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);
b5040b42
WB
224 }
225
30aad017
DC
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
2c6e956e
DC
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
260sub 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};
931e5bc1 315 $dnmap->{lc($dn)} = $username;
2c6e956e
DC
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
324sub 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}}) {
931e5bc1 356 if (my $user = $dnmap->{lc($member)}) {
2c6e956e
DC
357 $ret->{$name}->{users}->{$user} = 1;
358 }
359 }
360 }
361 }
362
363 return $ret;
364}
365
30aad017
DC
366sub authenticate_user {
367 my ($class, $config, $realm, $username, $password) = @_;
368
369 my $ldap = $class->connect_and_bind($config, $realm);
370
d9e93d2e
DC
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);
5bb4e06a
DM
373
374 $ldap->unbind();
d9e93d2e 375 return 1;
5bb4e06a
DM
376}
377
782b702d
DC
378my $ldap_pw_dir = "/etc/pve/priv/realm";
379
380sub ldap_cred_file_name {
381 my ($realmid) = @_;
382 return "${ldap_pw_dir}/${realmid}.pw";
383}
384
385sub 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
399sub 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
410sub 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
419sub ldap_delete_credentials {
420 my ($realmid) = @_;
421
422 if (my $cred_file = get_cred_file($realmid)) {
eeabad5a 423 return if ! -e $cred_file; # nothing to do
782b702d
DC
424 unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
425 }
426}
427
428sub 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
438sub 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
450sub on_delete_hook {
451 my ($class, $realm, $config) = @_;
452
453 ldap_delete_credentials($realm);
454}
455
5bb4e06a 4561;