Files
liceo-ariosto/administrator/components/com_users/src/Controller/CaptiveController.php
2025-06-17 11:53:18 +02:00

246 lines
9.0 KiB
PHP

<?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|null $factory MVC Factory for the com_users component
* @param CMSApplication|null $app CMS application object
* @param Input|null $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 $e) {
// 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);
}
}