246 lines
9.0 KiB
PHP
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);
|
|
}
|
|
}
|