<?php
/**
 * Copyright 1999-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you did
 * not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @author   Jon Parise <jon@horde.org>
 * @category Horde
 * @license  http://www.horde.org/licenses/lgpl21 LGPL-2.1
 * @package  Auth
 */

/**
 * The Horde_Auth_Ldap class provides an LDAP implementation of the Horde
 * authentication system.
 *
 * 'preauthenticate' hook should return LDAP connection information in the
 * 'ldap' credentials key.
 *
 * @author    Jon Parise <jon@horde.org>
 * @category  Horde
 * @copyright 1999-2017 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL-2.1
 * @package   Auth
 */
class Horde_Auth_Ldap extends Horde_Auth_Base
{
    /**
     * An array of capabilities, so that the driver can report which
     * operations it supports and which it doesn't.
     *
     * @var array
     */
    protected $_capabilities = array(
        'add' => true,
        'update' => true,
        'resetpassword' => true,
        'remove' => true,
        'list' => true,
        'authenticate' => true,
    );

    /**
     * LDAP object
     *
     * @var Horde_Ldap
     */
    protected $_ldap;

    /**
     * Constructor.
     *
     * @param array $params  Required parameters:
     * <pre>
     * 'basedn' - (string) [REQUIRED] The base DN for the LDAP server.
     * 'filter' - (string) The LDAP formatted search filter to search for
     *            users. This setting overrides the 'objectclass' parameter.
     * 'ldap' - (Horde_Ldap) [REQUIRED] Horde LDAP object.
     * 'objectclass - (string|array): The objectclass filter used to search
     *                for users. Either a single or an array of objectclasses.
     * 'uid' - (string) [REQUIRED] The username search key.
     * </pre>
     *
     * @throws Horde_Auth_Exception
     * @throws InvalidArgumentException
     */
    public function __construct(array $params = array())
    {
        foreach (array('basedn', 'ldap', 'uid') as $val) {
            if (!isset($params[$val])) {
                throw new InvalidArgumentException(__CLASS__ . ': Missing ' . $val . ' parameter.');
            }
        }

        if (!empty($params['ad'])) {
            $this->_capabilities['resetpassword'] = false;
        }

        $this->_ldap = $params['ldap'];
        unset($params['ldap']);

        parent::__construct($params);
    }

