<?php

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

namespace Joomla\Component\Users\Administrator\Controller;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Event\MultiFactor\Validate;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserFactoryAwareInterface;
use Joomla\CMS\User\UserFactoryAwareTrait;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\CaptiveModel;
use Joomla\Input\Input;

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

/**
 * Captive Multi-factor Authentication page controller
 *
 * @since 4.2.0
 */
class CaptiveController extends BaseController implements UserFactoryAwareInterface
{
    use UserFactoryAwareTrait;

    /**
     * Public constructor
     *
     * @param   array                 $config   Plugin configuration
     * @param   ?MVCFactoryInterface  $factory  MVC Factory for the com_users component
     * @param   ?CMSApplication       $app      CMS application object
     * @param   ?Input                $input    Joomla CMS input object
     *
     * @since 4.2.0
     */
    public function __construct(array $config = [], ?MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
    {
        parent::__construct($config, $factory, $app, $input);

        $this->registerTask('captive', 'display');
    }

    /**
     * Displays the captive login page
     *
     * @param   boolean        $cachable   Ignored. This page is never cached.
     * @param   boolean|array  $urlparams  Ignored. This page is never cached.
     *
     * @return  void
     * @throws  \Exception
     * @since   4.2.0
     */
    public function display($cachable = false, $urlparams = false): void
    {
        $user = $this->app->getIdentity() ?: $this->getUserFactory()->loadUserById(0);

        // Only allow logged in Users
        if ($user->guest) {
            throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        // Get the view object
        $viewLayout = $this->input->get('layout', 'default', 'string');
        $view       = $this->getView(
            'Captive',
            'html',
            '',
            [
                'base_path' => $this->basePath,
                'layout'    => $viewLayout,
            ]
        );

        $view->document = $this->app->getDocument();

        // If we're already logged in go to the site's home page
        if ((int) $this->app->getSession()->get('com_users.mfa_checked', 0) === 1) {
            $url = Route::_('index.php?option=com_users&task=methods.display', false);

            $this->setRedirect($url);
        }

        // Pass the model to the view
        /** @var CaptiveModel $model */
        $model = $this->getModel('Captive');
        $view->setModel($model, true);

        /** @var BackupcodesModel $codesModel */
        $codesModel = $this->getModel('Backupcodes');
        $view->setModel($codesModel, false);

        try {
            // Suppress all modules on the page except those explicitly allowed
            $model->suppressAllModules();
        } catch (\Exception) {
            // If we can't kill the modules we can still survive.
        }

        // Pass the MFA record ID to the model
        $recordId = $this->input->getInt('record_id', null);
        $model->setState('record_id', $recordId);

        // Do not go through $this->display() because it overrides the model.
        $view->display();
    }

    /**
     * Validate the MFA code entered by the user
     *
     * @param   bool   $cachable         Ignored. This page is never cached.
     * @param   array  $urlparameters    Ignored. This page is never cached.
     *
     * @return  void
     * @throws  \Exception
     * @since   4.2.0
     */
    public function validate($cachable = false, $urlparameters = [])
    {
        // CSRF Check
        $this->checkToken($this->input->getMethod());

        // Get the MFA parameters from the request
        $recordId  = $this->input->getInt('record_id', null);
        $code      = $this->input->get('code', null, 'raw');
        /** @var CaptiveModel $model */
        $model = $this->getModel('Captive');

        // Validate the MFA record
        $model->setState('record_id', $recordId);
        $record = $model->getRecord();

        if (empty($record)) {
            $event = new NotifyActionLog('onComUsersCaptiveValidateInvalidMethod');
            $this->app->getDispatcher()->dispatch($event->getName(), $event);

            throw new \RuntimeException(Text::_('COM_USERS_MFA_INVALID_METHOD'), 500);
        }

        if (!$model->checkTryLimit($record)) {
            // The try limit is reached, show error and return
            $captiveURL = Route::_('index.php?option=com_users&view=captive&task=select', false);
            $message    = Text::_('COM_USERS_MFA_TRY_LIMIT_REACHED');
            $this->setRedirect($captiveURL, $message, 'error');

            $event = new NotifyActionLog('onComUsersCaptiveValidateTryLimitReached');
            $this->app->getDispatcher()->dispatch($event->getName(), $event);

            return;
        }

        // Validate the code
        $user = $this->app->getIdentity() ?: $this->getUserFactory()->loadUserById(0);

        $event   = new Validate($record, $user, $code);
        $results = $this->app
            ->getDispatcher()
            ->dispatch($event->getName(), $event)
            ->getArgument('result', []);

        $isValidCode = false;

        if ($record->method === 'backupcodes') {
            /** @var BackupcodesModel $codesModel */
            $codesModel = $this->getModel('Backupcodes');
            $results    = [$codesModel->isBackupCode($code, $user)];
            /**
             * This is required! Do not remove!
             *
             * There is a store() call below. It saves the in-memory MFA record to the database. That includes the
             * options key which contains the configuration of the Method. For backup codes, these are the actual codes
             * you can use. When we check for a backup code validity we also "burn" it, i.e. we remove it from the
             * options table and save that to the database. However, this DOES NOT update the $record here. Therefore
             * the call to saveRecord() would overwrite the database contents with a record that _includes_ the backup
             * code we had just burned. As a result the single use backup codes end up being multiple use.
             *
             * By doing a getRecord() here, right after we have "burned" any correct backup codes, we resolve this
             * issue. The loaded record will reflect the database contents where the options DO NOT include the code we
             * just used. Therefore the call to store() will result in the correct database state, i.e. the used backup
             * code being removed.
             */
            $record = $model->getRecord();
        }

        $isValidCode = array_reduce(
            $results,
            function (bool $carry, $result) {
                return $carry || \boolval($result);
            },
            false
        );

        if (!$isValidCode) {
            // The code is wrong. Display an error and go back.
            $captiveURL = Route::_('index.php?option=com_users&view=captive&record_id=' . $recordId, false);
            $message    = Text::_('COM_USERS_MFA_INVALID_CODE');
            $this->setRedirect($captiveURL, $message, 'error');

            $event = new NotifyActionLog('onComUsersCaptiveValidateFailed', [$record->title]);
            $this->app->getDispatcher()->dispatch($event->getName(), $event);

            return;
        }

        // Update the Last Used, UA and IP columns
        $jNow = Date::getInstance();

        $record->last_used = $jNow->toSql();
        $record->tries     = 0;
        $record->last_try  = null;
        $record->store();

        // Flag the user as fully logged in
        $session = $this->app->getSession();
        $session->set('com_users.mfa_checked', 1);
        $session->set('com_users.mandatory_mfa_setup', 0);

        // Get the return URL stored by the plugin in the session
        $returnUrl = $session->get('com_users.return_url', '');

        // If the return URL is not set or not internal to this site redirect to the site's front page
        if (empty($returnUrl) || !Uri::isInternal($returnUrl)) {
            $returnUrl = Uri::base();
        }

        $this->setRedirect($returnUrl);

        $event = new NotifyActionLog('onComUsersCaptiveValidateSuccess', [$record->title]);
        $this->app->getDispatcher()->dispatch($event->getName(), $event);
    }
}
