GemsTracker/gemstracker-library

View on GitHub
classes/Gems/User/UserLoader.php

Summary

Maintainability
D
2 days
Test Coverage
F
42%
<?php

/**
 *
 * @package    Gems
 * @subpackage User
 * @author     Matijs de Jong <mjong@magnafacta.nl>
 * @copyright  Copyright (c) 2011 Erasmus MC
 * @license    New BSD License
 */

use Gems\User\Group;
use Gems\User\TwoFactor\TwoFactorAuthenticatorInterface;

/**
 * Loads users.
 *
 * @package    Gems
 * @subpackage User
 * @copyright  Copyright (c) 2011 Erasmus MC
 * @license    New BSD License
 * @since      Class available since version 1.4.4
 */
class Gems_User_UserLoader extends \Gems_Loader_TargetLoaderAbstract
{
    /**
     * The org ID for no organization
     */
    const SYSTEM_NO_ORG  = -1;

    /**
     * The user id used for the project user
     */
    const SYSTEM_USER_ID = 1;

    /**
     * User class constants
     */
    const USER_CONSOLE    = 'ConsoleUser';
    const USER_NOLOGIN    = 'NoLogin';
    const USER_LDAP       = 'LdapUser';
    const USER_PROJECT    = 'ProjectUser';
    const USER_RADIUS     = 'RadiusUser';
    const USER_RESPONDENT = 'RespondentUser';
    const USER_STAFF      = 'StaffUser';

    /**
     * When true a user is allowed to login to a different organization than the
     * one that provides an account. See GetUserClassSelect for the possible options
     * but be aware that duplicate accounts could lead to problems. To avoid
     * problems you can always use the organization switch AFTER login.
     *
     * @var boolean
     */
    public $allowLoginOnOtherOrganization = false;

    /**
     * When true a user is allowed to login without specifying an organization
     * See GetUserClassSelect for the possible options
     * but be aware that duplicate accounts could lead to problems. To avoid
     * problems you can always use the organization switch AFTER login.
     *
     * @var boolean
     */
    public $allowLoginOnWithoutOrganization = false;

    /**
     * When true Respondent members can use their e-mail address as login name
     * @var boolean
     */
    public $allowRespondentEmailLogin = false;

    /**
     * When true Staff members can use their e-mail address as login name
     * @var boolean
     */
    public $allowStaffEmailLogin = false;

    /**
     *
     * @var \Zend_Cache_Core
     */
    protected $cache;

    /**
     * Allows sub classes of \Gems_Loader_LoaderAbstract to specify the subdirectory where to look for.
     *
     * @var string $cascade An optional subdirectory where this subclass always loads from.
     */
    protected $cascade = 'User';

    /**
     *
     * @var \Zend_Db_Adapter_Abstract
     */
    protected $db;

    /**
     *
     * @var \Gems_Project_ProjectSettings
     */
    protected $project;

    /**
     *
     * @var \Zend_Session_Namespace
     */
    protected $session;

    /**
     * @var \Zend_Translate_Adapter
     */
    protected $translate;

    /**
     *
     * @var \Gems_Util
     */
    protected $util;

    /**
     * There can be only one, current user that is.
     *
     * @var \Gems_User_User
     */
    protected static $currentUser;

    /**
     * Should be called after answering the request to allow the Target
     * to check if all required registry values have been set correctly.
     *
     * @return boolean False if required values are missing.
     */
    public function checkRegistryRequestsAnswers()
    {
        // Make sure \Gems_User_User gets userLoader variable.
        $extras['userLoader'] = $this;

        // Make sure that this code keeps working when _initSession
        // is removed from GemsEscort
        if (! $this->session instanceof \Zend_Session_Namespace) {
            $this->session = new \Zend_Session_Namespace('gems.' . GEMS_PROJECT_NAME . '.session');

            $idleTimeout = $this->project->getSessionTimeout();

            $this->session->setExpirationSeconds($idleTimeout);

            $extras['session'] = $this->session;
        }

        $this->addRegistryContainer($extras);
    }

