<?php

/**
 * @file classes/security/Validation.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2003-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class Validation
 *
 * @ingroup security
 *
 * @brief Class providing user validation/authentication operations.
 */

namespace PKP\security;

use APP\core\Application;
use APP\facades\Repo;
use PKP\config\Config;
use PKP\core\Core;
use PKP\core\PKPString;
use PKP\db\DAORegistry;
use PKP\session\SessionDAO;
use PKP\session\SessionManager;
use PKP\site\Site;
use PKP\site\SiteDAO;
use PKP\user\User;
use PKP\validation\ValidatorFactory;

class Validation
{
    public const ADMINISTRATION_PROHIBITED = 0;
    public const ADMINISTRATION_PARTIAL = 1;
    public const ADMINISTRATION_FULL = 2;

    public const AUTH_KEY_USERNAME = 1;
    public const AUTH_KEY_EMAIL = 2;

    /**
     * Authenticate user credentials and mark the user as logged in in the current session.
     *
     * @param string $username
     * @param string $password unencrypted password
     * @param string $reason reference to string to receive the reason an account was disabled; null otherwise
     * @param bool $remember remember a user's session past the current browser session
     *
     * @return ?User the User associated with the login credentials, or false if the credentials are invalid
     */
    public static function login($username, $password, &$reason, $remember = false)
    {
        $reason = null;
        $authKey = static::AUTH_KEY_USERNAME;

        if (ValidatorFactory::make(['email' => $username], ['email' => 'email'])->passes()) {
            $user = Repo::user()->getByEmail($username, true);
            $authKey = static::AUTH_KEY_EMAIL;
        } else{
            $user = Repo::user()->getByUsername($username, true);
        }
        
        if (!isset($user)) {
            // User does not exist
            return false;
        }

        // Validate against user database
        $rehash = null;
        if (!self::verifyPassword($username, $password, $user->getPassword(), $rehash)) {
            return false;
        }

        if (!empty($rehash)) {
            // update to new hashing algorithm
            $user->setPassword($rehash);
        }

        return self::registerUserSession($user, $reason, $remember, $authKey);
    }

    /**
     * Verify if the input password is correct
     *
     * @param string $username the string username
     * @param string $password the plaintext password
     * @param string $hash the password hash from the database
     * @param string &$rehash if password needs rehash, this variable is used
     *
     * @return bool
     */
    public static function verifyPassword($username, $password, $hash, &$rehash)
    {
        if (password_needs_rehash($hash, PASSWORD_BCRYPT)) {
            // update to new hashing algorithm
            $oldHash = self::encryptCredentials($username, $password, false, true);

            if ($oldHash === $hash) {
                // update hash
                $rehash = self::encryptCredentials($username, $password);

                return true;
            }
        }

        return password_verify($password, $hash);
    }

    /**
     * Mark the user as logged in in the current session.
     *
     * @param User      $user       user to register in the session
     * @param string    $reason     reference to string to receive the reason an account
     *                              was disabled; null otherwise
     * @param bool      $remember   remember a user's session past the current browser session
     * @param int       $authKey    const value of AUTH_KEY_* define auth key(email/username)
     *
     * @return mixed                User or boolean the User associated with the login credentials,
     *                              or false if the credentials are invalid
     */
    public static function registerUserSession($user, &$reason, $remember = false, $authKey = self::AUTH_KEY_USERNAME)
    {
        if (!$user instanceof User) {
            return false;
        }

        if ($user->getDisabled()) {
            // The user has been disabled.
            $reason = $user->getDisabledReason();
            if ($reason === null) {
                $reason = '';
            }
            return false;
        }

        // The user is valid, mark user as logged in in current session
        $sessionManager = SessionManager::getManager();

        // Regenerate session ID first
        $sessionManager->regenerateSessionId();

        $session = $sessionManager->getUserSession();
        $session->setSessionVar('userId', $user->getId());
        $session->setUserId($user->getId());
        $session->setSessionVar('username', $user->getUsername());
        if ($authKey === static::AUTH_KEY_EMAIL) {
            $session->setSessionVar('email', $user->getEmail());
        }
        $session->getCSRFToken(); // Force generation (see issue #2417)
        $session->setRemember($remember);

        if ($remember && Config::getVar('general', 'session_lifetime') > 0) {
            // Update session expiration time
            $sessionManager->updateSessionLifetime(time() + Config::getVar('general', 'session_lifetime') * 86400);
        }

        $user->setDateLastLogin(Core::getCurrentDate());
        Repo::user()->edit($user);

        return $user;
    }

