<?php

/**
 * @package     Joomla.Plugin
 * @subpackage  Authentication.cookie
 *
 * @copyright   (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Plugin\Authentication\Cookie\Extension;

use Joomla\CMS\Authentication\Authentication;
use Joomla\CMS\Event\Privacy\CollectCapabilitiesEvent;
use Joomla\CMS\Event\User\AfterLoginEvent;
use Joomla\CMS\Event\User\AfterLogoutEvent;
use Joomla\CMS\Event\User\AuthenticationEvent;
use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\User\UserFactoryAwareTrait;
use Joomla\CMS\User\UserHelper;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Event\SubscriberInterface;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Joomla Authentication plugin
 *
 * @since  3.2
 * @note   Code based on http://jaspan.com/improved_persistent_login_cookie_best_practice
 *         and http://fishbowl.pastiche.org/2004/01/19/persistent_login_cookie_best_practice/
 */
final class Cookie extends CMSPlugin implements SubscriberInterface
{
    use DatabaseAwareTrait;
    use UserFactoryAwareTrait;

    /**
     * Returns an array of events this subscriber will listen to.
     *
     * @return  array
     *
     * @since   5.2.0
     */
    public static function getSubscribedEvents(): array
    {
        return [
            'onPrivacyCollectAdminCapabilities' => 'onPrivacyCollectAdminCapabilities',
            'onUserAuthenticate'                => 'onUserAuthenticate',
            'onUserAfterLogin'                  => 'onUserAfterLogin',
            'onUserAfterLogout'                 => 'onUserAfterLogout',
        ];
    }

    /**
     * Reports the privacy related capabilities for this plugin to site administrators.
     *
     * @return  void
     *
     * @since   3.9.0
     */
    public function onPrivacyCollectAdminCapabilities(CollectCapabilitiesEvent $event): void
    {
        $this->loadLanguage();

        $event->addResult([
            $this->getApplication()->getLanguage()->_('PLG_AUTHENTICATION_COOKIE') => [
                $this->getApplication()->getLanguage()->_('PLG_AUTHENTICATION_COOKIE_PRIVACY_CAPABILITY_COOKIE'),
            ],
        ]);
    }