    /**
     * Returns a user object, that may be empty if no user exist.
     *
     * @param string $login_name
     * @param int $organization
     * @param string $userClassName
     * @param int $userId The person creating the user.
     * @return \Gems_User_User Newly created
     */
    public function createUser($login_name, $organization, $userClassName, $userId)
    {
        $now = new \MUtil_Db_Expr_CurrentTimestamp();;

        $values['gul_user_class'] = $userClassName;
        $values['gul_can_login']  = 1;
        $values['gul_changed']    = $now;
        $values['gul_changed_by'] = $userId;

        $select = $this->db->select();
        $select->from('gems__user_logins', array('gul_id_user'))
                ->where('gul_login = ?', $login_name)
                ->where('gul_id_organization = ?', $organization)
                ->limit(1);

        // Update class definition if it already exists
        if ($login_id = $this->db->fetchOne($select)) {
            $where = implode(' ', $select->getPart(\Zend_Db_Select::WHERE));
            $this->db->update('gems__user_logins', $values, $where);

        } else {
            $values['gul_login']           = $login_name;
            $values['gul_id_organization'] = $organization;
            $values['gul_created']         = $now;
            $values['gul_created_by']      = $userId;

            $this->db->insert('gems__user_logins', $values);
        }

        return $this->getUser($login_name, $organization);
    }

    /**
     * Makes sure default values are set for a user
     *
     * @param array $values
     * @param \Gems_User_UserDefinitionInterface $definition
     * @param string $defName Optional
     * @return array
     */
    public function ensureDefaultUserValues(array $values, \Gems_User_UserDefinitionInterface $definition, $defName = null)
    {
        if (! isset($values['user_active'])) {
            $values['user_active'] = true;
        }
        if (! isset($values['user_staff'])) {
            $values['user_staff'] = $definition->isStaff();
        }
        if (! isset($values['user_resetkey_valid'])) {
            $values['user_resetkey_valid'] = false;
        }
        if (! isset($values['user_two_factor_key'])) {
            $values['user_two_factor_key'] = null;
        }
        if (! isset($values['user_enable_2factor'])) {
            $values['user_enable_2factor'] = null;
        }


        if ($defName) {
            $values['__user_definition'] = $defName;
        }

        return $values;
    }

    /**
     * Get userclass / description array of available UserDefinitions for respondents
     *
     * @return array
     */
    public function getAvailableRespondentDefinitions()
    {
        $definitions = array(
            self::USER_RESPONDENT => $this->translate->_('Db storage')
        );

        return $definitions;
    }

    /**
     * Get userclass / description array of available UserDefinitions for staff
     *
     * @return array
     */
    public function getAvailableStaffDefinitions()
    {
        $output = array(
            self::USER_STAFF  => $this->translate->_('Db storage'),
            self::USER_RADIUS => $this->translate->_('Radius storage'),
        );

        if ($this->project->getLdapSettings()) {
            $output[self::USER_LDAP] = $this->translate->_('LDAP');
        }
        asort($output);

        return $output;
    }

    /**
     * Returns a change password form for this user
     *
     * @param \Gems_user_User $user
     * @param mixed $args_array \MUtil_Ra::args array for LoginForm initiation.
     * @return \Gems_User_Form_ChangePasswordForm
     */
    public function getChangePasswordForm($user, $args_array = null)
    {
        $args = \MUtil_Ra::args(func_get_args(), array('user' => 'Gems_User_User'));

        $form = $this->_loadClass('Form_ChangePasswordForm', true, array($args));

        return $form;
    }

    /**
     * Get the currently loggin in user
     *
     * @return \Gems_User_User
     */
    public final function getCurrentUser()
    {
        if (! self::$currentUser) {
            if ($this->session->__isset('__user_definition')) {
                $defName = $this->session->__get('__user_definition');

                if (substr($defName, -10, 10) != 'Definition') {
                    $defName .= 'Definition';
                }

                self::$currentUser = $this->_loadClass('User', true, array($this->session, $this->_getClass($defName)));

            } else {
                if (\MUtil_Console::isConsole()) {
                    if (! $this->project->isConsoleAllowed()) {
                        echo "Accessing " . GEMS_PROJECT_NAME . " from the command line is not allowed.\n";
                        exit;
                    }

                    $request = \Zend_Controller_Front::getInstance()->getRequest();

                    if (($request instanceof \MUtil_Controller_Request_Cli) && $request->hasUserLogin()) {
                        $user = $this->getUser($request->getUserName(), $request->getUserOrganization());

                        $authResult = $user->authenticate($request->getUserPassword());
                        if (! $authResult->isValid()) {
                            echo "Invalid user login data.\n";
                            echo implode("\n", $authResult->getMessages());
                            exit;
                        }
                        self::$currentUser = $user;

                    } elseif ($this->project->getConsoleRole()) {
                        // \MUtil_Echo::track($this->request->getUserName(), $this->request->getUserOrganization());
                        self::$currentUser = $this->loadUser(self::USER_CONSOLE, 0, '(system)');
                    }

                }
                if (! self::$currentUser) {
                    self::$currentUser = $this->getUser(null, self::SYSTEM_NO_ORG);
                }
                self::$currentUser->setAsCurrentUser();
           }
        }

        return self::$currentUser;
    }

