pve-access-control/PVE/Auth/LDAP.pm
Dominik Csapak 72a9742b94 auth ldap/ad: introduce connection 'mode'
instead of having only a 'secure' flag which switches between
ldap/ldaps we now have a mode which also contains 'ldap+starttls'

our connection code in PVE::LDAP can handle this already (used in pmg)
so that is no problem

if we want to really remove the 'secure' flag, e.g. in 7.0
we'd either have to rewrite the config or have it as an error
in a pve6to7 script

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
2020-04-25 18:31:35 +02:00

447 lines
11 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,
},
mode => {
description => "LDAP protocol mode.",
type => 'string',
enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
optional => 1,
default => 'ldap',
},
};
}
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 },
mode => { optional => 1 },
};
}
sub get_scheme_and_port {
my ($class, $config) = @_;
my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap');
my $default_port = $scheme eq 'ldaps' ? 636 : 389;
my $port = $config->{port} // $default_port;
return ($scheme, $port);
}
sub connect_and_bind {
my ($class, $config, $realm) = @_;
my $servers = [$config->{server1}];
push @$servers, $config->{server2} if $config->{server2};
my ($scheme, $port) = $class->get_scheme_and_port($config);
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 ($scheme ne 'ldap') {
$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;