    /**
     * Mark the user as logged out in the current session.
     *
     * @return bool
     */
    public static function logout()
    {
        $sessionManager = SessionManager::getManager();
        $session = $sessionManager->getUserSession();
        $session->unsetSessionVar('userId');
        $session->unsetSessionVar('signedInAs');
        $session->setUserId(null);

        if ($session->getRemember()) {
            $session->setRemember(0);
            $sessionManager->updateSessionLifetime(0);
        }

        $sessionDao = DAORegistry::getDAO('SessionDAO'); /** @var SessionDAO $sessionDao */
        $sessionDao->updateObject($session);

        return true;
    }

    /**
     * Redirect to the login page, appending the current URL as the source.
     *
     * @param string $message Optional name of locale key to add to login page
     */
    public static function redirectLogin($message = null)
    {
        $args = [];

        if (isset($_SERVER['REQUEST_URI'])) {
            $args['source'] = $_SERVER['REQUEST_URI'];
        }
        if ($message !== null) {
            $args['loginMessage'] = $message;
        }

        $request = Application::get()->getRequest();
        $request->redirect(null, 'login', null, null, $args);
    }

    /**
     * Check if a user's credentials are valid.
     *
     * @param string $username username
     * @param string $password unencrypted password
     *
     * @return bool
     */
    public static function checkCredentials($username, $password)
    {
        $user = Repo::user()->getByUsername($username, false);
        if (!$user) {
            return false;
        }

        // Validate against user database
        $rehash = null;
        if (!self::verifyPassword($username, $password, $user->getPassword(), $rehash)) {
            return false;
        }

        if (!empty($rehash)) {
            // update to new hashing algorithm
            $user->setPassword($rehash);

            // save new password hash to database
            Repo::user()->edit($user);
        }

        return true;
    }

    /**
     * Check if a user is authorized to access the specified role in the specified context.
     *
     * @param int $roleId
     * @param int $contextId optional (e.g., for global site admin role), the ID of the context
     *
     * @return bool
     */
    public static function isAuthorized($roleId, $contextId = 0)
    {
        if (!self::isLoggedIn()) {
            return false;
        }

        if ($contextId === -1) {
            // Get context ID from request
            $request = Application::get()->getRequest();
            $context = $request->getContext();
            $contextId = $context == null ? 0 : $context->getId();
        }

        $sessionManager = SessionManager::getManager();
        $session = $sessionManager->getUserSession();
        $user = $session->getUser();

        $roleDao = DAORegistry::getDAO('RoleDAO'); /** @var RoleDAO $roleDao */
        return $roleDao->userHasRole($contextId, $user->getId(), $roleId);
    }

    /**
     * Encrypt user passwords for database storage.
     * The username is used as a unique salt to make dictionary
     * attacks against a compromised database more difficult.
     *
     * @param string $username username (kept for backwards compatibility)
     * @param string $password unencrypted password
     * @param string $encryption optional encryption algorithm to use, defaulting to the value from the site configuration
     * @param bool $legacy if true, use legacy hashing technique for backwards compatibility
     *
     * @return string encrypted password
     */
    public static function encryptCredentials($username, $password, $encryption = false, $legacy = false)
    {
        if ($legacy) {
            $valueToEncrypt = $username . $password;

            if ($encryption == false) {
                $encryption = Config::getVar('security', 'encryption');
            }

            switch ($encryption) {
                case 'sha1':
                    if (function_exists('sha1')) {
                        return sha1($valueToEncrypt);
                    }
                    // no break
                case 'md5':
                default:
                    return md5($valueToEncrypt);
            }
        } else {
            return password_hash($password, PASSWORD_BCRYPT);
        }
    }

    /**
     * Generate a random password.
     * Assumes the random number generator has already been seeded.
     *
     * @param int $length the length of the password to generate (default is site minimum)
     *
     * @return string
     */
    public static function generatePassword($length = null)
    {
        if (!$length) {
            $siteDao = DAORegistry::getDAO('SiteDAO'); /** @var SiteDAO $siteDao */
            $site = $siteDao->getSite(); /** @var Site $site */
            $length = $site->getMinPasswordLength();
        }
        $letters = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
        $numbers = '23456789';

        $password = '';
        for ($i = 0; $i < $length; $i++) {
            $password .= random_int(1, 4) == 4 ? $numbers[random_int(0, strlen($numbers) - 1)] : $letters[random_int(0, strlen($letters) - 1)];
        }
        return $password;
    }