    /**
     * Returns a group object, initiated from the database or from
     * Group::$_noGroup when the database does not yet exist.
     *
     * @param int $groupId Group id
     * @return \Gems\User\Group
     */
    public function getGroup($groupId)
    {
        static $groups = array();

        if (! isset($groups[$groupId])) {
            $groups[$groupId] = $this->_loadClass('Group', true, array($groupId));
    }

        return $groups[$groupId];
    }

    /**
     *
     * @return array  id => label
     */
    public function getGroupTwoFactorNotSetOptions()
    {
        return [
            Group::NO_TWO_FACTOR_INSIDE_ONLY   => $this->translate->_('Allowed only in optional IP Range'),
            // Group::NO_TWO_FACTOR_SETUP_INSIDE  => $this->translate->_('Only in optional, setup required'),
            // Group::NO_TWO_FACTOR_SETUP_OUTSIDE => $this->translate->_('Allowed in allowed, setup required'),
            Group::NO_TWO_FACTOR_ALLOWED       => $this->translate->_('Allowed in "allowed from" IP Range'),
        ];
    }

    /**
     *
     * @return array  id => label
     */
    public function getGroupTwoFactorSetOptions()
    {
        return [
            Group::TWO_FACTOR_SET_REQUIRED      => $this->translate->_('Always required - even in optional IP Range'),
            Group::TWO_FACTOR_SET_OUTSIDE_ONLY  => $this->translate->_('Required - except in optional IP Range'),
            Group::TWO_FACTOR_SET_DISABLED      => $this->translate->_('Disabled - never ask'),
        ];
    }

    /**
     * Returns a layered login form where user first selects a top organization and then a
     * child organization
     *
     * @param mixed $args_array \MUtil_Ra::args array for LoginForm initiation.
     * @return \Gems_User_Form_LayeredLoginForm
     */
    public function getLayeredLoginForm($args_array = null)
    {
        $args = \MUtil_Ra::args(func_get_args());

        return $this->_loadClass('Form_LayeredLoginForm', true, array($args));
    }

    /**
     * Returns a login form
     *
     * @param mixed $args_array \MUtil_Ra::args array for LoginForm initiation.
     * @return \Gems_User_Form_LoginForm
     */
    public function getLoginForm($args_array = null)
    {
        $args = \MUtil_Ra::args(func_get_args());

        return $this->_loadClass('Form_LoginForm', true, array($args));
    }

    /**
     *
     * @staticvar \Gems\User\LoginStatusTracker $statusTracker
     * @return \Gems\User\LoginStatusTracker
     */
    public function getLoginStatusTracker()
    {
        static $statusTracker;

        if (! $statusTracker) {
            $statusTracker = $this->_loadClass('LoginStatusTracker', true, [$this]);
        }

        return $statusTracker;
    }

    /**
     * @return string[] default array for when no organizations have been created
     */
    public static function getNotOrganizationArray()
    {
        return [self::SYSTEM_NO_ORG => 'create db first'];
    }
    
    /**
     * Returns an organization object, initiated from the database or from
     * self::$_noOrganization when the database does not yet exist.
     *
     * @param int $organizationId Optional, uses current user or url when empty
     * @return \Gems_User_Organization
     */
    public function getOrganization($organizationId = null)
    {
        static $organizations = array();

        if (null === $organizationId) {
            $user = $this->getCurrentUser();

            $organizationId = intval($user->getCurrentOrganizationId());
        }

        if (! isset($organizations[$organizationId])) {
            $organizations[$organizationId] = $this->_loadClass(
                    'Organization',
                    true,
                    array($organizationId, $this->getAvailableStaffDefinitions())
                    );
        }

        return $organizations[$organizationId];
    }