    /**
     * Checks for shadowLastChange and shadowMin/Max support and returns their
     * values.  We will also check for pwdLastSet if Active Directory is
     * support is requested.  For this check to succeed we need to be bound
     * to the directory.
     *
     * @param string $dn  The dn of the user.
     *
     * @return array  Array with keys being "shadowlastchange", "shadowmin"
     *                "shadowmax", "shadowwarning" and containing their
     *                respective values or false for no support.
     */
    protected function _lookupShadow($dn)
    {
        /* Init the return array. */
        $lookupshadow = array(
            'shadowlastchange' => false,
            'shadowmin' => false,
            'shadowmax' => false,
            'shadowwarning' => false
        );

        /* According to LDAP standard, to read operational attributes, you
         * must request them explicitly. Attributes involved in password
         * expiration policy:
         *    pwdlastset: Active Directory
         *    shadow*: shadowUser schema
         *    passwordexpirationtime: Sun and Fedora Directory Server */
        try {
            $result = $this->_ldap->search(null, '(objectClass=*)', array(
                'attributes' => array(
                    'pwdlastset',
                    'shadowmax',
                    'shadowmin',
                    'shadowlastchange',
                    'shadowwarning',
                    'passwordexpirationtime'
                ),
                'scope' => 'base'
            ));
        } catch (Horde_Ldap_Exception $e) {
            return $lookupshadow;
        }

        if (!$result) {
            return $lookupshadow;
        }

        $info = reset($result);

        // TODO: 'ad'?
        if (!empty($this->_params['ad'])) {
            if (isset($info['pwdlastset'][0])) {
                /* Active Directory handles timestamps a bit differently.
                 * Convert the timestamp to a UNIX timestamp. */
                $lookupshadow['shadowlastchange'] = floor((($info['pwdlastset'][0] / 10000000) - 11644406783) / 86400) - 1;

                /* Password expiry attributes are in a policy. We cannot
                 * read them so use the Horde config. */
                $lookupshadow['shadowwarning'] = $this->_params['warnage'];
                $lookupshadow['shadowmin'] = $this->_params['minage'];
                $lookupshadow['shadowmax'] = $this->_params['maxage'];
            }
        } elseif (isset($info['passwordexpirationtime'][0])) {
            /* Sun/Fedora Directory Server uses a special attribute
             * passwordexpirationtime.  It has precedence over shadow*
             * because it actually locks the expired password at the LDAP
             * server level.  The correct way to check expiration should
             * be using LDAP controls, unfortunately PHP doesn't support
             * controls on bind() responses. */
            $ldaptimepattern = "/([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})Z/";
            if (preg_match($ldaptimepattern, $info['passwordexpirationtime'][0], $regs)) {
                /* Sun/Fedora Directory Server return expiration time, not
                 * last change time. We emulate the behaviour taking it
                 * back to maxage. */
                $lookupshadow['shadowlastchange'] = floor(mktime($regs[4], $regs[5], $regs[6], $regs[2], $regs[3], $regs[1]) / 86400) - $this->_params['maxage'];

                /* Password expiry attributes are in not accessible policy
                 * entry. */
                $lookupshadow['shadowwarning'] = $this->_params['warnage'];
                $lookupshadow['shadowmin']     = $this->_params['minage'];
                $lookupshadow['shadowmax']     = $this->_params['maxage'];
            } elseif ($this->_logger) {
                $this->_logger->log('Wrong time format: ' . $info['passwordexpirationtime'][0], 'ERR');
            }
        } else {
            if (isset($info['shadowmax'][0])) {
                $lookupshadow['shadowmax'] = $info['shadowmax'][0];
            }
            if (isset($info['shadowmin'][0])) {
                $lookupshadow['shadowmin'] = $info['shadowmin'][0];
            }
            if (isset($info['shadowlastchange'][0])) {
                $lookupshadow['shadowlastchange'] = $info['shadowlastchange'][0];
            }
            if (isset($info['shadowwarning'][0])) {
                $lookupshadow['shadowwarning'] = $info['shadowwarning'][0];
            }
        }

        return $lookupshadow;
    }

    /**
     * Find out if the given set of login credentials are valid.
     *
     * @param string $userId       The userId to check.
     * @param array  $credentials  An array of login credentials.
     *
     * @throws Horde_Auth_Exception
     */
    protected function _authenticate($userId, $credentials)
    {
        if (!strlen($credentials['password'])) {
            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
        }

        /* Search for the user's full DN. */
        $this->_ldap->bind();
        try {
            $dn = $this->_ldap->findUserDN($userId);
        } catch (Horde_Exception_NotFound $e) {
            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
        } catch (Horde_Exception_Ldap $e) {
            throw new Horde_Auth_Exception($e->getMessage(), Horde_Auth::REASON_MESSAGE);
        }

        /* Attempt to bind to the LDAP server as the user. */
        try {
            $this->_ldap->bind($dn, $credentials['password']);
            // Be sure we rebind as the configured user.
            $this->_ldap->bind();
        } catch (Horde_Ldap_Exception $e) {
            // Be sure we rebind as the configured user.
            $this->_ldap->bind();
            if (Horde_Ldap::errorName($e->getCode() == 'LDAP_INVALID_CREDENTIALS')) {
                throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
            }
            throw new Horde_Auth_Exception($e->getMessage(), Horde_Auth::REASON_MESSAGE);
        }

        if ($this->_params['password_expiration'] == 'yes') {
            $shadow = $this->_lookupShadow($dn);
            if ($shadow['shadowmax'] && $shadow['shadowlastchange'] &&
                $shadow['shadowwarning']) {
                $today = floor(time() / 86400);
                $toexpire = $shadow['shadowlastchange'] +
                            $shadow['shadowmax'] - $today;

                $warnday = $shadow['shadowlastchange'] + $shadow['shadowmax'] - $shadow['shadowwarning'];
                if ($today >= $warnday) {
                    $this->setCredential('expire', $toexpire);
                }

                if ($toexpire == 0) {
                    $this->setCredential('change', true);
                } elseif ($toexpire < 0) {
                    throw new Horde_Auth_Exception('', Horde_Auth::REASON_EXPIRED);
                }
            }
        }
    }