    /**
     * Generate a hash value to use for confirmation to reset a password.
     *
     * @param int $userId
     * @param int $expiry timestamp when hash expires, defaults to CURRENT_TIME + RESET_SECONDS
     *
     * @return string (boolean false if user is invalid)
     */
    public static function generatePasswordResetHash($userId, $expiry = null)
    {
        if (($user = Repo::user()->get($userId)) == null) {
            // No such user
            return false;
        }
        // create hash payload
        $salt = Config::getVar('security', 'salt');

        if (empty($expiry)) {
            $expires = (int) Config::getVar('security', 'reset_seconds', 7200);
            $expiry = time() + $expires;
        }

        // use last login time to ensure the hash changes when they log in
        $data = $user->getUsername() . $user->getPassword() . $user->getDateLastLogin() . $expiry;

        // generate hash and append expiry timestamp
        $algos = hash_algos();

        foreach (['sha256', 'sha1', 'md5'] as $algo) {
            if (in_array($algo, $algos)) {
                return hash_hmac($algo, $data, $salt) . ':' . $expiry;
            }
        }

        // fallback to MD5
        return md5($data . $salt) . ':' . $expiry;
    }

    /**
     * Check if provided password reset hash is valid.
     *
     * @param int $userId
     * @param string $hash
     *
     * @return bool
     */
    public static function verifyPasswordResetHash($userId, $hash)
    {
        // append ":" to ensure the explode results in at least 2 elements
        [, $expiry] = explode(':', $hash . ':');

        if (empty($expiry) || ((int) $expiry < time())) {
            // expired
            return false;
        }

        return ($hash === self::generatePasswordResetHash($userId, $expiry));
    }

    /**
     * Suggest a username given the first and last names.
     *
     * @param string $givenName
     * @param string $familyName
     *
     * @return string
     */
    public static function suggestUsername($givenName, $familyName = null)
    {
        $name = $givenName;
        if (!empty($familyName)) {
            $initial = PKPString::substr($givenName, 0, 1);
            $name = $initial . $familyName;
        }

        $suggestion = PKPString::regexp_replace('/[^a-zA-Z0-9_-]/', '', \Stringy\Stringy::create($name)->toAscii()->toLowerCase());
        for ($i = ''; Repo::user()->getByUsername($suggestion . $i, true); $i++);
        return $suggestion . $i;
    }

    /**
     * Check if the user is logged in.
     *
     * @return bool
     */
    public static function isLoggedIn()
    {
        if (!SessionManager::hasSession()) {
            return false;
        }

        $sessionManager = SessionManager::getManager();
        $session = $sessionManager->getUserSession();
        return !!$session->getUserId();
    }

    /**
     * Check if the user is logged in as a different user. Returns the original user ID or null
     */
    public static function loggedInAs(): ?int
    {
        if (!SessionManager::hasSession()) {
            return null;
        }
        $sessionManager = SessionManager::getManager();
        $session = $sessionManager->getUserSession();
        $userId = $session->getSessionVar('signedInAs');

        return $userId ? (int) $userId : null;
    }

    /**
     * Check if the user is logged in as a different user.
     *
     *
     * @deprecated 3.4
     */
    public static function isLoggedInAs(): bool
    {
        return (bool) static::loggedInAs();
    }

    /**
     * Shortcut for checking authorization as site admin.
     *
     * @return bool
     */
    public static function isSiteAdmin()
    {
        return self::isAuthorized(Role::ROLE_ID_SITE_ADMIN);
    }

