<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

namespace Piwik\Session;

use Piwik\Auth;
use Piwik\AuthResult;
use Piwik\Config;
use Piwik\Container\StaticContainer;
use Piwik\Date;
use Piwik\Plugins\UsersManager\Model as UsersModel;
use Piwik\Session;
use Piwik\Log\LoggerInterface;

/**
 * Validates already authenticated sessions.
 *
 * See {@link \Piwik\Session\SessionFingerprint} for more info.
 */
class SessionAuth implements Auth
{
    /**
     * For tests, since there's no actual session there.
     *
     * @var bool
     */
    private $shouldDestroySession;

    /**
     * @var UsersModel
     */
    private $userModel;

    /**
     * Set internally so it can be queried in FrontController.
     *
     * @var array
     */
    private $user;

    private $tokenAuth;

    /**
     * @var bool
     */
    private $sessionExpired = false;

    public function __construct(?UsersModel $userModel = null, $shouldDestroySession = true)
    {
        $this->userModel = $userModel ?: new UsersModel();
        $this->shouldDestroySession = $shouldDestroySession;
    }

    public function getName()
    {
        return null;
    }

    public function setTokenAuth(
        #[\SensitiveParameter]
        $token_auth
    ) {
        $this->tokenAuth = $token_auth;
    }

    public function getLogin()
    {
        if (isset($this->user['login'])) {
            return $this->user['login'];
        }

        return null;
    }

    public function getTokenAuthSecret()
    {
        return null;
    }

    public function setLogin($login)
    {
        // empty
    }

    public function setPassword(
        #[\SensitiveParameter]
        $password
    ) {
        // empty
    }

    public function setPasswordHash(
        #[\SensitiveParameter]
        $passwordHash
    ) {
        // empty
    }

    public function authenticate()
    {
        $this->sessionExpired = false;
        $sessionFingerprint = new SessionFingerprint();
        $userModel = $this->userModel;

        $this->checkIfSessionFailedToRead();

        if ($this->isExpiredSession($sessionFingerprint)) {
            $sessionFingerprint->clear();
            return $this->makeAuthFailure();
        }

        $userForSession = $sessionFingerprint->getUser();
        if (empty($userForSession)) {
            return $this->makeAuthFailure();
        }

        $user = $userModel->getUser($userForSession);
        if (
            empty($user)
            || $user['login'] !== $userForSession // sanity check in case there's a bug in getUser()
        ) {
            return $this->makeAuthFailure();
        }

        $tsPasswordModified = !empty($user['ts_password_modified']) ? $user['ts_password_modified'] : null;
        if ($this->isSessionStartedBeforePasswordChange($sessionFingerprint, $tsPasswordModified)) {
            $this->destroyCurrentSession($sessionFingerprint);
            return $this->makeAuthFailure();
        }

        $this->updateSessionExpireTime($sessionFingerprint);

        if (
            $this->tokenAuth !== null
            && $this->tokenAuth !== false
            && $this->tokenAuth !== $sessionFingerprint->getSessionTokenAuth()
        ) {
            return $this->makeAuthFailure();
        }

        if ($sessionFingerprint->getSessionTokenAuth()) {
            $tokenAuth = $sessionFingerprint->getSessionTokenAuth();
        } else {
            $tokenAuth = $this->userModel->generateRandomTokenAuth();
        }

        return $this->makeAuthSuccess($user, $tokenAuth);
    }

    private function isSessionStartedBeforePasswordChange(SessionFingerprint $sessionFingerprint, $tsPasswordModified)
    {
        // sanity check, make sure users can still login if the ts_password_modified column does not exist
        if ($tsPasswordModified === null) {
            return false;
        }

        // if the session start time doesn't exist for some reason, log the user out
        $sessionStartTime = $sessionFingerprint->getSessionStartTime();
        if (empty($sessionStartTime)) {
            return true;
        }

        return $sessionStartTime < Date::factory($tsPasswordModified)->getTimestampUTC();
    }

    private function makeAuthFailure()
    {
        return new AuthResult(AuthResult::FAILURE, null, null);
    }

    private function makeAuthSuccess(
        $user,
        #[\SensitiveParameter]
        $tokenAuth
    ) {
        $this->user = $user;
        $this->tokenAuth = $tokenAuth;

        $isSuperUser = (int) $user['superuser_access'];
        $code = $isSuperUser ? AuthResult::SUCCESS_SUPERUSER_AUTH_CODE : AuthResult::SUCCESS;

        return new AuthResult($code, $user['login'], $tokenAuth);
    }

    protected function initNewBlankSession(SessionFingerprint $sessionFingerprint)
    {
        // this user should be using a different session, so generate a new ID
        // NOTE: Zend_Session cannot be used since it will destroy the old
        // session.
        if ($this->shouldDestroySession) {
            session_regenerate_id();
        }

        // regenerating the ID will create a new session w/ a new ID, but will
        // copy over the existing session data. we want the new session for the
        // unauthorized user to be different, so we clear the session fingerprint.
        $sessionFingerprint->clear();
    }

    protected function destroyCurrentSession(SessionFingerprint $sessionFingerprint)
    {
        // Note: Piwik will attempt to create another session in the LoginController
        // when rendering the login form (the nonce for the form is stored in the session).
        // So we can't use Session::destroy() since Zend prohibits starting a new session
        // after session_destroy() is called. Instead we clear the session fingerprint for
        // the existing session and generate a new session. Both the old session &
        // new session should have no stored data.
        $sessionFingerprint->clear();
        if ($this->shouldDestroySession) {
            Session::regenerateId();
        }
    }

    public function getTokenAuth()
    {
        return $this->tokenAuth;
    }

    private function updateSessionExpireTime(SessionFingerprint $sessionFingerprint)
    {
        $sessionParams = session_get_cookie_params();

        // we update the session cookie to make sure expired session cookies are not available client side...
        $sessionCookieLifetime = Config::getInstance()->General['login_cookie_expire'];
        Session::writeCookie(
            session_name(),
            session_id(),
            time() + $sessionCookieLifetime,
            $sessionParams['path'],
            $sessionParams['domain'],
            $sessionParams['secure'],
            $sessionParams['httponly'],
            Session::getSameSiteCookieValue()
        );

        // ...and we also update the expiration time stored server side so we can prevent expired sessions from being reused
        $sessionFingerprint->updateSessionExpirationTime();
    }

    private function isExpiredSession(SessionFingerprint $sessionFingerprint)
    {
        $expirationTime = $sessionFingerprint->getExpirationTime();
        if (empty($expirationTime)) {
            return true;
        }

        $isExpired = Date::now()->getTimestampUTC() > $expirationTime;
        if ($isExpired) {
            $this->sessionExpired = true;
        }
        return $isExpired;
    }

    public function wasSessionExpired(): bool
    {
        return $this->sessionExpired;
    }

    private function checkIfSessionFailedToRead()
    {
        if (Session\SaveHandler\DbTable::$wasSessionToLargeToRead) {
            StaticContainer::get(LoggerInterface::class)->warning(
                "Too much data stored in the session so it could not be read properly. If you were logged out, this is why."
            );
        }
    }
}