    /**
     * Add a set of authentication credentials.
     *
     * @param string $userId      The userId to add.
     * @param array $credentials  The credentials to be set.
     *
     * @throws Horde_Auth_Exception
     */
    public function addUser($userId, $credentials)
    {
        if (!empty($this->_params['ad'])) {
            throw new Horde_Auth_Exception(__CLASS__ . ': Adding users is not supported for Active Directory.');
        }

        if (isset($credentials['ldap'])) {
            $entry = $credentials['ldap'];
            $dn = $entry['dn'];

            /* Remove the dn entry from the array. */
            unset($entry['dn']);
        } else {
            /* Try this simple default and hope it works. */
            $dn = $this->_params['uid'] . '=' . $userId . ','
                . $this->_params['basedn'];
            $entry['cn'] = $userId;
            $entry['sn'] = $userId;
            $entry[$this->_params['uid']] = $userId;
            $entry['objectclass'] = array_merge(
                array('top'),
                $this->_params['newuser_objectclass']);
            $entry['userPassword'] = Horde_Auth::getCryptedPassword(
                $credentials['password'], '',
                $this->_params['encryption'],
                'true');

            if ($this->_params['password_expiration'] == 'yes') {
                $entry['shadowMin'] = $this->_params['minage'];
                $entry['shadowMax'] = $this->_params['maxage'];
                $entry['shadowWarning'] = $this->_params['warnage'];
                $entry['shadowLastChange'] = floor(time() / 86400);
            }
        }

        try {
            $this->_ldap->add(Horde_Ldap_Entry::createFresh($dn, $entry));
        } catch (Horde_Ldap_Exception $e) {
            throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to add user "%s". This is what the server said: ', $userId) . $e->getMessage());
        }
    }

    /**
     * Remove a set of authentication credentials.
     *
     * @param string $userId  The userId to add.
     * @param string $dn      TODO
     *
     * @throws Horde_Auth_Exception
     */
    public function removeUser($userId, $dn = null)
    {
        if (!empty($this->_params['ad'])) {
            throw new Horde_Auth_Exception(__CLASS__ . ': Removing users is not supported for Active Directory');
        }

        if (is_null($dn)) {
            /* Search for the user's full DN. */
            try {
                $dn = $this->_ldap->findUserDN($userId);
            } catch (Horde_Exception_Ldap $e) {
                throw new Horde_Auth_Exception($e);
            }
        }

        try {
            $this->_ldap->delete($dn);
        } catch (Horde_Ldap_Exception $e) {
            throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to remove user "%s"', $userId));
        }
    }

    /**
     * Update a set of authentication credentials.
     *
     * @todo Clean this up for Horde 5.
     *
     * @param string $oldID       The old userId.
     * @param string $newID       The new userId.
     * @param array $credentials  The new credentials.
     * @param string $olddn       The old user DN.
     * @param string $newdn       The new user DN.
     *
     * @throws Horde_Auth_Exception
     */
    public function updateUser($oldID, $newID, $credentials, $olddn = null,
                               $newdn = null)
    {
        if (!empty($this->_params['ad'])) {
            throw new Horde_Auth_Exception(__CLASS__ . ': Updating users is not supported for Active Directory.');
        }

        if (is_null($olddn)) {
            /* Search for the user's full DN. */
            try {
                $dn = $this->_ldap->findUserDN($oldID);
            } catch (Horde_Exception_Ldap $e) {
                throw new Horde_Auth_Exception($e);
            }

            $olddn = $dn;
            $newdn = preg_replace('/uid=.*?,/', 'uid=' . $newID . ',', $dn, 1);
            $shadow = $this->_lookupShadow($dn);

            /* If shadowmin hasn't yet expired only change when we are
               administrator */
            if ($shadow['shadowlastchange'] &&
                $shadow['shadowmin'] &&
                ($shadow['shadowlastchange'] + $shadow['shadowmin'] > (time() / 86400))) {
                throw new Horde_Auth_Exception('Minimum password age has not yet expired');
            }

            /* Set the lastchange field */
            if ($shadow['shadowlastchange']) {
                $entry['shadowlastchange'] =  floor(time() / 86400);
            }

            /* Encrypt the new password */
            $entry['userpassword'] = Horde_Auth::getCryptedPassword(
                $credentials['password'], '',
                $this->_params['encryption'],
                'true');
        } else {
            $entry = $credentials;
            unset($entry['dn']);
        }

        try {
            if ($oldID != $newID) {
                $this->_ldap->move($olddn, $newdn);
                $this->_ldap->modify($newdn, array('replace' => $entry));
            } else {
                $this->_ldap->modify($olddn, array('replace' => $entry));
            }
        } catch (Horde_Ldap_Exception $e) {
            throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to update user "%s"', $newID));
        }
    }