    /**
     * This method should handle any authentication and report back to the subject
     *
     * @param   AuthenticationEvent  $event    Authentication event
     *
     * @return  void
     *
     * @since   3.2
     */
    public function onUserAuthenticate(AuthenticationEvent $event): void
    {
        $app = $this->getApplication();

        // No remember me for admin
        if ($app->isClient('administrator')) {
            return;
        }

        // Get cookie
        $cookieName  = 'joomla_remember_me_' . UserHelper::getShortHashedUserAgent();
        $cookieValue = $app->getInput()->cookie->get($cookieName);

        // Try with old cookieName (pre 3.6.0) if not found
        if (!$cookieValue) {
            $cookieName  = UserHelper::getShortHashedUserAgent();
            $cookieValue = $app->getInput()->cookie->get($cookieName);
        }

        if (!$cookieValue) {
            return;
        }

        $cookieArray = explode('.', $cookieValue);

        // Check for valid cookie value
        if (\count($cookieArray) !== 2) {
            // Destroy the cookie in the browser.
            $app->getInput()->cookie->set($cookieName, '', 1, $app->get('cookie_path', '/'), $app->get('cookie_domain', ''));
            Log::add('Invalid cookie detected.', Log::WARNING, 'error');

            return;
        }

        $response       = $event->getAuthenticationResponse();
        $response->type = 'Cookie';

        // Filter series since we're going to use it in the query
        $filter = new InputFilter();
        $series = $filter->clean($cookieArray[1], 'ALNUM');
        $now    = time();

        // Remove expired tokens
        $db    = $this->getDatabase();
        $query = $db->getQuery(true)
            ->delete($db->quoteName('#__user_keys'))
            ->where($db->quoteName('time') . ' < :now')
            ->bind(':now', $now);

        try {
            $db->setQuery($query)->execute();
        } catch (\RuntimeException $e) {
            // We aren't concerned with errors from this query, carry on
        }

        // Find the matching record if it exists.
        $query = $db->getQuery(true)
            ->select($db->quoteName(['user_id', 'token', 'series', 'time']))
            ->from($db->quoteName('#__user_keys'))
            ->where($db->quoteName('series') . ' = :series')
            ->where($db->quoteName('uastring') . ' = :uastring')
            ->order($db->quoteName('time') . ' DESC')
            ->bind(':series', $series)
            ->bind(':uastring', $cookieName);

        try {
            $results = $db->setQuery($query)->loadObjectList();
        } catch (\RuntimeException $e) {
            $response->status = Authentication::STATUS_FAILURE;

            return;
        }

        if (\count($results) !== 1) {
            // Destroy the cookie in the browser.
            $app->getInput()->cookie->set($cookieName, '', 1, $app->get('cookie_path', '/'), $app->get('cookie_domain', ''));
            $response->status = Authentication::STATUS_FAILURE;

            return;
        }

        // We have a user with one cookie with a valid series and a corresponding record in the database.
        if (!UserHelper::verifyPassword($cookieArray[0], $results[0]->token)) {
            /*
             * This is a real attack!
             * Either the series was guessed correctly or a cookie was stolen and used twice (once by attacker and once by victim).
             * Delete all tokens for this user!
             */
            $query = $db->getQuery(true)
                ->delete($db->quoteName('#__user_keys'))
                ->where($db->quoteName('user_id') . ' = :userid')
                ->bind(':userid', $results[0]->user_id);

            try {
                $db->setQuery($query)->execute();
            } catch (\RuntimeException $e) {
                // Log an alert for the site admin
                Log::add(
                    \sprintf('Failed to delete cookie token for user %s with the following error: %s', $results[0]->user_id, $e->getMessage()),
                    Log::WARNING,
                    'security'
                );
            }

            // Destroy the cookie in the browser.
            $app->getInput()->cookie->set($cookieName, '', 1, $app->get('cookie_path', '/'), $app->get('cookie_domain', ''));

            // Issue warning by email to user and/or admin?
            Log::add(Text::sprintf('PLG_AUTHENTICATION_COOKIE_ERROR_LOG_LOGIN_FAILED', $results[0]->user_id), Log::WARNING, 'security');
            $response->status = Authentication::STATUS_FAILURE;

            return;
        }

        // Make sure there really is a user with this name and get the data for the session.
        $query = $db->getQuery(true)
            ->select($db->quoteName(['id', 'username', 'password']))
            ->from($db->quoteName('#__users'))
            ->where($db->quoteName('username') . ' = :userid')
            ->where($db->quoteName('requireReset') . ' = 0')
            ->bind(':userid', $results[0]->user_id);

        try {
            $result = $db->setQuery($query)->loadObject();
        } catch (\RuntimeException $e) {
            $response->status = Authentication::STATUS_FAILURE;

            return;
        }

        if ($result) {
            // Bring this in line with the rest of the system
            $user = $this->getUserFactory()->loadUserById($result->id);

            // Set response data.
            $response->username = $result->username;
            $response->email    = $user->email;
            $response->fullname = $user->name;
            $response->password = $result->password;
            $response->language = $user->getParam('language');

            // Set response status.
            $response->status        = Authentication::STATUS_SUCCESS;
            $response->error_message = '';

            // Stop event propagation when status is STATUS_SUCCESS
            $event->stopPropagation();
        } else {
            $response->status        = Authentication::STATUS_FAILURE;
            $response->error_message = $app->getLanguage()->_('JGLOBAL_AUTH_NO_USER');
        }
    }

