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