    /**
     * Reset a user's password. Used for example when the user does not
     * remember the existing password.
     *
     * @param string $userId  The user id for which to reset the password.
     *
     * @return string  The new password on success.
     * @throws Horde_Auth_Exception
     */
    public function resetPassword($userId)
    {
        if (!empty($this->_params['ad'])) {
            throw new Horde_Auth_Exception(__CLASS__ . ': Updating users is not supported for Active Directory.');
        }

        /* Search for the user's full DN. */
        try {
            $dn = $this->_ldap->findUserDN($userId);
        } catch (Horde_Exception_Ldap $e) {
            throw new Horde_Auth_Exception($e);
        }

        /* Get a new random password. */
        $password = Horde_Auth::genRandomPassword();

        /* Encrypt the new password */
        $entry = array(
            'userpassword' => Horde_Auth::getCryptedPassword($password,
                                                             '',
                                                             $this->_params['encryption'],
                                                             'true'));

        /* Set the lastchange field */
        $shadow = $this->_lookupShadow($dn);
        if ($shadow['shadowlastchange']) {
            $entry['shadowlastchange'] = floor(time() / 86400);
        }

        /* Update user entry. */
        try {
            $this->_ldap->modify($dn, array('replace' => $entry));
        } catch (Horde_Ldap_Exception $e) {
            throw new Horde_Auth_Exception($e);
        }

        return $password;
    }

    /**
     * Lists all users in the system.
     *
     * @param boolean $sort  Sort the users?
     *
     * @return array  The array of userIds.
     * @throws Horde_Auth_Exception
     */
    public function listUsers($sort = false)
    {
        $params = array(
            'attributes' => array($this->_params['uid']),
            'scope' => $this->_params['scope'],
            'sizelimit' => isset($this->_params['sizelimit']) ? $this->_params['sizelimit'] : 0
        );

        /* Add a sizelimit, if specified. Default is 0, which means no limit.
         * Note: You cannot override a server-side limit with this. */
        $userlist = array();
        try {
            $search = $this->_ldap->search(
                $this->_params['basedn'],
                Horde_Ldap_Filter::build(array('filter' => $this->_params['filter'])),
                $params);
            $uid = Horde_String::lower($this->_params['uid']);
            foreach ($search as $val) {
                $userlist[] = $val->getValue($uid, 'single');
            }
        } catch (Horde_Ldap_Exception $e) {
        }

        return $this->_sort($userlist, $sort);
    }

    /**
     * Checks if $userId exists in the LDAP backend system.
     *
     * @author Marco Ferrante, University of Genova (I)
     *
     * @param string $userId  User ID for which to check
     *
     * @return boolean  Whether or not $userId already exists.
     */
    public function exists($userId)
    {
        $params = array(
            'scope' => $this->_params['scope']
        );

        try {
            $uidfilter = Horde_Ldap_Filter::create($this->_params['uid'], 'equals', $userId);
            $classfilter = Horde_Ldap_Filter::build(array('filter' => $this->_params['filter']));

            $search = $this->_ldap->search(
                $this->_params['basedn'],
                Horde_Ldap_Filter::combine('and', array($uidfilter, $classfilter)),
                $params);
            if ($search->count() < 1) {
                return false;
            }
            if ($search->count() > 1 && $this->_logger) {
                $this->_logger->log('Multiple LDAP entries with user identifier ' . $userId, 'WARN');
            }
            return true;
        } catch (Horde_Ldap_Exception $e) {
            if ($this->_logger) {
                $this->_logger->log('Error searching LDAP user: ' . $e->getMessage(), 'ERR');
            }
            return false;
        }
    }
}