    /**
     * Returns the current organization according to the current site url.
     *
     * @return int An organization id or null
     * @deprecated since version 1.9.1
     */
    public function getOrganizationIdByUrl()
    {
        return null;
    }

    /**
     * Returns the current organization according to the current site url.
     *
     * @static array $urls An array of url => orgId values
     * @return array url => orgId
     * @deprecated since version 1.9.1
     */
    public function getOrganizationUrls()
    {
        return [];
//        static $urls;
//
//        if (! is_array($urls)) {
//            if ($this->cache) {
//                $cacheId = GEMS_PROJECT_NAME . '__' . strtr(get_class($this), '\\/', '__') . '__organizations_url';
//                $urls = $this->cache->load($cacheId);
//            } else {
//                $cacheId = false;
//            }
//
//            // When we don't use cache or cache reports 'false' for a miss or expiration
//            // then try to reload the data
//            if ($cacheId === false || $urls === false) {
//                $urls = array();
//                try {
//                    $data = $this->db->fetchPairs(
//                            "SELECT gor_id_organization, gor_url_base
//                                FROM gems__organizations
//                                WHERE gor_active=1 AND gor_url_base IS NOT NULL"
//                            );
//                } catch (\Zend_Db_Exception $zde) {
//                    // Table might not be filled
//                    $data = array();
//                }
//                foreach ($data as $orgId => $urlsBase) {
//                    foreach (explode(' ', $urlsBase) as $url) {
//                        if ($url) {
//                            $urls[$url] = $orgId;
//                        }
//                    }
//                }
//
//                if ($cacheId) {
//                    $this->cache->save($urls, $cacheId, array('organization', 'organizations'));
//                }
//            }
//            // \MUtil_Echo::track($urls);
//        }
//
//        return $urls;
    }

    /**
     * Get password weakness checker.
     *
     * @return \Gems_User_PasswordChecker
     */
    public function getPasswordChecker()
    {
        return $this->_getClass('passwordChecker');
    }

    /**
     * Returns a reset form for handling both the incoming request and the outgoing reset request
     *
     * @param mixed $args_array \MUtil_Ra::args array for LoginForm initiation.
     * @return \Gems_User_Form_ResetRequestForm
     */
    public function getResetRequestForm($args_array = null)
    {
        $args = \MUtil_Ra::args(func_get_args());

        return $this->_loadClass('Form_ResetRequestForm', true, array($args));
    }

    /**
     * Get TwoFactorAuthenticatorInterface class
     *
     * @return Gems\User\TwoFactor\TwoFactorAuthenticatorInterface
     */
    public function getTwoFactorAuthenticator($className)
    {
        $settings = $this->project->getTwoFactorMethodSettings();

        $authenticatorSettings = null;
        if (isset($settings[$className]) && $settings[$className] != 1) {
            $authenticatorSettings = $settings[$className];
        }

        $object = $this->_loadClass('TwoFactor_' . $className, true, [$authenticatorSettings]);

        if (! $object instanceof TwoFactorAuthenticatorInterface) {
            throw new \Gems_Exception_Coding(sprintf(
                    'The authenticator class %s should be an instance of TwoFactorAuthenticatorInterface.',
                    $className
                    ));
        }

        return $object;
    }

    /**
     * Returns a user object, that may be empty if no user exist.
     *
     * @param string $login_name
     * @param int $currentOrganization
     * @return \Gems_User_User But ! ->isActive when the user does not exist
     */
    public function getUser($login_name, $currentOrganization)
    {
        $user = $this->getUserClass($login_name, $currentOrganization);
        
        if ($this->allowLoginOnWithoutOrganization && (! $currentOrganization)) {
            $user->setCurrentOrganization($user->getBaseOrganizationId());
        } else {
            if (! $currentOrganization) {
                $currentOrganization = self::SYSTEM_NO_ORG;
            }
            // Check: can the user log in as this organization, if not load non-existing user
            if (! $user->isAllowedOrganization($currentOrganization)) {
                $user = $this->loadUser(self::USER_NOLOGIN, $currentOrganization, $login_name);
            }

            $user->setCurrentOrganization($currentOrganization);
        }

        return $user;
    }

