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   Chuck Hagenbuch <chuck@horde.org>
9 * @author   Michael Slusarz <slusarz@horde.org>
10 * @category Horde
11 * @license  http://www.horde.org/licenses/lgpl21 LGPL-2.1
12 * @package  Auth
13 */
14
15/**
16 * The Horde_Auth_Base class provides a common abstracted interface to creating
17 * various authentication backends.
18 *
19 * @author    Chuck Hagenbuch <chuck@horde.org>
20 * @author    Michael Slusarz <slusarz@horde.org>
21 * @category  Horde
22 * @copyright 1999-2017 Horde LLC
23 * @license   http://www.horde.org/licenses/lgpl21 LGPL-2.1
24 * @package   Auth
25 */
26abstract class Horde_Auth_Base
27{
28    /**
29     * An array of capabilities, so that the driver can report which
30     * operations it supports and which it doesn't.
31     *
32     * @var array
33     */
34    protected $_capabilities = array(
35        'add'           => false,
36        'authenticate'  => true,
37        'groups'        => false,
38        'list'          => false,
39        'resetpassword' => false,
40        'remove'        => false,
41        'transparent'   => false,
42        'update'        => false,
43        'badlogincount' => false,
44        'lock'          => false,
45    );
46
47    /**
48     * Hash containing parameters needed for the drivers.
49     *
50     * @var array
51     */
52    protected $_params = array();
53
54    /**
55     * The credentials currently being authenticated.
56     *
57     * @var array
58     */
59    protected $_credentials = array(
60        'change' => false,
61        'credentials' => array(),
62        'expire' => null,
63        'userId' => ''
64    );
65
66    /**
67     * Logger object.
68     *
69     * @var Horde_Log_Logger
70     */
71    protected $_logger;
72
73    /**
74     * History object.
75     *
76     * @var Horde_History
77     */
78    protected $_history_api;
79
80    /**
81     * Lock object.
82     *
83     * @var Horde_Lock
84     */
85    protected $_lock_api;
86
87    /**
88     * Authentication error information.
89     *
90     * @var array
91     */
92    protected $_error;
93
94    /**
95     * Constructor.
96     *
97     * @param array $params  Optional parameters:
98     *     - default_user:      (string) The default user.
99     *     - logger:            (Horde_Log_Logger, optional) A logger object.
100     *     - lock_api:          (Horde_Lock, optional) A locking object.
101     *     - history_api:       (Horde_History, optional) A history object.
102     *     - login_block_count: (integer, optional) How many failed logins
103     *                          trigger autoblocking? 0 disables the feature.
104     *     - login_block_time:  (integer, options) How many minutes should
105     *                          autoblocking last? 0 means no expiration.
106     */
107    public function __construct(array $params = array())
108    {
109        if (isset($params['logger'])) {
110            $this->_logger = $params['logger'];
111            unset($params['logger']);
112        }
113
114        if (isset($params['lock_api'])) {
115            $this->_lock_api = $params['lock_api'];
116            $this->_capabilities['lock'] = true;
117            unset($params['lock_api']);
118        }
119
120        if (isset($params['history_api'])) {
121            $this->_history_api = $params['history_api'];
122            $this->_capabilities['badlogincount'] = true;
123            unset($params['history_api']);
124        }
125
126        $params = array_merge(array(
127            'default_user' => ''
128        ), $params);
129
130        $this->_params = $params;
131    }
132
133    /**
134     * Finds out if a set of login credentials are valid, and if requested,
135     * mark the user as logged in in the current session.
136     *
137     * @param string $userId      The userId to check.
138     * @param array $credentials  The credentials to check.
139     * @param boolean $login      Whether to log the user in. If false, we'll
140     *                            only test the credentials and won't modify
141     *                            the current session. Defaults to true.
142     *
143     * @return boolean  Whether or not the credentials are valid.
144     */
145    public function authenticate($userId, $credentials, $login = true)
146    {
147        $userId = trim($userId);
148
149        try {
150            $this->_credentials['userId'] = $userId;
151            if (($this->hasCapability('lock')) &&
152                $this->isLocked($userId)) {
153                $details = $this->isLocked($userId, true);
154                if ($details['lock_timeout'] == Horde_Lock::PERMANENT) {
155                    $message = Horde_Auth_Translation::t("Your account has been permanently locked");
156                } else {
157                    $message = sprintf(Horde_Auth_Translation::t("Your account has been locked for %d minutes"), ceil(($details['lock_timeout'] - time()) / 60));
158                }
159                throw new Horde_Auth_Exception($message, Horde_Auth::REASON_LOCKED);
160            }
161            $this->_authenticate($userId, $credentials);
162            $this->setCredential('userId', $this->_credentials['userId']);
163            $this->setCredential('credentials', $credentials);
164            if ($this->hasCapability('badlogincount')) {
165                $this->_resetBadLogins($userId);
166            }
167            return true;
168        } catch (Horde_Auth_Exception $e) {
169            if (($code = $e->getCode()) &&
170                $code != Horde_Auth::REASON_MESSAGE) {
171                if (($code == Horde_Auth::REASON_BADLOGIN) &&
172                    $this->hasCapability('badlogincount')) {
173                    $this->_badLogin($userId);
174                }
175                $this->setError($code, $e->getMessage());
176            } else {
177                $this->setError(Horde_Auth::REASON_MESSAGE, $e->getMessage());
178            }
179            return false;
180        }
181    }
182
183    /**
184     * Basic sort implementation.
185     *
186     * If the backend has listUsers and doesn't have a native sorting option,
187     * fall back to this method.
188     *
189     * @param array   $users  An array of usernames.
190     * @param boolean $sort   Whether to sort or not.
191     *
192     * @return array the users, sorted or not
193     *
194     */
195    protected function _sort($users, $sort)
196    {
197        if ($sort) {
198            sort($users);
199        }
200        return $users;
201    }
202
203    /**
204     * Authentication stub.
205     *
206     * On failure, Horde_Auth_Exception should pass a message string (if any)
207     * in the message field, and the Horde_Auth::REASON_* constant in the code
208     * field (defaults to Horde_Auth::REASON_MESSAGE).
209     *
210     * @param string $userId      The userID to check.
211     * @param array $credentials  An array of login credentials.
212     *
213     * @throws Horde_Auth_Exception
214     */
215    abstract protected function _authenticate($userId, $credentials);
216
217    /**
218     * Checks for triggers that may invalidate the current auth.
219     * These triggers are independent of the credentials.
220     *
221     * @return boolean  True if the results of authenticate() are still valid.
222     */
223    public function validateAuth()
224    {
225        return true;
226    }
227
228    /**
229     * Adds a set of authentication credentials.
230     *
231     * @param string $userId      The userId to add.
232     * @param array $credentials  The credentials to use.
233     *
234     * @throws Horde_Auth_Exception
235     */
236    public function addUser($userId, $credentials)
237    {
238        throw new Horde_Auth_Exception('Unsupported.');
239    }
240
241    /**
242     * Locks a user indefinitely or for a specified time.
243     *
244     * @param string $userId  The user to lock.
245     * @param integer $time   The duration in minutes, 0 = permanent.
246     *
247     * @throws Horde_Auth_Exception
248     */
249    public function lockUser($userId, $time = 0)
250    {
251        if (!$this->_lock_api) {
252            throw new Horde_Auth_Exception('Unsupported.');
253        }
254
255        if ($time == 0) {
256            $time = Horde_Lock::PERMANENT;
257        } else {
258            $time *= 60;
259        }
260
261        try {
262            if ($this->_lock_api->setLock($userId, 'horde_auth', 'login:' . $userId, $time, Horde_Lock::TYPE_EXCLUSIVE)) {
263                return;
264            }
265        } catch (Horde_Lock_Exception $e) {
266            throw new Horde_Auth_Exception($e);
267        }
268
269        throw new Horde_Auth_Exception('User is already locked',
270                                       Horde_Auth::REASON_LOCKED);
271    }
272
273    /**
274     * Unlocks a user and optionally resets the bad login count.
275     *
276     * @param string  $userId          The user to unlock.
277     * @param boolean $resetBadLogins  Reset bad login counter?
278     *
279     * @throws Horde_Auth_Exception
280     */
281    public function unlockUser($userId, $resetBadLogins = false)
282    {
283        if (!$this->_lock_api) {
284            throw new Horde_Auth_Exception('Unsupported.');
285        }
286
287        try {
288            $locks = $this->_lock_api->getLocks(
289                'horde_auth', 'login:' . $userId, Horde_Lock::TYPE_EXCLUSIVE);
290            $lock_id = key($locks);
291            if ($lock_id) {
292                $this->_lock_api->clearLock($lock_id);
293            }
294            if ($resetBadLogins) {
295                $this->_resetBadLogins($userId);
296            }
297        } catch (Horde_Lock_Exception $e) {
298            throw new Horde_Auth_Exception($e);
299        }
300    }
301
302    /**
303     * Returns whether a user is currently locked.
304     *
305     * @param string $userId         The user to check.
306     * @param boolean $show_details  Return timeout too?
307     *
308     * @return boolean|array  If $show_details is a true, an array with
309     *                        'locked' and 'lock_timeout' values. Whether the
310     *                        user is locked, otherwise.
311     * @throws Horde_Auth_Exception
312     */
313    public function isLocked($userId, $show_details = false)
314    {
315        if (!$this->_lock_api) {
316            throw new Horde_Auth_Exception('Unsupported.');
317        }
318
319        try  {
320            $locks = $this->_lock_api->getLocks(
321                'horde_auth', 'login:' . $userId, Horde_Lock::TYPE_EXCLUSIVE);
322        } catch (Horde_Lock_Exception $e) {
323            throw new Horde_Auth_Exception($e);
324        }
325
326        if ($show_details) {
327            $lock_id = key($locks);
328            return empty($lock_id)
329                ? array('locked' => false, 'lock_timeout' => 0)
330                : array('locked' => true, 'lock_timeout' => $locks[$lock_id]['lock_expiry_timestamp']);
331        }
332
333        return !empty($locks);
334    }
335
336    /**
337     * Handles a bad login.
338     *
339     * @param string $userId  The user with a bad login.
340     *
341     * @throws Horde_Auth_Exception
342     */
343    protected function _badLogin($userId)
344    {
345        if (!$this->_history_api) {
346            throw new Horde_Auth_Exception('Unsupported.');
347        }
348
349        $history_identifier = $userId . '@logins.failed';
350        try {
351            $this->_history_api->log(
352                $history_identifier,
353                array('action' => 'login_failed', 'who' => $userId));
354            $history_log = $this->_history_api->getHistory($history_identifier);
355            if ($this->_params['login_block_count'] > 0 &&
356                $this->_params['login_block_count'] <= $history_log->count() &&
357                $this->hasCapability('lock')) {
358                $this->lockUser($userId, $this->_params['login_block_time']);
359            }
360        } catch (Horde_History_Exception $e) {
361            throw new Horde_Auth_Exception($e);
362        }
363    }
364
365    /**
366     * Resets the bad login counter.
367     *
368     * @param string $userId  The user to reset.
369     *
370     * @throws Horde_Auth_Exception
371     */
372    protected function _resetBadLogins($userId)
373    {
374        if (!$this->_history_api) {
375            throw new Horde_Auth_Exception('Unsupported.');
376        }
377
378        try {
379            $this->_history_api->removeByNames(array($userId . '@logins.failed'));
380        } catch (Horde_History_Exception $e) {
381            throw new Horde_Auth_Exception($e);
382        }
383    }
384
385    /**
386     * Updates a set of authentication credentials.
387     *
388     * @param string $oldID       The old userId.
389     * @param string $newID       The new userId.
390     * @param array $credentials  The new credentials
391     *
392     * @throws Horde_Auth_Exception
393     */
394    public function updateUser($oldID, $newID, $credentials)
395    {
396        throw new Horde_Auth_Exception('Unsupported.');
397    }
398
399    /**
400     * Deletes a set of authentication credentials.
401     *
402     * @param string $userId  The userId to delete.
403     *
404     * @throws Horde_Auth_Exception
405     */
406    public function removeUser($userId)
407    {
408        throw new Horde_Auth_Exception('Unsupported.');
409    }
410
411    /**
412     * Lists all users in the system.
413     *
414     * @param boolean $sort  Sort the users?
415     *
416     * @return mixed  The array of userIds.
417     * @throws Horde_Auth_Exception
418     */
419    public function listUsers($sort = false)
420    {
421        throw new Horde_Auth_Exception('Unsupported.');
422    }
423
424    /**
425     * Searches the users for a substring.
426     *
427     * @since Horde_Auth 2.2.0
428     *
429     * @param string $search  The search term.
430     *
431     * @return array  A list of all matching users.
432     */
433    public function searchUsers($search)
434    {
435        try {
436            $users = $this->listUsers();
437        } catch (Horde_Auth_Exception $e) {
438            return array();
439        }
440        $matches = array();
441        foreach ($users as $user) {
442            if (Horde_String::ipos($user, $search) !== false) {
443                $matches[] = $user;
444            }
445        }
446        return $matches;
447    }
448
449    /**
450     * Checks if $userId exists in the system.
451     *
452     * @param string $userId  User ID for which to check
453     *
454     * @return boolean  Whether or not $userId already exists.
455     */
456    public function exists($userId)
457    {
458        try {
459            $users = $this->listUsers();
460            return in_array($userId, $users);
461        } catch (Horde_Auth_Exception $e) {
462            return false;
463        }
464    }
465
466    /**
467     * Automatic authentication.
468     *
469     * Transparent authentication should set 'userId', 'credentials', or
470     * 'params' in $this->_credentials as needed - these values will be used
471     * to set the credentials in the session.
472     *
473     * Transparent authentication should normally never throw an error - false
474     * should be returned.
475     *
476     * @return boolean  Whether transparent login is supported.
477     * @throws Horde_Auth_Exception
478     */
479    public function transparent()
480    {
481        return false;
482    }
483
484    /**
485     * Reset a user's password. Used for example when the user does not
486     * remember the existing password.
487     *
488     * @param string $userId  The user id for which to reset the password.
489     *
490     * @return string  The new password on success.
491     * @throws Horde_Auth_Exception
492     */
493    public function resetPassword($userId)
494    {
495        throw new Horde_Auth_Exception('Unsupported.');
496    }
497
498    /**
499     * Queries the current driver to find out if it supports the given
500     * capability.
501     *
502     * @param string $capability  The capability to test for.
503     *
504     * @return boolean  Whether or not the capability is supported.
505     */
506    public function hasCapability($capability)
507    {
508        return !empty($this->_capabilities[$capability]);
509    }
510
511    /**
512     * Returns the named parameter for the current auth driver.
513     *
514     * @param string $param  The parameter to fetch.
515     *
516     * @return string  The parameter's value, or null if it doesn't exist.
517     */
518    public function getParam($param)
519    {
520        return isset($this->_params[$param])
521            ? $this->_params[$param]
522            : null;
523    }
524
525    /**
526     * Returns internal credential value(s).
527     *
528     * @param mixed $name  The credential value to get. If null, will return
529     *                     the entire credential list. Valid names:
530     * - 'change': (boolean) Do credentials need to be changed?
531     * - 'credentials': (array) The credentials needed to authenticate.
532     * - 'expire': (integer) UNIX timestamp of the credential expiration date.
533     * - 'userId': (string) The user ID.
534     *
535     * @return mixed  The credential information, or null if the credential
536     *                doesn't exist.
537     */
538    public function getCredential($name = null)
539    {
540        if (is_null($name)) {
541            return $this->_credentials;
542        }
543
544        return isset($this->_credentials[$name])
545            ? $this->_credentials[$name]
546            : null;
547    }
548
549    /**
550     * Sets an internal credential value.
551     *
552     * @param string $type  The credential name to set. See getCredential()
553     *                      for the list of valid credentials/types.
554     * @param mixed $value  The credential value to set.
555     */
556    public function setCredential($type, $value)
557    {
558        switch ($type) {
559        case 'change':
560            $this->_credentials['change'] = (bool)$value;
561            break;
562
563        case 'credentials':
564            $this->_credentials['credentials'] = array_filter(array_merge($this->_credentials['credentials'], $value));
565            break;
566
567        case 'expire':
568            $this->_credentials['expire'] = intval($value);
569            break;
570
571        case 'userId':
572            $this->_credentials['userId'] = strval($value);
573            break;
574        }
575    }
576
577    /**
578     * Sets the error message for an invalid authentication.
579     *
580     * @param string $type  The type of error (Horde_Auth::REASON_* constant).
581     * @param string $msg   The error message/reason for invalid
582     *                      authentication.
583     */
584    public function setError($type, $msg = null)
585    {
586        $this->_error = array(
587            'msg' => $msg,
588            'type' => $type
589        );
590    }
591
592    /**
593     * Returns the error type or message for an invalid authentication.
594     *
595     * @param boolean $msg  If true, returns the message string (if set).
596     *
597     * @return mixed  Error type, error message (if $msg is true) or false
598     *                if entry doesn't exist.
599     */
600    public function getError($msg = false)
601    {
602        return isset($this->_error['type'])
603            ? ($msg ? $this->_error['msg'] : $this->_error['type'])
604            : false;
605    }
606
607}
608