1<?php
2/**
3 * Copyright 2002-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://opensource.org/licenses/lgpl-2.1.php
7 *
8 * @author   Chuck Hagenbuch <chuck@horde.org>
9 * @author   Michael Slusarz <slusarz@horde.org>
10 * @category Horde
11 * @license  http://opensource.org/licenses/lgpl-2.1.php LGPL
12 * @package  Core
13 */
14
15/**
16 * The Horde_Core_Auth_Application class provides application-specific
17 * authentication built on top of the horde/Auth API.
18 *
19 * @author   Chuck Hagenbuch <chuck@horde.org>
20 * @author   Michael Slusarz <slusarz@horde.org>
21 * @category Horde
22 * @license  http://opensource.org/licenses/lgpl-2.1.php LGPL
23 * @package  Core
24 */
25class Horde_Core_Auth_Application extends Horde_Auth_Base
26{
27    /**
28     * Authentication failure reasons (additions to Horde_Auth:: reasons):
29     *   - REASON_BROWSER: A browser change was detected
30     *   - REASON_SESSIONIP: Logout due to change of IP address during session
31     *   - REASON_SESSIONMAXTIME: Logout due to the session exceeding the
32     *                            maximum allowed length.
33     */
34    const REASON_BROWSER = 100;
35    const REASON_SESSIONIP = 101;
36    const REASON_SESSIONMAXTIME = 102;
37
38    /**
39     * Application for authentication.
40     *
41     * @var string
42     */
43    protected $_app = 'horde';
44
45    /**
46     * The list of application capabilities.
47     *
48     * @var array
49     */
50    protected $_appCapabilities;
51
52    /**
53     * The base auth driver, used for Horde authentication.
54     *
55     * @var Horde_Auth_Base
56     */
57    protected $_base;
58
59    /**
60     * The view mode.
61     *
62     * @var string
63     */
64    protected $_view = 'auto';
65
66    /**
67     * Available capabilities.
68     *
69     * @var array
70     */
71    protected $_capabilities = array(
72        'add'           => true,
73        'authenticate'  => true,
74        'exists'        => true,
75        'list'          => true,
76        'remove'        => true,
77        'resetpassword' => true,
78        'transparent'   => true,
79        'update'        => true,
80        'validate'      => true
81    );
82
83    /**
84     * Constructor.
85     *
86     * @param array $params  Required parameters:
87     *   - app: (string) The application which is providing authentication.
88     *   - base: (Horde_Auth_Base) The base Horde_Auth driver. Only needed if
89     *           'app' is 'horde'.
90     *
91     * @throws InvalidArgumentException
92     */
93    public function __construct(array $params = array())
94    {
95        if (!isset($params['app'])) {
96            throw new InvalidArgumentException('Missing app parameter.');
97        }
98        $this->_app = $params['app'];
99        unset($params['app']);
100
101        if ($this->_app == 'horde') {
102            if (!isset($params['base'])) {
103                throw new InvalidArgumentException('Missing base parameter.');
104            }
105
106            $this->_base = $params['base'];
107            unset($params['base']);
108        }
109
110        parent::__construct($params);
111    }
112
113    /**
114     * Finds out if a set of login credentials are valid, and if requested,
115     * mark the user as logged in in the current session.
116     *
117     * @param string $userId      The user ID to check.
118     * @param array $credentials  The credentials to check.
119     * @param boolean $login      Whether to log the user in. If false, we'll
120     *                            only test the credentials and won't modify
121     *                            the current session. Defaults to true.
122     *
123     * @return boolean  Whether or not the credentials are valid.
124     */
125    public function authenticate($userId, $credentials, $login = true)
126    {
127        if (!strlen($credentials['password'])) {
128            return false;
129        }
130
131        try {
132            list($userId, $credentials) = $this->runHook(trim($userId), $credentials, 'preauthenticate', 'authenticate');
133         } catch (Horde_Auth_Exception $e) {
134            return false;
135        }
136
137        if ($this->_base) {
138            if (!$this->_base->authenticate($userId, $credentials, $login)) {
139                return false;
140            }
141        } elseif (!parent::authenticate($userId, $credentials, $login)) {
142            return false;
143        }
144
145        /* Remember the user's mode choice, if applicable. */
146        if (!empty($credentials['mode'])) {
147            $this->_view = $credentials['mode'];
148        }
149
150        return $this->_setAuth();
151    }
152
153    /**
154     * Find out if a set of login credentials are valid.
155     *
156     * @param string $userId      The user ID to check.
157     * @param array $credentials  The credentials to use. This object will
158     *                            always be available in the 'auth_ob' key.
159     *
160     * @throws Horde_Auth_Exception
161     */
162    protected function _authenticate($userId, $credentials)
163    {
164        if (!$this->hasCapability('authenticate')) {
165            throw new Horde_Auth_Exception($this->_app . ' does not provide an authenticate() method.');
166        }
167
168        $credentials['auth_ob'] = $this;
169
170        $GLOBALS['registry']->callAppMethod($this->_app, 'authAuthenticate', array('args' => array($userId, $credentials), 'noperms' => true));
171    }
172
173    /**
174     * Checks for triggers that may invalidate the current auth.
175     * These triggers are independent of the credentials.
176     *
177     * @return boolean  True if the results of authenticate() are still valid.
178     */
179    public function validateAuth()
180    {
181        if ($this->_base) {
182            return $this->_base->validateAuth();
183        }
184
185        try {
186            return $this->hasCapability('validate')
187                ? $GLOBALS['registry']->callAppMethod($this->_app, 'authValidate', array('noperms' => true))
188                : parent::validateAuth();
189        } catch (Horde_Exception_AuthenticationFailure $e) {
190            return false;
191        }
192    }
193
194    /**
195     * Add a set of authentication credentials.
196     *
197     * @param string $userId      The user ID to add.
198     * @param array $credentials  The credentials to use.
199     *
200     * @throws Horde_Auth_Exception
201     */
202    public function addUser($userId, $credentials)
203    {
204        if ($this->_base) {
205            $this->_base->addUser($userId, $credentials);
206            return;
207        }
208
209        if ($this->hasCapability('add')) {
210            $GLOBALS['registry']->callAppMethod($this->_app, 'authAddUser', array('args' => array($userId, $credentials)));
211        } else {
212            parent::addUser($userId, $credentials);
213        }
214    }
215    /**
216     * Locks a user indefinitely or for a specified time
217     *
218     * @param string $userId      The userId to lock.
219     * @param integer $time       The duration in seconds, 0 = permanent
220     *
221     * @throws Horde_Auth_Exception
222     */
223    public function lockUser($userId, $time = 0)
224    {
225        if ($this->_base) {
226            $this->_base->lockUser($userId, $time);
227            return;
228        }
229
230        if ($this->hasCapability('lock')) {
231            parent::lockUser($userId, $time);
232        }
233    }
234
235    /**
236     * Unlocks a user and optionally resets bad login count
237     *
238     * @param string  $userId          The userId to unlock.
239     * @param boolean $resetBadLogins  Reset bad login counter, default no.
240     *
241     * @throws Horde_Auth_Exception
242     */
243    public function unlockUser($userId, $resetBadLogins = false)
244    {
245        if ($this->_base) {
246            $this->_base->unlockUser($userId, $resetBadLogins);
247            return;
248        }
249
250        if ($this->hasCapability('lock')) {
251            parent::unlockUser($userId, $resetBadLogins);
252        }
253    }
254
255    /**
256     * Checks if $userId is currently locked.
257     *
258     * @param string  $userId      The userId to check.
259     * @param boolean $show_details     Toggle array format with timeout.
260     *
261     * @throws Horde_Auth_Exception
262     */
263    public function isLocked($userId, $show_details = false)
264    {
265        if ($this->_base) {
266            return $this->_base->isLocked($userId, $show_details);
267        }
268
269        if ($this->hasCapability('lock')) {
270            return parent::isLocked($userId, $show_details);
271        }
272    }
273    /**
274     * Update a set of authentication credentials.
275     *
276     * @param string $oldID       The old user ID.
277     * @param string $newID       The new user ID.
278     * @param array $credentials  The new credentials
279     *
280     * @throws Horde_Auth_Exception
281     */
282    public function updateUser($oldID, $newID, $credentials)
283    {
284        if ($this->_base) {
285            $this->_base->updateUser($oldID, $newID, $credentials);
286            return;
287        }
288
289        if ($this->hasCapability('update')) {
290            $GLOBALS['registry']->callAppMethod($this->_app, 'authUpdateUser', array('args' => array($oldID, $newID, $credentials)));
291        } else {
292            parent::updateUser($oldID, $newID, $credentials);
293        }
294    }
295
296    /**
297     * Delete a set of authentication credentials.
298     *
299     * @param string $userId  The user ID to delete.
300     *
301     * @throws Horde_Auth_Exception
302     */
303    public function removeUser($userId)
304    {
305        if ($this->_base) {
306            $this->_base->removeUser($userId);
307        } else {
308            if ($this->hasCapability('remove')) {
309                $GLOBALS['registry']->callAppMethod($this->_app, 'authRemoveUser', array('args' => array($userId)));
310            } else {
311                parent::removeUser($userId);
312            }
313        }
314    }
315
316    /**
317     * List all users in the system.
318     *
319     * @return array  The array of user IDs.
320     * @throws Horde_Auth_Exception
321     */
322    public function listUsers($sort = false)
323    {
324        if ($this->_base) {
325            return $this->_base->listUsers($sort);
326        }
327
328        return $this->hasCapability('list')
329            ? $GLOBALS['registry']->callAppMethod($this->_app, 'authUserList')
330            : parent::listUsers($sort);
331    }
332
333    /**
334     * List all users in the system with their real names.
335     *
336     * @since Horde_Core 2.23.0
337     *
338     * @return array  The array of user IDs as keys and names as values.
339     * @throws Horde_Auth_Exception
340     */
341    public function listNames()
342    {
343        $factory = $GLOBALS['injector']
344            ->getInstance('Horde_Core_Factory_Identity');
345        $names = array();
346        foreach ($this->listUsers() as $user) {
347            $names[$user] = $factory->create($user)->getName();
348        }
349        asort($names);
350        return $names;
351    }
352
353    /**
354     * Checks if a user ID exists in the system.
355     *
356     * @param string $userId  User ID to check.
357     *
358     * @return boolean  Whether or not the user ID already exists.
359     */
360    public function exists($userId)
361    {
362        if ($this->_base) {
363            return $this->_base->exists($userId);
364        }
365
366        return $this->hasCapability('exists')
367            ? $GLOBALS['registry']->callAppMethod($this->_app, 'authUserExists', array('args' => array($userId)))
368            : parent::exists($userId);
369    }
370
371    /**
372     * Automatic authentication.
373     *
374     * @return boolean  Whether or not the client is allowed.
375     * @throws Horde_Auth_Exception
376     */
377    public function transparent()
378    {
379        global $registry;
380
381        if (!($userId = $this->getCredential('userId'))) {
382            $userId = $registry->getAuth();
383        }
384        if (!($credentials = $this->getCredential('credentials'))) {
385            $credentials = $registry->getAuthCredential();
386        }
387
388        list($userId, $credentials) = $this->runHook($userId, $credentials, 'preauthenticate', 'transparent');
389
390        $this->setCredential('userId', $userId);
391        $this->setCredential('credentials', $credentials);
392
393        if ($this->_base) {
394            $result = $this->_base->transparent();
395        } elseif ($this->hasCapability('transparent')) {
396            $result = $registry->callAppMethod($this->_app, 'authTransparent', array('args' => array($this), 'noperms' => true));
397        } else {
398            /* If this application contains neither transparent nor
399             * authenticate capabilities, it does not require any
400             * authentication if already authenticated to Horde. */
401            $result = ($registry->getAuth() && !$this->hasCapability('authenticate'));
402        }
403
404        return $result && $this->_setAuth();
405    }
406
407    /**
408     * Reset a user's password. Used for example when the user does not
409     * remember the existing password.
410     *
411     * @param string $userId  The user ID for which to reset the password.
412     *
413     * @return string  The new password on success.
414     * @throws Horde_Auth_Exception
415     */
416    public function resetPassword($userId)
417    {
418        if ($this->_base) {
419            return $this->_base->resetPassword($userId);
420        }
421
422        return $this->hasCapability('resetpassword')
423            ? $GLOBALS['registry']->callAppMethod($this->_app, 'authResetPassword', array('args' => array($userId)))
424            : parent::resetPassword();
425    }
426
427    /**
428     * Queries the current driver to find out if it supports the given
429     * capability.
430     *
431     * @param string $capability  The capability to test for.
432     *
433     * @return boolean  Whether or not the capability is supported.
434     */
435    public function hasCapability($capability)
436    {
437        if ($this->_base) {
438            return $this->_base->hasCapability($capability);
439        }
440        // The follow capabilities are not determined by the Application,
441        // but by 'Horde'.
442        if (in_array(Horde_String::lower($capability), array('badlogincount', 'lock'))) {
443            return parent::hasCapability($capability);
444        } elseif (!isset($this->_appCapabilities)) {
445            $this->_appCapabilities = $GLOBALS['registry']->getApiInstance($this->_app, 'application')->auth;
446        }
447
448        return in_array(Horde_String::lower($capability), $this->_appCapabilities);
449    }
450
451    /**
452     * Returns the named parameter for the current auth driver.
453     *
454     * @param string $param  The parameter to fetch.
455     *
456     * @return string  The parameter's value, or null if it doesn't exist.
457     */
458    public function getParam($param)
459    {
460        return $this->_base
461            ? $this->_base->getParam($param)
462            : parent::getParam($param);
463    }
464
465    /**
466     * Retrieve internal credential value(s).
467     *
468     * @param mixed $name  The credential value to get. If null, will return
469     *                     the entire credential list. Valid names:
470     *   - change: (boolean) Do credentials need to be changed?
471     *   - credentials: (array) The credentials needed to authenticate.
472     *   - expire: (integer) UNIX timestamp of the credential expiration date.
473     *   - userId: (string) The user ID.
474     *
475     * @return mixed  Return the credential information, or null if the
476     *                credential doesn't exist.
477     */
478    public function getCredential($name = null)
479    {
480        return $this->_base
481            ? $this->_base->getCredential($name)
482            : parent::getCredential($name);
483    }
484
485    /**
486     * Set internal credential value.
487     *
488     * @param string $name  The credential name to set.
489     * @param mixed $value  The credential value to set. See getCredential()
490     *                      for the list of valid credentials/types.
491     */
492    public function setCredential($name, $value)
493    {
494        if ($this->_base) {
495            $this->_base->setCredential($name, $value);
496        } else {
497            parent::setCredential($name, $value);
498        }
499    }
500
501    /**
502     * Sets the error message for an invalid authentication.
503     *
504     * @param string $type  The type of error (Horde_Auth::REASON_* constant).
505     * @param string $msg   The error message/reason for invalid
506     *                      authentication.
507     */
508    public function setError($type, $msg = null)
509    {
510        if ($this->_base) {
511            $this->_base->setError($type, $msg);
512        } else {
513            parent::setError($type, $msg);
514        }
515    }
516
517    /**
518     * Returns the error type or message for an invalid authentication.
519     *
520     * @param boolean $msg  If true, returns the message string (if set).
521     *
522     * @return mixed  Error type, error message (if $msg is true) or false
523     *                if entry doesn't exist.
524     */
525    public function getError($msg = false)
526    {
527        return $this->_base
528            ? $this->_base->getError($msg)
529            : parent::getError($msg);
530    }
531
532    /**
533     * Returns information on what login parameters to display on the login
534     * screen.
535     *
536     * @return array  An array with the following keys:
537     * <pre>
538     * 'js_code' - (array) A list of javascript statements to be included.
539     * 'js_files' - (array) A list of javascript files to be included.
540     * 'params' - (array) A list of parameters to display on the login screen.
541     *            Each entry is an array with the following entries:
542     *            'label' - (string) The label of the entry.
543     *            'type' - (string) 'select', 'text', or 'password'.
544     *            'value' - (mixed) If type is 'text' or 'password', the
545     *                      text to insert into the field by default. If type
546     *                      is 'select', an array with they keys as the
547     *                      option values and an array with the following keys:
548     *                      'hidden' - (boolean) If true, the option will be
549     *                                 hidden.
550     *                      'name' - (string) The option label.
551     *                      'selected' - (boolean) If true, will be selected
552     *                                   by default.
553     * </pre>
554     *
555     * @throws Horde_Exception
556     */
557    public function getLoginParams()
558    {
559        return ($this->_base && method_exists($this->_base, 'getLoginParams'))
560            ? $this->_base->getLoginParams()
561            : $GLOBALS['registry']->callAppMethod($this->_app, 'authLoginParams', array('noperms' => true));
562    }
563
564    /**
565     * Indicate whether the application requires authentication.
566     *
567     * @return boolean  True if application requires authentication.
568     */
569    public function requireAuth()
570    {
571        return !$this->_base &&
572               ($this->hasCapability('authenticate') ||
573                $this->hasCapability('transparent'));
574    }
575
576    /**
577     * Runs the pre/post-authenticate hook and parses the result.
578     *
579     * @param string $userId      The userId who has been authorized.
580     * @param array $credentials  The credentials of the user.
581     * @param string $type        Either 'preauthenticate' or
582     *                            'postauthenticate'.
583     * @param string $method      The triggering method (preauthenticate only).
584     *                            Either 'authenticate' or 'transparent'.
585     *
586     * @return array  Two element array, $userId and $credentials.
587     * @throws Horde_Auth_Exception
588     */
589    public function runHook($userId, $credentials, $type, $method = null)
590    {
591        if (!is_array($credentials)) {
592            $credentials = empty($credentials)
593                ? array()
594                : array($credentials);
595        }
596
597        $ret_array = array($userId, $credentials);
598
599        if ($type == 'preauthenticate') {
600            $credentials['authMethod'] = $method;
601        }
602
603        try {
604            $result = $GLOBALS['injector']->getInstance('Horde_Core_Hooks')
605                ->callHook($type, $this->_app, array($userId, $credentials));
606        } catch (Horde_Exception_HookNotSet $e) {
607            return $ret_array;
608        } catch (Horde_Exception $e) {
609            throw new Horde_Auth_Exception($e);
610        }
611
612        unset($credentials['authMethod']);
613
614        if ($result === false) {
615            if ($this->getError() != Horde_Auth::REASON_MESSAGE) {
616                $this->setError(Horde_Auth::REASON_FAILED);
617            }
618            throw new Horde_Auth_Exception($type . ' hook failed');
619        }
620
621        if (is_array($result)) {
622            if ($type == 'postauthenticate') {
623                $ret_array[1] = $result;
624            } else {
625                if (isset($result['userId'])) {
626                    $ret_array[0] = $result['userId'];
627                }
628
629                if (isset($result['credentials'])) {
630                    $ret_array[1] = $result['credentials'];
631                }
632            }
633        }
634
635        return $ret_array;
636    }
637
638    /**
639     * Set authentication credentials in the Horde session.
640     *
641     * @return boolean  True on success, false on failure.
642     */
643    protected function _setAuth()
644    {
645        global $registry;
646
647        if ($registry->isAuthenticated(array('app' => $this->_app, 'notransparent' => true))) {
648            return true;
649        }
650
651        /* Grab the current language before we destroy the session. */
652        $language = $registry->preferredLang();
653
654        /* Destroy any existing session on login and make sure to use a
655         * new session ID, to avoid session fixation issues. */
656        if (($userId = $registry->getAuth()) === false) {
657            $GLOBALS['session']->clean();
658            $userId = $this->getCredential('userId');
659        }
660
661        $credentials = $this->getCredential('credentials');
662
663        try {
664            list(,$credentials) = $this->runHook($userId, $credentials, 'postauthenticate');
665        } catch (Horde_Auth_Exception $e) {
666            return false;
667        }
668
669        $registry->setAuth($userId, $credentials, array(
670            'app' => $this->_app,
671            'change' => $this->getCredential('change'),
672            'language' => $language
673        ));
674
675        /* Only set the view mode on initial authentication */
676        if (!$GLOBALS['session']->exists('horde', 'view')) {
677            $this->_setView();
678        }
679
680        if ($this->_base &&
681            isset($GLOBALS['notification']) &&
682            ($expire = $this->_base->getCredential('expire'))) {
683            $toexpire = ($expire - time()) / 86400;
684            $GLOBALS['notification']->push(sprintf(Horde_Core_Translation::ngettext("%d day until your password expires.", "%d days until your password expires.", $toexpire), $toexpire), 'horde.warning');
685        }
686
687        return true;
688    }
689
690    /**
691     * Sets the default global view mode in the horde session. This can be
692     * checked by applications, and overridden if desired. Also sets a cookie
693     * to remember the last view selection if applicable.
694     */
695    protected function _setView()
696    {
697        global $conf, $browser, $notification, $registry;
698
699        $mode = $this->_view;
700
701        if (empty($conf['user']['force_view'])) {
702            if (empty($conf['user']['select_view'])) {
703                $mode = 'auto';
704            } else {
705                /* 'auto' is default, so don't store in cookie. */
706                setcookie(
707                    'default_horde_view',
708                    ($mode == 'auto') ? '' : $mode,
709                    time() + (($mode == 'auto') ? -3600 : (30 * 86400)),
710                    $conf['cookie']['path'],
711                    $conf['cookie']['domain']
712                );
713            }
714        } else {
715            // Forcing mode as per config.
716            $mode = $conf['user']['force_view'];
717        }
718
719        /* $mode now contains the user's preference for view based on the
720         * login screen parameters and configuration. */
721        switch ($mode) {
722        case 'auto':
723            if ($browser->hasFeature('ajax')) {
724                $mode = $browser->isMobile()
725                    ? 'smartmobile'
726                    : 'dynamic';
727            } else {
728                $mode = $browser->isMobile()
729                    ? 'mobile'
730                    : 'basic';
731            }
732            break;
733
734        case 'basic':
735            if (!$browser->hasFeature('javascript')) {
736                $notification->push(Horde_Core_Translation::t("Your browser does not support javascript. Using minimal view instead."), 'horde.warning');
737                $mode = 'mobile';
738            }
739            break;
740
741        case 'dynamic':
742            if (!$browser->hasFeature('ajax')) {
743                if ($browser->hasFeature('javascript')) {
744                    $notification->push(Horde_Core_Translation::t("Your browser does not support the dynamic view. Using basic view instead."), 'horde.warning');
745                    $mode = 'basic';
746                } else {
747                    $notification->push(Horde_Core_Translation::t("Your browser does not support the dynamic view. Using minimal view instead."), 'horde.warning');
748                    $mode = 'mobile';
749                }
750            }
751            break;
752
753        case 'smartmobile':
754            if (!$browser->hasFeature('ajax')) {
755                $notification->push(Horde_Core_Translation::t("Your browser does not support the dynamic view. Using minimal view instead."), 'horde.warning');
756                $mode = 'mobile';
757            }
758            break;
759
760        case 'mobile':
761        default:
762            $mode = 'mobile';
763            break;
764        }
765
766        if (($browser->getBrowser() == 'msie') &&
767            ($browser->getMajor() < 8) &&
768            ($mode != 'mobile')) {
769            $notification->push(Horde_Core_Translation::t("You are using an old, unsupported version of Internet Explorer. You need at least Internet Explorer 8. If you already run IE8 or higher, disable the Compatibility View. Minimal view will be used until you upgrade your browser."));
770            $mode = 'mobile';
771        }
772
773        $registry_map = array(
774            'basic' => Horde_Registry::VIEW_BASIC,
775            'dynamic' => Horde_Registry::VIEW_DYNAMIC,
776            'mobile' => Horde_Registry::VIEW_MINIMAL,
777            'smartmobile' => Horde_Registry::VIEW_SMARTMOBILE
778        );
779
780        $this->_view = $mode;
781        $registry->setView($registry_map[$mode]);
782    }
783
784}
785