363 lines
12 KiB
PHP
363 lines
12 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\Helper;
|
|
|
|
use Exception;
|
|
use Joomla\CMS\Application\CMSApplication;
|
|
use Joomla\CMS\Component\ComponentHelper;
|
|
use Joomla\CMS\Document\HtmlDocument;
|
|
use Joomla\CMS\Event\MultiFactor\GetMethod;
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
|
use Joomla\CMS\Plugin\PluginHelper;
|
|
use Joomla\CMS\Uri\Uri;
|
|
use Joomla\CMS\User\User;
|
|
use Joomla\CMS\User\UserFactoryInterface;
|
|
use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
|
|
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
|
|
use Joomla\Component\Users\Administrator\Model\MethodsModel;
|
|
use Joomla\Component\Users\Administrator\Table\MfaTable;
|
|
use Joomla\Component\Users\Administrator\View\Methods\HtmlView;
|
|
use Joomla\Database\DatabaseInterface;
|
|
use Joomla\Database\ParameterType;
|
|
|
|
// phpcs:disable PSR1.Files.SideEffects
|
|
\defined('_JEXEC') or die;
|
|
// phpcs:enable PSR1.Files.SideEffects
|
|
|
|
/**
|
|
* Helper functions for captive MFA handling
|
|
*
|
|
* @since 4.2.0
|
|
*/
|
|
abstract class Mfa
|
|
{
|
|
/**
|
|
* Cache of all currently active MFAs
|
|
*
|
|
* @var array|null
|
|
* @since 4.2.0
|
|
*/
|
|
protected static $allMFAs = null;
|
|
|
|
/**
|
|
* Are we inside the administrator application
|
|
*
|
|
* @var boolean
|
|
* @since 4.2.0
|
|
*/
|
|
protected static $isAdmin = null;
|
|
|
|
/**
|
|
* Get the HTML for the Multi-factor Authentication configuration interface for a user.
|
|
*
|
|
* This helper method uses a sort of primitive HMVC to display the com_users' Methods page which
|
|
* renders the MFA configuration interface.
|
|
*
|
|
* @param User $user The user we are going to show the configuration UI for.
|
|
*
|
|
* @return string|null The HTML of the UI; null if we cannot / must not show it.
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public static function getConfigurationInterface(User $user): ?string
|
|
{
|
|
// Check the conditions
|
|
if (!self::canShowConfigurationInterface($user)) {
|
|
return null;
|
|
}
|
|
|
|
/** @var CMSApplication $app */
|
|
$app = Factory::getApplication();
|
|
|
|
if (!$app->getInput()->getCmd('option', '') === 'com_users') {
|
|
$app->getLanguage()->load('com_users');
|
|
$app->getDocument()
|
|
->getWebAssetManager()
|
|
->getRegistry()
|
|
->addExtensionRegistryFile('com_users');
|
|
}
|
|
|
|
// Get a model
|
|
/** @var MVCFactoryInterface $factory */
|
|
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
|
|
|
|
/** @var MethodsModel $methodsModel */
|
|
$methodsModel = $factory->createModel('Methods', 'Administrator');
|
|
/** @var BackupcodesModel $methodsModel */
|
|
$backupCodesModel = $factory->createModel('Backupcodes', 'Administrator');
|
|
|
|
// Get a view object
|
|
$appRoot = $app->isClient('site') ? \JPATH_SITE : \JPATH_ADMINISTRATOR;
|
|
$prefix = $app->isClient('site') ? 'Site' : 'Administrator';
|
|
/** @var HtmlView $view */
|
|
$view = $factory->createView(
|
|
'Methods',
|
|
$prefix,
|
|
'Html',
|
|
[
|
|
'base_path' => $appRoot . '/components/com_users',
|
|
]
|
|
);
|
|
$view->setModel($methodsModel, true);
|
|
/** @noinspection PhpParamsInspection */
|
|
$view->setModel($backupCodesModel);
|
|
$view->document = $app->getDocument();
|
|
$view->returnURL = base64_encode(Uri::getInstance()->toString());
|
|
$view->user = $user;
|
|
$view->set('forHMVC', true);
|
|
$view->setLanguage($app->getLanguage());
|
|
|
|
@ob_start();
|
|
|
|
try {
|
|
$view->display();
|
|
} catch (\Throwable $e) {
|
|
@ob_end_clean();
|
|
|
|
/**
|
|
* This is intentional! When you are developing a Multi-factor Authentication plugin you
|
|
* will inevitably mess something up and end up with an error. This would cause the
|
|
* entire MFA configuration page to disappear. No problem! Set Debug System to Yes in
|
|
* Global Configuration and you can see the error exception which will help you solve
|
|
* your problem.
|
|
*/
|
|
if (\defined('JDEBUG') && JDEBUG) {
|
|
throw $e;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return @ob_get_clean();
|
|
}
|
|
|
|
/**
|
|
* Get a list of all of the MFA Methods
|
|
*
|
|
* @return MethodDescriptor[]
|
|
* @since 4.2.0
|
|
*/
|
|
public static function getMfaMethods(): array
|
|
{
|
|
PluginHelper::importPlugin('multifactorauth');
|
|
|
|
if (\is_null(self::$allMFAs)) {
|
|
// Get all the plugin results
|
|
$event = new GetMethod();
|
|
$temp = Factory::getApplication()
|
|
->getDispatcher()
|
|
->dispatch($event->getName(), $event)
|
|
->getArgument('result', []);
|
|
|
|
// Normalize the results
|
|
self::$allMFAs = [];
|
|
|
|
foreach ($temp as $method) {
|
|
if (!\is_array($method) && !($method instanceof MethodDescriptor)) {
|
|
continue;
|
|
}
|
|
|
|
$method = $method instanceof MethodDescriptor
|
|
? $method : new MethodDescriptor($method);
|
|
|
|
if (empty($method['name'])) {
|
|
continue;
|
|
}
|
|
|
|
self::$allMFAs[$method['name']] = $method;
|
|
}
|
|
}
|
|
|
|
return self::$allMFAs;
|
|
}
|
|
|
|
/**
|
|
* Is the current user allowed to add/edit MFA methods for $user?
|
|
*
|
|
* This is only allowed if I am adding / editing methods for myself.
|
|
*
|
|
* If the target user is a member of any group disallowed to use MFA this will return false.
|
|
*
|
|
* @param User|null $user The user you want to know if we're allowed to edit
|
|
*
|
|
* @return boolean
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public static function canAddEditMethod(?User $user = null): bool
|
|
{
|
|
// Cannot do MFA operations on no user or a guest user.
|
|
if (\is_null($user) || $user->guest) {
|
|
return false;
|
|
}
|
|
|
|
// If the user is in a user group which disallows MFA we cannot allow adding / editing methods.
|
|
$neverMFAGroups = ComponentHelper::getParams('com_users')->get('neverMFAUserGroups', []);
|
|
$neverMFAGroups = \is_array($neverMFAGroups) ? $neverMFAGroups : [];
|
|
|
|
if (\count(array_intersect($user->getAuthorisedGroups(), $neverMFAGroups))) {
|
|
return false;
|
|
}
|
|
|
|
// Check if this is the same as the logged-in user.
|
|
$myUser = Factory::getApplication()->getIdentity()
|
|
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
|
|
|
|
return $myUser->id === $user->id;
|
|
}
|
|
|
|
/**
|
|
* Is the current user allowed to delete MFA methods / disable MFA for $user?
|
|
*
|
|
* This is allowed if:
|
|
* - The user being queried is the same as the logged-in user
|
|
* - The logged-in user is a Super User AND the queried user is NOT a Super User.
|
|
*
|
|
* Note that Super Users can be edited by their own user only for security reasons. If a Super
|
|
* User gets locked out they must use the Backup Codes to regain access. If that's not possible,
|
|
* they will need to delete their records from the `#__user_mfa` table.
|
|
*
|
|
* @param User|null $user The user being queried.
|
|
*
|
|
* @return boolean
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public static function canDeleteMethod(?User $user = null): bool
|
|
{
|
|
// Cannot do MFA operations on no user or a guest user.
|
|
if (\is_null($user) || $user->guest) {
|
|
return false;
|
|
}
|
|
|
|
$myUser = Factory::getApplication()->getIdentity()
|
|
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
|
|
|
|
return $myUser->id === $user->id
|
|
|| ($myUser->authorise('core.admin') && !$user->authorise('core.admin'));
|
|
}
|
|
|
|
/**
|
|
* Return all MFA records for a specific user
|
|
*
|
|
* @param int|null $userId User ID. NULL for currently logged in user.
|
|
*
|
|
* @return MfaTable[]
|
|
* @throws \Exception
|
|
*
|
|
* @since 4.2.0
|
|
*/
|
|
public static function getUserMfaRecords(?int $userId): array
|
|
{
|
|
if (empty($userId)) {
|
|
$user = Factory::getApplication()->getIdentity() ?: Factory::getUser();
|
|
$userId = $user->id ?: 0;
|
|
}
|
|
|
|
/** @var DatabaseInterface $db */
|
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('id'))
|
|
->from($db->quoteName('#__user_mfa'))
|
|
->where($db->quoteName('user_id') . ' = :user_id')
|
|
->bind(':user_id', $userId, ParameterType::INTEGER);
|
|
|
|
try {
|
|
$ids = $db->setQuery($query)->loadColumn() ?: [];
|
|
} catch (\Exception $e) {
|
|
$ids = [];
|
|
}
|
|
|
|
if (empty($ids)) {
|
|
return [];
|
|
}
|
|
|
|
/** @var MVCFactoryInterface $factory */
|
|
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
|
|
|
|
// Map all results to MFA table objects
|
|
$records = array_map(
|
|
function ($id) use ($factory) {
|
|
/** @var MfaTable $record */
|
|
$record = $factory->createTable('Mfa', 'Administrator');
|
|
$loaded = $record->load($id);
|
|
|
|
return $loaded ? $record : null;
|
|
},
|
|
$ids
|
|
);
|
|
|
|
// Let's remove Methods we couldn't decrypt when reading from the database.
|
|
$hasBackupCodes = false;
|
|
|
|
$records = array_filter(
|
|
$records,
|
|
function ($record) use (&$hasBackupCodes) {
|
|
$isValid = !\is_null($record) && (!empty($record->options));
|
|
|
|
if ($isValid && ($record->method === 'backupcodes')) {
|
|
$hasBackupCodes = true;
|
|
}
|
|
|
|
return $isValid;
|
|
}
|
|
);
|
|
|
|
// If the only Method is backup codes it's as good as having no records
|
|
if ((\count($records) === 1) && $hasBackupCodes) {
|
|
return [];
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
/**
|
|
* Are the conditions for showing the MFA configuration interface met?
|
|
*
|
|
* @param User|null $user The user to be configured
|
|
*
|
|
* @return boolean
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public static function canShowConfigurationInterface(?User $user = null): bool
|
|
{
|
|
// If I have no user to check against that's all the checking I can do.
|
|
if (empty($user)) {
|
|
return false;
|
|
}
|
|
|
|
// I need at least one MFA method plugin for the setup interface to make any sense.
|
|
$plugins = PluginHelper::getPlugin('multifactorauth');
|
|
|
|
if (\count($plugins) < 1) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CMSApplication $app */
|
|
$app = Factory::getApplication();
|
|
|
|
// We can only show a configuration page in the front- or backend application.
|
|
if (!$app->isClient('site') && !$app->isClient('administrator')) {
|
|
return false;
|
|
}
|
|
|
|
// Only show the configuration page if we have an HTML document
|
|
if (!($app->getDocument() instanceof HtmlDocument)) {
|
|
return false;
|
|
}
|
|
|
|
// I must be able to add, edit or delete the user's MFA settings
|
|
return self::canAddEditMethod($user) || self::canDeleteMethod($user);
|
|
}
|
|
}
|