    /**
     * Get the user having the reset key specified
     *
     * @param string $resetKey
     * @return \Gems_User_User But ! ->isActive when the user does not exist
     */
    public function getUserByResetKey($resetKey)
    {
        if ((null == $resetKey) || (0 == strlen(trim($resetKey)))) {
            return $this->loadUser(self::USER_NOLOGIN, null, null);
        }

        $select = $this->db->select();
        $select->from('gems__user_passwords', array())
                ->joinLeft('gems__user_logins', 'gup_id_user = gul_id_user', array("gul_user_class", 'gul_id_organization', 'gul_login'))
                ->where('gup_reset_key = ?', $resetKey);

        if ($row = $this->db->fetchRow($select, null, \Zend_Db::FETCH_NUM)) {
            // \MUtil_Echo::track($row);
            return $this->loadUser($row[0], $row[1], $row[2]);
        }

        return $this->loadUser(self::USER_NOLOGIN, null, null);
    }

    /**
     * Get a staff user using the $staff_id
     *
     * @param int $staff_id
     * @return \Gems_User_User But ! ->isActive when the user does not exist
     */
    public function getUserByStaffId($staff_id)
    {
        $data = $this->db->fetchRow("SELECT gsf_login, gsf_id_organization FROM gems__staff WHERE gsf_id_user = ?", $staff_id);

        // \MUtil_Echo::track($data);
        if (false == $data) {
            $data = array('gsf_login' => null, 'gsf_id_organization' => null);
        }

        return $this->getUser($data['gsf_login'], $data['gsf_id_organization']);
    }

    /**
     * Returns the name of the user definition class of this user.
     *
     * @param string $login_name
     * @param int $organization
     * @return \Gems_User_User But ! ->isActive when the user does not exist
     */
    protected function getUserClass($login_name, $organization)
    {
        //First check for project user, as this one can run without a db
        if ((null !== $login_name) && $this->isProjectUser($login_name)) {
            return $this->loadUser(self::USER_PROJECT, $organization, $login_name);
        }


        if (null == $login_name) {
            return $this->loadUser(self::USER_NOLOGIN, $organization, $login_name);
        }

        if (!$this->allowLoginOnWithoutOrganization) {
            if ((null == $organization) || (! intval($organization))) {
                return $this->loadUser(self::USER_NOLOGIN, $organization, $login_name);
            }
        }

        try {
            $select = $this->getUserClassSelect($login_name, $organization);

            if ($row = $this->db->fetchRow($select, null, \Zend_Db::FETCH_NUM)) {
                if ($row[3] == 1 || $this->allowLoginOnOtherOrganization === true) {
                    // \MUtil_Echo::track($row);
                    return $this->loadUser($row[0], $row[1], $row[2]);
                }
            }

        } catch (\Zend_Db_Exception $e) {
            // Intentional fall through
            // \MUtil_Echo::track($e->getMessage());
        }

        return $this->loadUser(self::USER_NOLOGIN, $organization, $login_name);
    }