    /**
     * We set the authentication cookie only after login is successfully finished.
     * We set a new cookie either for a user with no cookies or one
     * where the user used a cookie to authenticate.
     *
     * @param   AfterLoginEvent  $event  Login event
     *
     * @return  void
     *
     * @since   3.2
     */
    public function onUserAfterLogin(AfterLoginEvent $event): void
    {
        $app = $this->getApplication();

        // No remember me for admin
        if ($app->isClient('administrator')) {
            return;
        }

        $db      = $this->getDatabase();
        $options = $event->getOptions();

        if (isset($options['responseType']) && $options['responseType'] === 'Cookie') {
            // Logged in using a cookie
            $cookieName = 'joomla_remember_me_' . UserHelper::getShortHashedUserAgent();

            // We need the old data to get the existing series
            $cookieValue = $app->getInput()->cookie->get($cookieName);

            // Try with old cookieName (pre 3.6.0) if not found
            if (!$cookieValue) {
                $oldCookieName = UserHelper::getShortHashedUserAgent();
                $cookieValue   = $app->getInput()->cookie->get($oldCookieName);

                // Destroy the old cookie in the browser
                $app->getInput()->cookie->set($oldCookieName, '', 1, $app->get('cookie_path', '/'), $app->get('cookie_domain', ''));
            }

            $cookieArray = explode('.', $cookieValue);

            // Filter series since we're going to use it in the query
            $filter = new InputFilter();
            $series = $filter->clean($cookieArray[1], 'ALNUM');
        } elseif (!empty($options['remember'])) {
            // Remember checkbox is set
            $cookieName = 'joomla_remember_me_' . UserHelper::getShortHashedUserAgent();

            // Create a unique series which will be used over the lifespan of the cookie
            $unique     = false;
            $errorCount = 0;

            do {
                $series = UserHelper::genRandomPassword(20);
                $query  = $db->getQuery(true)
                    ->select($db->quoteName('series'))
                    ->from($db->quoteName('#__user_keys'))
                    ->where($db->quoteName('series') . ' = :series')
                    ->bind(':series', $series);

                try {
                    $results = $db->setQuery($query)->loadResult();

                    if ($results === null) {
                        $unique = true;
                    }
                } catch (\RuntimeException $e) {
                    $errorCount++;

                    // We'll let this query fail up to 5 times before giving up, there's probably a bigger issue at this point
                    if ($errorCount === 5) {
                        return;
                    }
                }
            } while ($unique === false);
        } else {
            return;
        }

        // Get the parameter values
        $lifetime = $this->params->get('cookie_lifetime', 60) * 24 * 60 * 60;
        $length   = $this->params->get('key_length', 16);

        // Generate new cookie
        $token       = UserHelper::genRandomPassword($length);
        $cookieValue = $token . '.' . $series;

        // Overwrite existing cookie with new value
        $app->getInput()->cookie->set(
            $cookieName,
            $cookieValue,
            time() + $lifetime,
            $app->get('cookie_path', '/'),
            $app->get('cookie_domain', ''),
            $app->isHttpsForced(),
            true
        );

        $query = $db->getQuery(true);

        if (!empty($options['remember'])) {
            $future = (time() + $lifetime);

            // Create new record
            $query
                ->insert($db->quoteName('#__user_keys'))
                ->set($db->quoteName('user_id') . ' = :userid')
                ->set($db->quoteName('series') . ' = :series')
                ->set($db->quoteName('uastring') . ' = :uastring')
                ->set($db->quoteName('time') . ' = :time')
                ->bind(':userid', $options['user']->username)
                ->bind(':series', $series)
                ->bind(':uastring', $cookieName)
                ->bind(':time', $future);
        } else {
            // Update existing record with new token
            $query
                ->update($db->quoteName('#__user_keys'))
                ->where($db->quoteName('user_id') . ' = :userid')
                ->where($db->quoteName('series') . ' = :series')
                ->where($db->quoteName('uastring') . ' = :uastring')
                ->bind(':userid', $options['user']->username)
                ->bind(':series', $series)
                ->bind(':uastring', $cookieName);
        }

        $hashedToken = UserHelper::hashPassword($token);

        $query->set($db->quoteName('token') . ' = :token')
            ->bind(':token', $hashedToken);

        try {
            $db->setQuery($query)->execute();
        } catch (\RuntimeException $e) {
            // We aren't concerned with errors from this query, carry on
        }
    }

    /**
     * This is where we delete any authentication cookie when a user logs out
     *
     * @param   AfterLogoutEvent  $event  Logout event
     *
     * @return  void
     *
     * @since   3.2
     */
    public function onUserAfterLogout(AfterLogoutEvent $event): void
    {
        $app = $this->getApplication();

        // No remember me for admin
        if ($app->isClient('administrator')) {
            return;
        }

        $cookieName  = 'joomla_remember_me_' . UserHelper::getShortHashedUserAgent();
        $cookieValue = $app->getInput()->cookie->get($cookieName);

        // There are no cookies to delete.
        if (!$cookieValue) {
            return;
        }

        $cookieArray = explode('.', $cookieValue);

        // Filter series since we're going to use it in the query
        $filter = new InputFilter();
        $series = $filter->clean($cookieArray[1], 'ALNUM');

        // Remove the record from the database
        $db    = $this->getDatabase();
        $query = $db->getQuery(true)
            ->delete($db->quoteName('#__user_keys'))
            ->where($db->quoteName('series') . ' = :series')
            ->bind(':series', $series);

        try {
            $db->setQuery($query)->execute();
        } catch (\RuntimeException $e) {
            // We aren't concerned with errors from this query, carry on
        }

        // Destroy the cookie
        $app->getInput()->cookie->set($cookieName, '', 1, $app->get('cookie_path', '/'), $app->get('cookie_domain', ''));
    }
}
