pve-access-control/PVE/Auth/LDAP.pm
Dominik Csapak 782b702dbb auth ldap/ad: make password a parameter for the api
Allows us to add it in the gui, until now the admin needed to create
the file themself.

Mirrored after credential handling from CIFS and PBS in their
pve-storage plugins

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
[Thomas: don't differ from storage one unnecessarily, keep comments
 and behavior]
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2020-04-18 18:48:28 +02:00

430 lines
10 KiB
Perl
Executable File

package PVE::Auth::LDAP;
use strict;
use warnings;
use PVE::Auth::Plugin;
use PVE::JSONSchema;
use PVE::LDAP;
use PVE::Tools;
use base qw(PVE::Auth::Plugin);
sub type {
return 'ldap';
}
sub properties {
return {
base_dn => {
description => "LDAP base domain name",
type => 'string',
pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
optional => 1,
maxLength => 256,
},
user_attr => {
description => "LDAP user attribute name",
type => 'string',
pattern => '\S{2,}',
optional => 1,
maxLength => 256,
},
bind_dn => {
description => "LDAP bind domain name",
type => 'string',
pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
optional => 1,
maxLength => 256,
},
password => {
description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
type => 'string',
optional => 1,
},
verify => {
description => "Verify the server's SSL certificate",
type => 'boolean',
optional => 1,
default => 0,
},
capath => {
description => "Path to the CA certificate store",
type => 'string',
optional => 1,
default => '/etc/ssl/certs',
},
cert => {
description => "Path to the client certificate",
type => 'string',
optional => 1,
},
certkey => {
description => "Path to the client certificate key",
type => 'string',
optional => 1,
},
filter => {
description => "LDAP filter for user sync.",
type => 'string',
optional => 1,
maxLength => 2048,
},
sync_attributes => {
description => "Comma separated list of key=value pairs for specifying"
." which LDAP attributes map to which PVE user field. For example,"
." to map the LDAP attribute 'mail' to PVEs 'email', write "
." 'email=mail'. By default, each PVE user field is represented "
." by an LDAP attribute of the same name.",
optional => 1,
type => 'string',
pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
},
user_classes => {
description => "The objectclasses for users.",
type => 'string',
default => 'inetorgperson, posixaccount, person, user',
format => 'ldap-simple-attr-list',
optional => 1,
},
group_dn => {
description => "LDAP base domain name for group sync. If not set, the"
." base_dn will be used.",
type => 'string',
pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
optional => 1,
maxLength => 256,
},
group_name_attr => {
description => "LDAP attribute representing a groups name. If not set"
." or found, the first value of the DN will be used as name.",
type => 'string',
format => 'ldap-simple-attr',
optional => 1,
maxLength => 256,
},
group_filter => {
description => "LDAP filter for group sync.",
type => 'string',
optional => 1,
maxLength => 2048,
},
group_classes => {
description => "The objectclasses for groups.",
type => 'string',
default => 'groupOfNames, group, univentionGroup, ipausergroup',
format => 'ldap-simple-attr-list',
optional => 1,
},
'sync-defaults-options' => {
description => "The default options for behavior of synchronizations.",
type => 'string',
format => 'realm-sync-options',
optional => 1,
},
};
}
sub options {
return {
server1 => {},
server2 => { optional => 1 },
base_dn => {},
bind_dn => { optional => 1 },
password => { optional => 1 },
user_attr => {},
port => { optional => 1 },
secure => { optional => 1 },
sslversion => { optional => 1 },
default => { optional => 1 },
comment => { optional => 1 },
tfa => { optional => 1 },
verify => { optional => 1 },
capath => { optional => 1 },
cert => { optional => 1 },
certkey => { optional => 1 },
filter => { optional => 1 },
sync_attributes => { optional => 1 },
user_classes => { optional => 1 },
group_dn => { optional => 1 },
group_name_attr => { optional => 1 },
group_filter => { optional => 1 },
group_classes => { optional => 1 },
'sync-defaults-options' => { optional => 1 },
};
}
sub connect_and_bind {
my ($class, $config, $realm) = @_;
my $servers = [$config->{server1}];
push @$servers, $config->{server2} if $config->{server2};
my $default_port = $config->{secure} ? 636: 389;
my $port = $config->{port} // $default_port;
my $scheme = $config->{secure} ? 'ldaps' : 'ldap';
my %ldap_args;
if ($config->{verify}) {
$ldap_args{verify} = 'require';
$ldap_args{clientcert} = $config->{cert} if $config->{cert};
$ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
if (defined(my $capath = $config->{capath})) {
if (-d $capath) {
$ldap_args{capath} = $capath;
} else {
$ldap_args{cafile} = $capath;
}
}
} else {
$ldap_args{verify} = 'none';
}
if ($config->{secure}) {
$ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
}
my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
my $bind_dn;
my $bind_pass;
if ($config->{bind_dn}) {
$bind_dn = $config->{bind_dn};
$bind_pass = ldap_get_credentials($realm);
die "missing password for realm $realm\n" if !defined($bind_pass);
}
PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass);
if (!$config->{base_dn}) {
my $root = $ldap->root_dse(attrs => [ 'defaultNamingContext' ]);
$config->{base_dn} = $root->get_value('defaultNamingContext');
}
return $ldap;
}
# returns:
# {
# 'username@realm' => {
# 'attr1' => 'value1',
# 'attr2' => 'value2',
# ...
# },
# ...
# }
#
# or in list context:
# (
# {
# 'username@realm' => {
# 'attr1' => 'value1',
# 'attr2' => 'value2',
# ...
# },
# ...
# },
# {
# 'uid=username,dc=....' => 'username@realm',
# ...
# }
# )
# the map of dn->username is needed for group membership sync
sub get_users {
my ($class, $config, $realm) = @_;
my $ldap = $class->connect_and_bind($config, $realm);
my $user_name_attr = $config->{user_attr} // 'uid';
my $ldap_attribute_map = {
$user_name_attr => 'username',
enable => 'enable',
expire => 'expire',
firstname => 'firstname',
lastname => 'lastname',
email => 'email',
comment => 'comment',
keys => 'keys',
};
foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) {
my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/);
$ldap_attribute_map->{$ldap} = $ours;
}
my $filter = $config->{filter};
my $basedn = $config->{base_dn};
$config->{user_classes} //= 'inetorgperson, posixaccount, person, user';
my $classes = [PVE::Tools::split_list($config->{user_classes})];
my $users = PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes);
my $ret = {};
my $dnmap = {};
foreach my $user (@$users) {
my $user_attributes = $user->{attributes};
my $userid = $user_attributes->{$user_name_attr}->[0];
my $username = "$userid\@$realm";
# we cannot sync usernames that do not meet our criteria
eval { PVE::Auth::Plugin::verify_username($username) };
if (my $err = $@) {
warn "$err";
next;
}
$ret->{$username} = {};
foreach my $attr (keys %$user_attributes) {
if (my $ours = $ldap_attribute_map->{$attr}) {
$ret->{$username}->{$ours} = $user_attributes->{$attr}->[0];
}
}
if (wantarray) {
my $dn = $user->{dn};
$dnmap->{$dn} = $username;
}
}
return wantarray ? ($ret, $dnmap) : $ret;
}
# needs a map for dn -> username, we get this from the get_users call
# otherwise we cannot determine the group membership
sub get_groups {
my ($class, $config, $realm, $dnmap) = @_;
my $filter = $config->{group_filter};
my $basedn = $config->{group_dn} // $config->{base_dn};
my $attr = $config->{group_name_attr};
$config->{group_classes} //= 'groupOfNames, group, univentionGroup, ipausergroup';
my $classes = [PVE::Tools::split_list($config->{group_classes})];
my $ldap = $class->connect_and_bind($config, $realm);
my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr);
my $ret = {};
foreach my $group (@$groups) {
my $name = $group->{name};
if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/){
$name = PVE::Tools::trim($1);
}
if ($name) {
$name .= "-$realm";
# we cannot sync groups that do not meet our criteria
eval { PVE::AccessControl::verify_groupname($name) };
if (my $err = $@) {
warn "$err";
next;
}
$ret->{$name} = { users => {} };
foreach my $member (@{$group->{members}}) {
if (my $user = $dnmap->{$member}) {
$ret->{$name}->{users}->{$user} = 1;
}
}
}
}
return $ret;
}
sub authenticate_user {
my ($class, $config, $realm, $username, $password) = @_;
my $ldap = $class->connect_and_bind($config, $realm);
my $user_dn = PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn});
PVE::LDAP::auth_user_dn($ldap, $user_dn, $password);
$ldap->unbind();
return 1;
}
my $ldap_pw_dir = "/etc/pve/priv/realm";
sub ldap_cred_file_name {
my ($realmid) = @_;
return "${ldap_pw_dir}/${realmid}.pw";
}
sub get_cred_file {
my ($realmid) = @_;
my $cred_file = ldap_cred_file_name($realmid);
if (-e $cred_file) {
return $cred_file;
} elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") {
# FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x
return "/etc/pve/priv/ldap/${realmid}.pw";
}
return $cred_file;
}
sub ldap_set_credentials {
my ($password, $realmid) = @_;
my $cred_file = ldap_cred_file_name($realmid);
mkdir $ldap_pw_dir;
PVE::Tools::file_set_contents($cred_file, $password);
return $cred_file;
}
sub ldap_get_credentials {
my ($realmid) = @_;
if (my $cred_file = get_cred_file($realmid)) {
return PVE::Tools::file_read_firstline($cred_file);
}
return undef;
}
sub ldap_delete_credentials {
my ($realmid) = @_;
if (my $cred_file = get_cred_file($realmid)) {
unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
}
}
sub on_add_hook {
my ($class, $realm, $config, %param) = @_;
if (defined($param{password})) {
ldap_set_credentials($param{password}, $realm);
} else {
ldap_delete_credentials($realm);
}
}
sub on_update_hook {
my ($class, $realm, $config, %param) = @_;
return if !exists($param{password});
if (defined($param{password})) {
ldap_set_credentials($param{password}, $realm);
} else {
ldap_delete_credentials($realm);
}
}
sub on_delete_hook {
my ($class, $realm, $config) = @_;
ldap_delete_credentials($realm);
}
1;