package PVE::Auth::LDAP;

use strict;
use warnings;

use PVE::Tools;
use PVE::Auth::Plugin;
use PVE::LDAP;
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,
	},
	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,
	},
    };
}

sub options {
    return {
	server1 => {},
	server2 => { optional => 1 },
	base_dn => {},
	bind_dn => { 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 },
    };
}

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 = PVE::Tools::file_read_firstline("/etc/pve/priv/ldap/${realm}.pw");
	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;
}

1;