    /**
     * Returns a select statement to find a corresponding user.
     *
     * @param string $login_name
     * @param int $organization
     * @return \Zend_Db_Select
     */
    protected function getUserClassSelect($login_name, $organization)
    {
        $select = $this->db->select();

        /**
         * tolerance field:
         * 1 - login and organization match
         * 2 - login found in an organization with access to the requested organization
         * 3 - login found in another organization without rights to the requested organiation
         *     (could be allowed due to privilege with rights to ALL organizations)
         */
        $select->from('gems__user_logins', array("gul_user_class", 'gul_id_organization', 'gul_login'))
                ->where('gul_can_login = 1');

        if ($this->allowLoginOnWithoutOrganization && !$organization) {
            $select->columns(new \Zend_Db_Expr('1 AS tolerance'));
        } else {
            $select->from('gems__organizations', array())
                ->columns(new \Zend_Db_Expr(
                        "CASE
                            WHEN gor_id_organization = gul_id_organization THEN 1
                            WHEN gor_accessible_by LIKE CONCAT('%:', gul_id_organization, ':%') THEN 2
                            ELSE 3
                        END AS tolerance"))
                ->where('gor_active = 1')
                ->where('gor_id_organization = ?', $organization)
                ->order('tolerance');
        }
        $wheres[] = $this->db->quoteInto('gul_login = ?', $login_name);
        $isEmail  = \MUtil_String::contains($login_name, '@');

        if ($isEmail && $this->allowStaffEmailLogin) {
            $rows = $this->db->fetchAll(
                    "SELECT gsf_login, gsf_id_organization FROM gems__staff WHERE gsf_email = ?",
                    $login_name
                    );
            if ($rows) {
                foreach ($rows as $row) {
                    $wheres[] = $this->db->quoteInto('gul_login = ? AND ', $row['gsf_login'])
                            . $this->db->quoteInto('gul_id_organization = ?', $row['gsf_id_organization']);
                }
            }
        }
        if ($isEmail && $this->allowRespondentEmailLogin) {
            $rows = $this->db->fetchAll(
                    "SELECT gr2o_patient_nr, gr2o_id_organization FROM gems__respondent2org  "
                    . "INNER JOIN gems__respondents WHERE gr2o_id_user = grs_id_user AND gr2o_email = ?",
                    $login_name
                    );
            if ($rows) {
                foreach ($rows as $row) {
                    $wheres[] = $this->db->quoteInto('gul_login = ? AND ', $row['gr2o_patient_nr'])
                            . $this->db->quoteInto('gul_id_organization = ?', $row['gr2o_id_organization']);
                }
            }
        }
        // Add search fields
        $select->where(new \Zend_Db_Expr('(' . implode(') OR (', $wheres) . ')'));
        // \MUtil_Echo::track($select->__toString());

        return $select;
    }

    /**
     * Retrieve a userdefinition, so we can check it's capabilities without
     * instantiating a user.
     *
     * @param string $userClassName
     * @return \Gems_User_UserDefinitionInterface
     */
    public function getUserDefinition($userClassName)
    {
        $definition = $this->_getClass($userClassName . 'Definition');

        return $definition;
    }

    /**
     * Check: is this user the super user defined
     * in project.ini?
     *
     * @param string $login_name
     * @return boolean
     */
    protected function isProjectUser($login_name)
    {
        return $this->project->getSuperAdminName() == $login_name;
    }

    /**
     * Returns a loaded user object
     *
     * @param string $defName
     * @param int $userOrganization
     * @param string $userName
     * @return \Gems_User_User But ! ->isActive when the user does not exist
     */
    protected function loadUser($defName, $userOrganization, $userName)
    {
        $definition = $this->getUserDefinition($defName);

        $values = $definition->getUserData($userName, $userOrganization);
        // \MUtil_Echo::track($defName, $userName, $userOrganization, $values);

        $values = $this->ensureDefaultUserValues($values, $definition, $defName);
        // \MUtil_Echo::track($values, $userName, $userOrganization, $defName);

        return $this->_loadClass('User', true, array($values, $definition));
    }

    /**
     * Check for password weakness.
     *
     * @param \Gems_User_User $user The user for e.g. name checks
     * @param string $password Or null when you want a report on all the rules for this password.
     * @return mixed String or array of strings containing warning messages
     */
    public function reportPasswordWeakness(\Gems_User_User $user, $password = null)
    {
        return $user->reportPasswordWeakness($password);
    }

    /**
     * Sets a new user as the current user.
     *
     * @param \Gems_User_User $user
     * @return \Gems_User_UserLoader (continuation pattern)
     */
    public function setCurrentUser(\Gems_User_User $user)
    {
        if ($user !== self::$currentUser) {
            $this->unsetCurrentUser();
            self::$currentUser = $user;

            // Update the escort variable used by loader
            if ($escort = GemsEscort::getInstance()) {
                $escort->currentUser = $user;
            }

            // Double check in case this function was used as original
            // start for setting the user.
            if (! $user->isCurrentUser()) {
                $user->setAsCurrentUser(true);
            }
        }

        return $this;
    }

    /**
     * Removes the current user
     *
     * @return \Gems_User_UserLoader (continuation pattern)
     */
    public function unsetCurrentUser()
    {
        // Remove if the currentUser still sees itself as the current user.
        if ((self::$currentUser instanceof \Gems_User_User) && self::$currentUser->isCurrentUser()) {
            self::$currentUser->unsetAsCurrentUser(false);
        }
        self::$currentUser = null;
        return $this;
    }
}