Current File : /home/pacjaorg/public_html/km/libraries/src/Application/MultiFactorAuthenticationHandler.php |
<?php
/**
* Joomla! Content Management System
*
* @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\CMS\Application;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Encrypt\Aes;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Table\User as UserTable;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects
/**
* Implements the code required for integrating with Joomla's Multi-factor Authentication.
*
* Please keep in mind that Joomla's MFA, like any MFA method, is designed to be user-interactive.
* Moreover, it's meant to be used in an HTML- and JavaScript-aware execution environment i.e. a web
* browser, web view or similar.
*
* If your application is designed to work non-interactively (e.g. a JSON API application) or
* outside and HTML- and JavaScript-aware execution environments (e.g. CLI) you MUST NOT use this
* trait. Authentication should be either implicit (e.g. CLI) or using sufficiently secure non-
* interactive methods (tokens, certificates, ...).
*
* Regarding the Joomla CMS itself, only the SiteApplication (frontend) and AdministratorApplication
* (backend) applications use this trait because of this reason. The CLI application is implicitly
* authorised at the highest level, whereas the ApiApplication encourages the use of tokens for
* authentication.
*
* @since 4.2.0
*/
trait MultiFactorAuthenticationHandler
{
/**
* Handle the redirection to the Multi-factor Authentication captive login or setup page.
*
* @return boolean True if we are currently handling a Multi-factor Authentication captive page.
* @throws \Exception
* @since 4.2.0
*/
protected function isHandlingMultiFactorAuthentication(): bool
{
// Multi-factor Authentication checks take place only for logged in users.
try {
$user = $this->getIdentity() ?? null;
} catch (\Exception $e) {
return false;
}
if (!($user instanceof User) || $user->guest) {
return false;
}
// If there is no need for a redirection I must not proceed
if (!$this->needsMultiFactorAuthenticationRedirection()) {
return false;
}
/**
* Automatically migrate from legacy MFA, if needed.
*
* We prefer to do a user-by-user migration instead of migrating everybody on Joomla update
* for practical reasons. On a site with hundreds or thousands of users the migration could
* take several minutes, causing Joomla Update to time out.
*
* Instead, every time we are in a captive Multi-factor Authentication page (captive MFA login
* or captive forced MFA setup) we spend a few milliseconds to check if a migration is
* necessary. If it's necessary, we perform it.
*
* The captive pages don't load any content or modules, therefore the few extra milliseconds
* we spend here are not a big deal. A failed all-users migration which would stop Joomla
* Update dead in its tracks would, however, be a big deal (broken sites). Moreover, a
* migration that has to be initiated by the site owner would also be a big deal — if they
* did not know they need to do it none of their users who had previously enabled MFA would
* now have it enabled!
*
* To paraphrase Otto von Bismarck: programming, like politics, is the art of the possible,
* the attainable -- the art of the next best.
*/
$this->migrateFromLegacyMFA();
// We only kick in when the user has actually set up MFA or must definitely enable MFA.
$userOptions = ComponentHelper::getParams('com_users');
$neverMFAUserGroups = $userOptions->get('neverMFAUserGroups', []);
$forceMFAUserGroups = $userOptions->get('forceMFAUserGroups', []);
$isMFADisallowed = count(
array_intersect(
is_array($neverMFAUserGroups) ? $neverMFAUserGroups : [],
$user->getAuthorisedGroups()
)
) >= 1;
$isMFAMandatory = count(
array_intersect(
is_array($forceMFAUserGroups) ? $forceMFAUserGroups : [],
$user->getAuthorisedGroups()
)
) >= 1;
$isMFADisallowed = $isMFADisallowed && !$isMFAMandatory;
$isMFAPending = $this->isMultiFactorAuthenticationPending();
$session = $this->getSession();
$isNonHtml = $this->input->getCmd('format', 'html') !== 'html';
// Prevent non-interactive (non-HTML) content from being loaded until MFA is validated.
if ($isMFAPending && $isNonHtml) {
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
if ($isMFAPending && !$isMFADisallowed) {
/**
* Saves the current URL as the return URL if all of the following conditions apply
* - It is not a URL to com_users' MFA feature itself
* - A return URL does not already exist, is imperfect or external to the site
*
* If no return URL has been set up and the current URL is com_users' MFA feature
* we will save the home page as the redirect target.
*/
$returnUrl = $session->get('com_users.return_url', '');
if (empty($returnUrl) || !Uri::isInternal($returnUrl)) {
$returnUrl = $this->isMultiFactorAuthenticationPage()
? Uri::base()
: Uri::getInstance()->toString(['scheme', 'user', 'pass', 'host', 'port', 'path', 'query', 'fragment']);
$session->set('com_users.return_url', $returnUrl);
}
// Redirect
$this->redirect(Route::_('index.php?option=com_users&view=captive', false), 307);
}
// If we're here someone just logged in but does not have MFA set up. Just flag him as logged in and continue.
$session->set('com_users.mfa_checked', 1);
// If the user is in a group that requires MFA we will redirect them to the setup page.
if (!$isMFAPending && $isMFAMandatory) {
// First unset the flag to make sure the redirection will apply until they conform to the mandatory MFA
$session->set('com_users.mfa_checked', 0);
// Now set a flag which forces rechecking MFA for this user
$session->set('com_users.mandatory_mfa_setup', 1);
// Then redirect them to the setup page
if (!$this->isMultiFactorAuthenticationPage()) {
$url = Route::_('index.php?option=com_users&view=methods', false);
$this->redirect($url, 307);
}
}
// Do I need to redirect the user to the MFA setup page after they have fully logged in?
$hasRejectedMultiFactorAuthenticationSetup = $this->hasRejectedMultiFactorAuthenticationSetup() && !$isMFAMandatory;
if (
!$isMFAPending && !$isMFADisallowed && ($userOptions->get('mfaredirectonlogin', 0) == 1)
&& !$user->guest && !$hasRejectedMultiFactorAuthenticationSetup && !empty(MfaHelper::getMfaMethods())
) {
$this->redirect(
$userOptions->get('mfaredirecturl', '') ?:
Route::_('index.php?option=com_users&view=methods&layout=firsttime', false)
);
}
return true;
}
/**
* Does the current user need to complete MFA authentication before being allowed to access the site?
*
* @return boolean
* @throws \Exception
* @since 4.2.0
*/
private function isMultiFactorAuthenticationPending(): bool
{
$user = $this->getIdentity();
if (empty($user) || $user->guest) {
return false;
}
// Get the user's MFA records
$records = MfaHelper::getUserMfaRecords($user->id);
// No MFA Methods? Then we obviously don't need to display a Captive login page.
if (count($records) < 1) {
return false;
}
// Let's get a list of all currently active MFA Methods
$mfaMethods = MfaHelper::getMfaMethods();
// If no MFA Method is active we can't really display a Captive login page.
if (empty($mfaMethods)) {
return false;
}
// Get a list of just the Method names
$methodNames = [];
foreach ($mfaMethods as $mfaMethod) {
$methodNames[] = $mfaMethod['name'];
}
// Filter the records based on currently active MFA Methods
foreach ($records as $record) {
if (in_array($record->method, $methodNames)) {
// We found an active Method. Show the Captive page.
return true;
}
}
// No viable MFA Method found. We won't show the Captive page.
return false;
}
/**
* Check whether we'll need to do a redirection to the Multi-factor Authentication captive page.
*
* @return boolean
* @since 4.2.0
*/
private function needsMultiFactorAuthenticationRedirection(): bool
{
$isAdmin = $this->isClient('administrator');
/**
* We only kick in if the session flag is not set AND the user is not flagged for monitoring of their MFA status
*
* In case a user belongs to a group which requires MFA to be always enabled and they logged in without having
* MFA enabled we have the recheck flag. This prevents the user from enabling and immediately disabling MFA,
* circumventing the requirement for MFA.
*/
$session = $this->getSession();
$isMFAComplete = $session->get('com_users.mfa_checked', 0) != 0;
$isMFASetupMandatory = $session->get('com_users.mandatory_mfa_setup', 0) != 0;
if ($isMFAComplete && !$isMFASetupMandatory) {
return false;
}
// Make sure we are logged in
try {
$user = $this->getIdentity();
} catch (\Exception $e) {
// This would happen if we are in CLI or under an old Joomla! version. Either case is not supported.
return false;
}
// The plugin only needs to kick in when you have logged in
if (empty($user) || $user->guest) {
return false;
}
// If we are in the administrator section we only kick in when the user has backend access privileges
if ($isAdmin && !$user->authorise('core.login.admin')) {
// @todo How exactly did you end up here if you didn't have the core.login.admin privilege to begin with?!
return false;
}
// Do not redirect if we are already in a MFA management or captive page
if ($this->isMultiFactorAuthenticationPage()) {
return false;
}
$option = strtolower($this->input->getCmd('option', ''));
$task = strtolower($this->input->getCmd('task', ''));
// Allow the frontend user to log out (in case they forgot their MFA code or something)
if (!$isAdmin && ($option == 'com_users') && in_array($task, ['user.logout', 'user.menulogout'])) {
return false;
}
// Allow the backend user to log out (in case they forgot their MFA code or something)
if ($isAdmin && ($option == 'com_login') && ($task == 'logout')) {
return false;
}
// Allow the Joomla update finalisation to run
if ($isAdmin && $option === 'com_joomlaupdate' && in_array($task, ['update.finalise', 'update.cleanup', 'update.finaliseconfirm'])) {
return false;
}
return true;
}
/**
* Is this a page concerning the Multi-factor Authentication feature?
*
* @param bool $onlyCaptive Should I only check for the MFA captive page?
*
* @return boolean
* @since 4.2.0
*/
public function isMultiFactorAuthenticationPage(bool $onlyCaptive = false): bool
{
$option = $this->input->get('option');
$task = $this->input->get('task');
$view = $this->input->get('view');
if ($option !== 'com_users') {
return false;
}
$allowedViews = ['captive', 'method', 'methods', 'callback'];
$allowedTasks = [
'captive.display', 'captive.captive', 'captive.validate',
'methods.display',
];
if (!$onlyCaptive) {
$allowedTasks = array_merge(
$allowedTasks,
[
'method.display', 'method.add', 'method.edit', 'method.regenerateBackupCodes',
'method.delete', 'method.save', 'methods.disable', 'methods.doNotShowThisAgain',
]
);
}
return in_array($view, $allowedViews) || in_array($task, $allowedTasks);
}
/**
* Does the user have a "don't show this again" flag?
*
* @return boolean
* @since 4.2.0
*/
private function hasRejectedMultiFactorAuthenticationSetup(): bool
{
$user = $this->getIdentity();
$profileKey = 'mfa.dontshow';
/** @var DatabaseInterface $db */
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select($db->quoteName('profile_value'))
->from($db->quoteName('#__user_profiles'))
->where($db->quoteName('user_id') . ' = :userId')
->where($db->quoteName('profile_key') . ' = :profileKey')
->bind(':userId', $user->id, ParameterType::INTEGER)
->bind(':profileKey', $profileKey);
try {
$result = $db->setQuery($query)->loadResult();
} catch (\Exception $e) {
$result = 1;
}
return $result == 1;
}
/**
* Automatically migrates a user's legacy MFA records into the new Captive MFA format.
*
* @return void
* @since 4.2.0
*/
private function migrateFromLegacyMFA(): void
{
$user = $this->getIdentity();
if (!($user instanceof User) || $user->guest || $user->id <= 0) {
return;
}
/** @var DatabaseInterface $db */
$db = Factory::getContainer()->get(DatabaseInterface::class);
$userTable = new UserTable($db);
if (!$userTable->load($user->id) || empty($userTable->otpKey)) {
return;
}
[$otpMethod, $otpKey] = explode(':', $userTable->otpKey, 2);
$secret = $this->get('secret');
$otpKey = $this->decryptLegacyTFAString($secret, $otpKey);
$otep = $this->decryptLegacyTFAString($secret, $userTable->otep);
$config = @json_decode($otpKey, true);
$hasConverted = true;
if (!empty($config)) {
switch ($otpMethod) {
case 'totp':
$this->getLanguage()->load('plg_multifactorauth_totp', JPATH_ADMINISTRATOR);
Factory::getApplication()->bootComponent('com_users')->getMVCFactory()->createTable('Mfa', 'Administrator')->save(
[
'user_id' => $user->id,
'title' => Text::_('PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE'),
'method' => 'totp',
'default' => 0,
'created_on' => Date::getInstance()->toSql(),
'last_used' => null,
'tries' => 0,
'try_count' => null,
'options' => ['key' => $config['code']],
]
);
break;
case 'yubikey':
$this->getLanguage()->load('plg_multifactorauth_yubikey', JPATH_ADMINISTRATOR);
Factory::getApplication()->bootComponent('com_users')->getMVCFactory()->createTable('Mfa', 'Administrator')->save(
[
'user_id' => $user->id,
'title' => sprintf("%s %s", Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'), $config['yubikey']),
'method' => 'yubikey',
'default' => 0,
'created_on' => Date::getInstance()->toSql(),
'last_used' => null,
'tries' => 0,
'try_count' => null,
'options' => ['id' => $config['yubikey']],
]
);
break;
default:
$hasConverted = false;
break;
}
}
// Convert the emergency codes
if ($hasConverted && !empty(@json_decode($otep, true))) {
// Delete any other record with the same user_id and Method.
$method = 'emergencycodes';
$userId = $user->id;
$query = $db->getQuery(true)
->delete($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' = :user_id')
->where($db->quoteName('method') . ' = :method')
->bind(':user_id', $userId, ParameterType::INTEGER)
->bind(':method', $method);
$db->setQuery($query)->execute();
// Migrate data
Factory::getApplication()->bootComponent('com_users')->getMVCFactory()->createTable('Mfa', 'Administrator')->save(
[
'user_id' => $user->id,
'title' => Text::_('COM_USERS_USER_BACKUPCODES'),
'method' => 'backupcodes',
'default' => 0,
'created_on' => Date::getInstance()->toSql(),
'last_used' => null,
'tries' => 0,
'try_count' => null,
'options' => @json_decode($otep, true),
]
);
}
// Remove the legacy MFA
$update = (object) [
'id' => $user->id,
'otpKey' => '',
'otep' => '',
];
$db->updateObject('#__users', $update, ['id']);
}
/**
* Tries to decrypt the legacy MFA configuration.
*
* @param string $secret Site's secret key
* @param string $stringToDecrypt Base64-encoded and encrypted, JSON-encoded information
*
* @return string Decrypted, but JSON-encoded, information
*
* @see https://github.com/joomla/joomla-cms/pull/12497
* @since 4.2.0
*/
private function decryptLegacyTFAString(string $secret, string $stringToDecrypt): string
{
// Is this already decrypted?
try {
$decrypted = @json_decode($stringToDecrypt, true);
} catch (\Exception $e) {
$decrypted = null;
}
if (!empty($decrypted)) {
return $stringToDecrypt;
}
// No, we need to decrypt the string
$aes = new Aes($secret, 256);
$decrypted = $aes->decryptString($stringToDecrypt);
if (!is_string($decrypted) || empty($decrypted)) {
$aes->setPassword($secret, true);
$decrypted = $aes->decryptString($stringToDecrypt);
}
if (!is_string($decrypted) || empty($decrypted)) {
return '';
}
// Remove the null padding added during encryption
return rtrim($decrypted, "\0");
}
}