# Copyright 2003-2006, Fred Steinberg, Brown Bear Software

# User authentication via LDAP

package User::LDAPAuth;

use strict;
use Net::LDAP;
use Calendar::LDAPPrefs;

our $lastError;

sub exists {
    my ($class, $username) = @_;
    if ($class->_find_user ($username)) {
        return 1;
    }
    else {
        return undef;
    }
}

# true if ok, false if bad, undef if user not found ?
sub checkPassword {
    my $classOrObject = shift;
    my ($username, $password);
    if (ref $classOrObject) {
        $password = shift;
        $username = $classOrObject->name;
    } else {
        ($username, $password) = @_;
    }

    my $ldap = $classOrObject->_connectToServer;
    return unless $ldap;

    my $prefs = MasterDB->new->getPreferences;

    # OK, try binding
    my $ok = $classOrObject->_bind_user ($prefs, $ldap, $username, $password);
    return $ok;                 # 1 if ok, undef if not
}

# Return list of (all) user names
sub getUserNames {
    my $classname = shift;
    my $ldap = $classname->_connectToServer;
    return unless $ldap;
    my $prefs = MasterDB->new->getPreferences;
    my $baseDN     = $prefs->LDAPBase;
    my $userFilter = $prefs->LDAPUserFilter;
    my $userAttrib = $prefs->LDAPUserAttrib;

    # bind - if required - before search
    if (!$classname->_bind_for_search ($prefs, $ldap)) {
        return;
    }

    require Net::LDAP::Control::Paged;
    use Net::LDAP::Constant 'LDAP_CONTROL_PAGED';
    my $page = Net::LDAP::Control::Paged->new (size => 500);
    my $cookie;

    my %search_params = (base    => $baseDN,
                         control => $page,
                         filter  => $userFilter);
    my @names;
    my $users;

    while (1) {
        $users = $ldap->search (%search_params);

        last if $users->code;   # don't search again if failed

        push @names, map {lc ($_->get_value ($userAttrib))} $users->entries;

        my ($resp) = $users->control (LDAP_CONTROL_PAGED) or last;
        $cookie    = $resp->cookie                        or last;
        $page->cookie ($cookie);
    }

    # If abnormal exit, tell server we're done
    if ($cookie) {
        $page->cookie ($cookie);
        $page->size (0);
        $ldap->search (%search_params);
    }

    if (my $code = $users->code) {
        my $mess = $users->error;
        $lastError = "Enumeration failed; code $code, '$mess'";
        return;
    }

    return @names;
#    return map {lc ($_->get_value ($userAttrib))} $users->entries;
}

sub get_group_ids_for_user {
    my ($class, $username) = @_;
    my @group_names;

    my $prefs = MasterDB->new->getPreferences;
    if (my $user_membership_attrib = $prefs->LDAPUserMembershipAttrib) {
        my $user_entry = $class->_find_user ($username);
        if (!$user_entry) {
            $lastError = "Couldn't find user $username";
            if (ref $class) {
                $class->{errorMessage} = $lastError;
            }
            return;
        }
        my @full_dn_names = $user_entry->get_value ($user_membership_attrib);
        my $attrib = $prefs->LDAPGroupAttrib || '';
        # Name has full DN; let's just get the attrib we want
        foreach my $dn (@full_dn_names) {
            if ($dn =~ /$attrib\s*=\s*([^,]+)/i) {
                push @group_names, $1;
            }
        }
    }
    else {
        # If we can't ask the user what group they're in, need to
        # check each group Get LDAP group membership
        require Calendar::LDAPGroup;
        foreach (LDAPGroup->getGroupNames) {
            my @members = LDAPGroup->getGroupMembers ($_);
            push @group_names, $_ if (grep (/$username/i, @members));
        }
    }

    return @group_names;
#   return map {"LDAP $_"} @group_names; # convert names to calcium "ids"
}


# If User/Pass specified, means anonymous searches not allowed, so
# bind for search (e.g. Win2003 Active Directory)
# If no username provided, don't do anything.
sub _bind_for_search {
    my ($class, $prefs, $ldap) = @_;
    my $ldapUser = $prefs->LDAPUsername;
    if (!$ldapUser) {
        return 1;
    }
    my $ldapPass = $prefs->LDAPPassword;
    my $result = $ldap->bind ($ldapUser, password => $ldapPass);
    if (my $code = $result->code) {
        $lastError = 'bind for search: ' . $result->error;
        if (ref $class) {
            $class->{errorMessage} = $result->error;
            $class->{errorCode}    = $result->code;
        }
        return undef;
    }
    return 1;
}

# Pass username; return Net::LDAP::Entry, or undef if not found
sub _find_user {
    my ($class, $username) = @_;

    my $prefs = MasterDB->new->getPreferences;
    my $baseDN     = $prefs->LDAPBase;
    my $userAttrib = $prefs->LDAPUserAttrib;
    my $userFilter = $prefs->LDAPUserFilter;

    my $ldap = $class->_connectToServer;
    return unless $ldap;

    # bind, if required, before search
    if (!$class->_bind_for_search ($prefs, $ldap)) {
        return;
    }

    # Find the user
    my $users = $ldap->search (base   => $baseDN,
                               filter =>
                                    "(&($userFilter)($userAttrib=$username))");
    if (my $code = $users->code) {
        my $mess = $users->error;
        $lastError = "Search for user failed; code $code, '$mess'";
        if (ref $class) {
            $class->{errorMessage} = $mess;
            $class->{errorCode}    = $code;
        }
        return;
    }

    my @users = $users->entries;
    my $user_entry = $users[0];
    return $user_entry;
}

sub _bind_user {
    my ($self, $prefs, $ldap, $username, $password) = @_;

    my $baseDN     = $prefs->LDAPBase;
    my $userAttrib = $prefs->LDAPUserAttrib;
    my $userFilter = $prefs->LDAPUserFilter;

    # bind, if required, before search
    if (!$self->_bind_for_search ($prefs, $ldap)) {
        return;
    }

    my $user_entry = $self->_find_user ($username);

    if (!$user_entry) {
        $lastError = "Couldn't find user $username";
        if (ref $self) {
            $self->{errorMessage} = $lastError;
        }
        return;
    }

    my $userDN = $user_entry->dn;

    my $result = $ldap->bind ($userDN, password => $password);
    if (my $code = $result->code) {
        $lastError = $result->error;
        if (ref $self) {
            $self->{errorMessage} = $result->error;
            $self->{errorCode}    = $result->code;
        }
        return;
    }

    return 1;
}

sub _connectToServer {
    my $me = shift;
    my $prefs = MasterDB->new->getPreferences;
    my $host       = $prefs->LDAPHost    || 'localhost';
    my $port       = $prefs->LDAPPort    || 389;
    my $version    = $prefs->LDAPVersion || 3;

    # untaint
    $host = $host =~ /(.*)/ ? $1 : 'localhost';

    my $ldap;

    # Might be list of servers; check until we find one that's alive
    my @hosts = split /\s+/, $host;
    foreach my $this_host (@hosts) {
        next unless defined $this_host;
        $ldap = Net::LDAP->new ($this_host,
                                port    => $port,
                                version => $version);
        if ($ldap and !$@) {
            return $ldap;
        }
        if ($@) {
            my $port = $port != 389 ? ":$port" : '';
            $lastError = "LDAP init to '$host$port' failed: $@";
            undef $ldap;
            next;
        }
    }
    return $ldap;
}

sub canSetPassword {
    return undef;
}

1;