    /**
     * Check whether a user is allowed to administer another user.
     *
     * @param int $administeredUserId User ID of user to potentially administer
     * @param int $administratorUserId User ID of user who wants to do the administrating
     *
     * @return bool True IFF the administration operation is permitted
     *
     * @deprecated 3.4 Use the method getAdministrationLevel and checked against the ADMINISTRATION_* constants
     */
    public static function canAdminister($administeredUserId, $administratorUserId)
    {
        $roleDao = DAORegistry::getDAO('RoleDAO'); /** @var RoleDAO $roleDao */

        // You can administer yourself
        if ($administeredUserId == $administratorUserId) {
            return true;
        }

        // You cannot administer administrators
        if ($roleDao->userHasRole(\PKP\core\PKPApplication::CONTEXT_SITE, $administeredUserId, Role::ROLE_ID_SITE_ADMIN)) {
            return false;
        }

        // Otherwise, administrators can administer everyone
        if ($roleDao->userHasRole(\PKP\core\PKPApplication::CONTEXT_SITE, $administratorUserId, Role::ROLE_ID_SITE_ADMIN)) {
            return true;
        }

        // Check for administered user group assignments in other contexts
        // that the administrator user doesn't have a manager role in.
        $userGroups = Repo::userGroup()->userUserGroups($administeredUserId);
        foreach ($userGroups as $userGroup) {
            if ($userGroup->getContextId() != \PKP\core\PKPApplication::CONTEXT_SITE && !$roleDao->userHasRole($userGroup->getContextId(), $administratorUserId, Role::ROLE_ID_MANAGER)) {
                // Found an assignment: disqualified.
                return false;
            }
        }

        // Make sure the administering user has a manager role somewhere
        $foundManagerRole = false;
        $roles = $roleDao->getByUserId($administratorUserId);
        foreach ($roles as $role) {
            if ($role->getRoleId() == Role::ROLE_ID_MANAGER) {
                $foundManagerRole = true;
            }
        }
        if (!$foundManagerRole) {
            return false;
        }

        // There were no conflicting roles. Permit administration.
        return true;
    }

    /**
     * Get the user's administration level
     *
     * @param int   $administeredUserId     User ID of user to potentially administer
     * @param int   $administratorUserId    User ID of user who wants to do the administrating
     * @param int   $contextId              The journal/context Id
     *
     * @return int The authorized administration level
     */
    public static function getAdministrationLevel(int $administeredUserId, int $administratorUserId, ?int $contextId = null): int
    {
        // You can administer yourself
        if ($administeredUserId == $administratorUserId) {
            return self::ADMINISTRATION_FULL;
        }

        $filteredSiteAdminUserGroups = Repo::userGroup()
            ->getCollector()
            ->filterByContextIds([\PKP\core\PKPApplication::CONTEXT_SITE])
            ->filterByRoleIds([Role::ROLE_ID_SITE_ADMIN]);

        // You cannot administer administrators
        if ($filteredSiteAdminUserGroups->filterByUserIds([$administeredUserId])->getCount() > 0) {
            return self::ADMINISTRATION_PROHIBITED;
        }

        // Otherwise, administrators can administer everyone
        if ($filteredSiteAdminUserGroups->filterByUserIds([$administratorUserId])->getCount() > 0) {
            return self::ADMINISTRATION_FULL;
        }

        // Make sure the administering user has a manager role somewhere
        $roleManagerCount = Repo::userGroup()
            ->getCollector()
            ->filterByUserIds([$administratorUserId])
            ->filterByRoleIds([Role::ROLE_ID_MANAGER])
            ->getCount();

        if ($roleManagerCount <= 0) {
            return self::ADMINISTRATION_PROHIBITED;
        }

        $administeredUserAssignedGroupIds = Repo::userGroup()
            ->getCollector()
            ->filterByUserIds([$administeredUserId])
            ->getMany()
            ->map(fn ($userGroup) => $userGroup->getContextId())
            ->sort()
            ->toArray();

        $administratorUserAssignedGroupIds = Repo::userGroup()
            ->getCollector()
            ->filterByUserIds([$administratorUserId])
            ->filterByRoleIds([Role::ROLE_ID_MANAGER])
            ->getMany()
            ->map(fn ($userGroup) => $userGroup->getContextId())
            ->sort()
            ->toArray();

        // Check for administered user group assignments in other contexts
        // that the administrator user doesn't have a manager role in.
        if (collect($administeredUserAssignedGroupIds)->diff($administratorUserAssignedGroupIds)->count() > 0) {
            // Found an assignment: disqualified.
            // But also determine if a partial administrate is allowed
            // if the Administrator User is a Journal Manager in the current context
            if ($contextId !== null &&
                Repo::userGroup()
                    ->getCollector()
                    ->filterByContextIds([$contextId])
                    ->filterByUserIds([$administratorUserId])
                    ->filterByRoleIds([Role::ROLE_ID_MANAGER])
                    ->getCount()) {
                return self::ADMINISTRATION_PARTIAL;
            }
            return self::ADMINISTRATION_PROHIBITED;
        }

        // There were no conflicting roles. Permit administration.
        return self::ADMINISTRATION_FULL;
    }
}

if (!PKP_STRICT_MODE) {
    class_alias('\PKP\security\Validation', '\Validation');
}
