1<?php
2/**
3 * Copyright 1999-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file COPYING for license information (LGPL). If you did
6 * not receive this file, see http://www.horde.org/licenses/lgpl21.
7 *
8 * @author   Jon Parise <jon@horde.org>
9 * @category Horde
10 * @license  http://www.horde.org/licenses/lgpl21 LGPL-2.1
11 * @package  Auth
12 */
13
14/**
15 * The Horde_Auth_Ldap class provides an LDAP implementation of the Horde
16 * authentication system.
17 *
18 * 'preauthenticate' hook should return LDAP connection information in the
19 * 'ldap' credentials key.
20 *
21 * @author    Jon Parise <jon@horde.org>
22 * @category  Horde
23 * @copyright 1999-2017 Horde LLC
24 * @license   http://www.horde.org/licenses/lgpl21 LGPL-2.1
25 * @package   Auth
26 */
27class Horde_Auth_Ldap extends Horde_Auth_Base
28{
29    /**
30     * An array of capabilities, so that the driver can report which
31     * operations it supports and which it doesn't.
32     *
33     * @var array
34     */
35    protected $_capabilities = array(
36        'add' => true,
37        'update' => true,
38        'resetpassword' => true,
39        'remove' => true,
40        'list' => true,
41        'authenticate' => true,
42    );
43
44    /**
45     * LDAP object
46     *
47     * @var Horde_Ldap
48     */
49    protected $_ldap;
50
51    /**
52     * Constructor.
53     *
54     * @param array $params  Required parameters:
55     * <pre>
56     * 'basedn' - (string) [REQUIRED] The base DN for the LDAP server.
57     * 'filter' - (string) The LDAP formatted search filter to search for
58     *            users. This setting overrides the 'objectclass' parameter.
59     * 'ldap' - (Horde_Ldap) [REQUIRED] Horde LDAP object.
60     * 'objectclass - (string|array): The objectclass filter used to search
61     *                for users. Either a single or an array of objectclasses.
62     * 'uid' - (string) [REQUIRED] The username search key.
63     * </pre>
64     *
65     * @throws Horde_Auth_Exception
66     * @throws InvalidArgumentException
67     */
68    public function __construct(array $params = array())
69    {
70        foreach (array('basedn', 'ldap', 'uid') as $val) {
71            if (!isset($params[$val])) {
72                throw new InvalidArgumentException(__CLASS__ . ': Missing ' . $val . ' parameter.');
73            }
74        }
75
76        if (!empty($params['ad'])) {
77            $this->_capabilities['resetpassword'] = false;
78        }
79
80        $this->_ldap = $params['ldap'];
81        unset($params['ldap']);
82
83        parent::__construct($params);
84    }
85
86    /**
87     * Checks for shadowLastChange and shadowMin/Max support and returns their
88     * values.  We will also check for pwdLastSet if Active Directory is
89     * support is requested.  For this check to succeed we need to be bound
90     * to the directory.
91     *
92     * @param string $dn  The dn of the user.
93     *
94     * @return array  Array with keys being "shadowlastchange", "shadowmin"
95     *                "shadowmax", "shadowwarning" and containing their
96     *                respective values or false for no support.
97     */
98    protected function _lookupShadow($dn)
99    {
100        /* Init the return array. */
101        $lookupshadow = array(
102            'shadowlastchange' => false,
103            'shadowmin' => false,
104            'shadowmax' => false,
105            'shadowwarning' => false
106        );
107
108        /* According to LDAP standard, to read operational attributes, you
109         * must request them explicitly. Attributes involved in password
110         * expiration policy:
111         *    pwdlastset: Active Directory
112         *    shadow*: shadowUser schema
113         *    passwordexpirationtime: Sun and Fedora Directory Server */
114        try {
115            $result = $this->_ldap->search(null, '(objectClass=*)', array(
116                'attributes' => array(
117                    'pwdlastset',
118                    'shadowmax',
119                    'shadowmin',
120                    'shadowlastchange',
121                    'shadowwarning',
122                    'passwordexpirationtime'
123                ),
124                'scope' => 'base'
125            ));
126        } catch (Horde_Ldap_Exception $e) {
127            return $lookupshadow;
128        }
129
130        if (!$result) {
131            return $lookupshadow;
132        }
133
134        $info = reset($result);
135
136        // TODO: 'ad'?
137        if (!empty($this->_params['ad'])) {
138            if (isset($info['pwdlastset'][0])) {
139                /* Active Directory handles timestamps a bit differently.
140                 * Convert the timestamp to a UNIX timestamp. */
141                $lookupshadow['shadowlastchange'] = floor((($info['pwdlastset'][0] / 10000000) - 11644406783) / 86400) - 1;
142
143                /* Password expiry attributes are in a policy. We cannot
144                 * read them so use the Horde config. */
145                $lookupshadow['shadowwarning'] = $this->_params['warnage'];
146                $lookupshadow['shadowmin'] = $this->_params['minage'];
147                $lookupshadow['shadowmax'] = $this->_params['maxage'];
148            }
149        } elseif (isset($info['passwordexpirationtime'][0])) {
150            /* Sun/Fedora Directory Server uses a special attribute
151             * passwordexpirationtime.  It has precedence over shadow*
152             * because it actually locks the expired password at the LDAP
153             * server level.  The correct way to check expiration should
154             * be using LDAP controls, unfortunately PHP doesn't support
155             * controls on bind() responses. */
156            $ldaptimepattern = "/([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})Z/";
157            if (preg_match($ldaptimepattern, $info['passwordexpirationtime'][0], $regs)) {
158                /* Sun/Fedora Directory Server return expiration time, not
159                 * last change time. We emulate the behaviour taking it
160                 * back to maxage. */
161                $lookupshadow['shadowlastchange'] = floor(mktime($regs[4], $regs[5], $regs[6], $regs[2], $regs[3], $regs[1]) / 86400) - $this->_params['maxage'];
162
163                /* Password expiry attributes are in not accessible policy
164                 * entry. */
165                $lookupshadow['shadowwarning'] = $this->_params['warnage'];
166                $lookupshadow['shadowmin']     = $this->_params['minage'];
167                $lookupshadow['shadowmax']     = $this->_params['maxage'];
168            } elseif ($this->_logger) {
169                $this->_logger->log('Wrong time format: ' . $info['passwordexpirationtime'][0], 'ERR');
170            }
171        } else {
172            if (isset($info['shadowmax'][0])) {
173                $lookupshadow['shadowmax'] = $info['shadowmax'][0];
174            }
175            if (isset($info['shadowmin'][0])) {
176                $lookupshadow['shadowmin'] = $info['shadowmin'][0];
177            }
178            if (isset($info['shadowlastchange'][0])) {
179                $lookupshadow['shadowlastchange'] = $info['shadowlastchange'][0];
180            }
181            if (isset($info['shadowwarning'][0])) {
182                $lookupshadow['shadowwarning'] = $info['shadowwarning'][0];
183            }
184        }
185
186        return $lookupshadow;
187    }
188
189    /**
190     * Find out if the given set of login credentials are valid.
191     *
192     * @param string $userId       The userId to check.
193     * @param array  $credentials  An array of login credentials.
194     *
195     * @throws Horde_Auth_Exception
196     */
197    protected function _authenticate($userId, $credentials)
198    {
199        if (!strlen($credentials['password'])) {
200            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
201        }
202
203        /* Search for the user's full DN. */
204        $this->_ldap->bind();
205        try {
206            $dn = $this->_ldap->findUserDN($userId);
207        } catch (Horde_Exception_NotFound $e) {
208            throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
209        } catch (Horde_Exception_Ldap $e) {
210            throw new Horde_Auth_Exception($e->getMessage(), Horde_Auth::REASON_MESSAGE);
211        }
212
213        /* Attempt to bind to the LDAP server as the user. */
214        try {
215            $this->_ldap->bind($dn, $credentials['password']);
216            // Be sure we rebind as the configured user.
217            $this->_ldap->bind();
218        } catch (Horde_Ldap_Exception $e) {
219            // Be sure we rebind as the configured user.
220            $this->_ldap->bind();
221            if (Horde_Ldap::errorName($e->getCode() == 'LDAP_INVALID_CREDENTIALS')) {
222                throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN);
223            }
224            throw new Horde_Auth_Exception($e->getMessage(), Horde_Auth::REASON_MESSAGE);
225        }
226
227        if ($this->_params['password_expiration'] == 'yes') {
228            $shadow = $this->_lookupShadow($dn);
229            if ($shadow['shadowmax'] && $shadow['shadowlastchange'] &&
230                $shadow['shadowwarning']) {
231                $today = floor(time() / 86400);
232                $toexpire = $shadow['shadowlastchange'] +
233                            $shadow['shadowmax'] - $today;
234
235                $warnday = $shadow['shadowlastchange'] + $shadow['shadowmax'] - $shadow['shadowwarning'];
236                if ($today >= $warnday) {
237                    $this->setCredential('expire', $toexpire);
238                }
239
240                if ($toexpire == 0) {
241                    $this->setCredential('change', true);
242                } elseif ($toexpire < 0) {
243                    throw new Horde_Auth_Exception('', Horde_Auth::REASON_EXPIRED);
244                }
245            }
246        }
247    }
248
249    /**
250     * Add a set of authentication credentials.
251     *
252     * @param string $userId      The userId to add.
253     * @param array $credentials  The credentials to be set.
254     *
255     * @throws Horde_Auth_Exception
256     */
257    public function addUser($userId, $credentials)
258    {
259        if (!empty($this->_params['ad'])) {
260            throw new Horde_Auth_Exception(__CLASS__ . ': Adding users is not supported for Active Directory.');
261        }
262
263        if (isset($credentials['ldap'])) {
264            $entry = $credentials['ldap'];
265            $dn = $entry['dn'];
266
267            /* Remove the dn entry from the array. */
268            unset($entry['dn']);
269        } else {
270            /* Try this simple default and hope it works. */
271            $dn = $this->_params['uid'] . '=' . $userId . ','
272                . $this->_params['basedn'];
273            $entry['cn'] = $userId;
274            $entry['sn'] = $userId;
275            $entry[$this->_params['uid']] = $userId;
276            $entry['objectclass'] = array_merge(
277                array('top'),
278                $this->_params['newuser_objectclass']);
279            $entry['userPassword'] = Horde_Auth::getCryptedPassword(
280                $credentials['password'], '',
281                $this->_params['encryption'],
282                'true');
283
284            if ($this->_params['password_expiration'] == 'yes') {
285                $entry['shadowMin'] = $this->_params['minage'];
286                $entry['shadowMax'] = $this->_params['maxage'];
287                $entry['shadowWarning'] = $this->_params['warnage'];
288                $entry['shadowLastChange'] = floor(time() / 86400);
289            }
290        }
291
292        try {
293            $this->_ldap->add(Horde_Ldap_Entry::createFresh($dn, $entry));
294        } catch (Horde_Ldap_Exception $e) {
295            throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to add user "%s". This is what the server said: ', $userId) . $e->getMessage());
296        }
297    }
298
299    /**
300     * Remove a set of authentication credentials.
301     *
302     * @param string $userId  The userId to add.
303     * @param string $dn      TODO
304     *
305     * @throws Horde_Auth_Exception
306     */
307    public function removeUser($userId, $dn = null)
308    {
309        if (!empty($this->_params['ad'])) {
310            throw new Horde_Auth_Exception(__CLASS__ . ': Removing users is not supported for Active Directory');
311        }
312
313        if (is_null($dn)) {
314            /* Search for the user's full DN. */
315            try {
316                $dn = $this->_ldap->findUserDN($userId);
317            } catch (Horde_Exception_Ldap $e) {
318                throw new Horde_Auth_Exception($e);
319            }
320        }
321
322        try {
323            $this->_ldap->delete($dn);
324        } catch (Horde_Ldap_Exception $e) {
325            throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to remove user "%s"', $userId));
326        }
327    }
328
329    /**
330     * Update a set of authentication credentials.
331     *
332     * @todo Clean this up for Horde 5.
333     *
334     * @param string $oldID       The old userId.
335     * @param string $newID       The new userId.
336     * @param array $credentials  The new credentials.
337     * @param string $olddn       The old user DN.
338     * @param string $newdn       The new user DN.
339     *
340     * @throws Horde_Auth_Exception
341     */
342    public function updateUser($oldID, $newID, $credentials, $olddn = null,
343                               $newdn = null)
344    {
345        if (!empty($this->_params['ad'])) {
346            throw new Horde_Auth_Exception(__CLASS__ . ': Updating users is not supported for Active Directory.');
347        }
348
349        if (is_null($olddn)) {
350            /* Search for the user's full DN. */
351            try {
352                $dn = $this->_ldap->findUserDN($oldID);
353            } catch (Horde_Exception_Ldap $e) {
354                throw new Horde_Auth_Exception($e);
355            }
356
357            $olddn = $dn;
358            $newdn = preg_replace('/uid=.*?,/', 'uid=' . $newID . ',', $dn, 1);
359            $shadow = $this->_lookupShadow($dn);
360
361            /* If shadowmin hasn't yet expired only change when we are
362               administrator */
363            if ($shadow['shadowlastchange'] &&
364                $shadow['shadowmin'] &&
365                ($shadow['shadowlastchange'] + $shadow['shadowmin'] > (time() / 86400))) {
366                throw new Horde_Auth_Exception('Minimum password age has not yet expired');
367            }
368
369            /* Set the lastchange field */
370            if ($shadow['shadowlastchange']) {
371                $entry['shadowlastchange'] =  floor(time() / 86400);
372            }
373
374            /* Encrypt the new password */
375            $entry['userpassword'] = Horde_Auth::getCryptedPassword(
376                $credentials['password'], '',
377                $this->_params['encryption'],
378                'true');
379        } else {
380            $entry = $credentials;
381            unset($entry['dn']);
382        }
383
384        try {
385            if ($oldID != $newID) {
386                $this->_ldap->move($olddn, $newdn);
387                $this->_ldap->modify($newdn, array('replace' => $entry));
388            } else {
389                $this->_ldap->modify($olddn, array('replace' => $entry));
390            }
391        } catch (Horde_Ldap_Exception $e) {
392            throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to update user "%s"', $newID));
393        }
394    }
395
396    /**
397     * Reset a user's password. Used for example when the user does not
398     * remember the existing password.
399     *
400     * @param string $userId  The user id for which to reset the password.
401     *
402     * @return string  The new password on success.
403     * @throws Horde_Auth_Exception
404     */
405    public function resetPassword($userId)
406    {
407        if (!empty($this->_params['ad'])) {
408            throw new Horde_Auth_Exception(__CLASS__ . ': Updating users is not supported for Active Directory.');
409        }
410
411        /* Search for the user's full DN. */
412        try {
413            $dn = $this->_ldap->findUserDN($userId);
414        } catch (Horde_Exception_Ldap $e) {
415            throw new Horde_Auth_Exception($e);
416        }
417
418        /* Get a new random password. */
419        $password = Horde_Auth::genRandomPassword();
420
421        /* Encrypt the new password */
422        $entry = array(
423            'userpassword' => Horde_Auth::getCryptedPassword($password,
424                                                             '',
425                                                             $this->_params['encryption'],
426                                                             'true'));
427
428        /* Set the lastchange field */
429        $shadow = $this->_lookupShadow($dn);
430        if ($shadow['shadowlastchange']) {
431            $entry['shadowlastchange'] = floor(time() / 86400);
432        }
433
434        /* Update user entry. */
435        try {
436            $this->_ldap->modify($dn, array('replace' => $entry));
437        } catch (Horde_Ldap_Exception $e) {
438            throw new Horde_Auth_Exception($e);
439        }
440
441        return $password;
442    }
443
444    /**
445     * Lists all users in the system.
446     *
447     * @param boolean $sort  Sort the users?
448     *
449     * @return array  The array of userIds.
450     * @throws Horde_Auth_Exception
451     */
452    public function listUsers($sort = false)
453    {
454        $params = array(
455            'attributes' => array($this->_params['uid']),
456            'scope' => $this->_params['scope'],
457            'sizelimit' => isset($this->_params['sizelimit']) ? $this->_params['sizelimit'] : 0
458        );
459
460        /* Add a sizelimit, if specified. Default is 0, which means no limit.
461         * Note: You cannot override a server-side limit with this. */
462        $userlist = array();
463        try {
464            $search = $this->_ldap->search(
465                $this->_params['basedn'],
466                Horde_Ldap_Filter::build(array('filter' => $this->_params['filter'])),
467                $params);
468            $uid = Horde_String::lower($this->_params['uid']);
469            foreach ($search as $val) {
470                $userlist[] = $val->getValue($uid, 'single');
471            }
472        } catch (Horde_Ldap_Exception $e) {
473        }
474
475        return $this->_sort($userlist, $sort);
476    }
477
478    /**
479     * Checks if $userId exists in the LDAP backend system.
480     *
481     * @author Marco Ferrante, University of Genova (I)
482     *
483     * @param string $userId  User ID for which to check
484     *
485     * @return boolean  Whether or not $userId already exists.
486     */
487    public function exists($userId)
488    {
489        $params = array(
490            'scope' => $this->_params['scope']
491        );
492
493        try {
494            $uidfilter = Horde_Ldap_Filter::create($this->_params['uid'], 'equals', $userId);
495            $classfilter = Horde_Ldap_Filter::build(array('filter' => $this->_params['filter']));
496
497            $search = $this->_ldap->search(
498                $this->_params['basedn'],
499                Horde_Ldap_Filter::combine('and', array($uidfilter, $classfilter)),
500                $params);
501            if ($search->count() < 1) {
502                return false;
503            }
504            if ($search->count() > 1 && $this->_logger) {
505                $this->_logger->log('Multiple LDAP entries with user identifier ' . $userId, 'WARN');
506            }
507            return true;
508        } catch (Horde_Ldap_Exception $e) {
509            if ($this->_logger) {
510                $this->_logger->log('Error searching LDAP user: ' . $e->getMessage(), 'ERR');
511            }
512            return false;
513        }
514    }
515}
516