Spade
Mini Shell
| Directory:~$ /home/lmsyaran/www/joomla5/libraries/src/Application/ |
| [Home] [System Details] [Kill Me] |
<?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();
} 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
*
* @link 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");
}
}