| Current Path : /var/www/consult-e-syn/public_html/plugins/system/loginguard_/ |
| Current File : /var/www/consult-e-syn/public_html/plugins/system/loginguard_/loginguard.php |
<?php
/**
* @package AkeebaLoginGuard
* @copyright Copyright (c)2016-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
use Akeeba\LoginGuard\Site\Helper\Tfa;
use FOF30\Container\Container;
use Joomla\CMS\Application\CliApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
// Prevent direct access
defined('_JEXEC') or die;
/**
* Akeeba LoginGuard System Plugin
*
* Implements the captive Two Step Verification page
*/
class PlgSystemLoginguard extends CMSPlugin
{
/**
* Are we enabled, all requirements met etc?
*
* @var bool
*/
public $enabled = true;
/**
* The component's container
*
* @var Container
* @since 2.0.0
*/
private $container = null;
/**
* User groups for which Two Step Verification is never applied
*
* @var array
* @since 3.0.1
*/
private $neverTSVUserGroups = [];
/**
* User groups for which Two Step Verification is mandatory
*
* @var array
* @since 3.0.1
*/
private $forceTSVUserGroups = [];
/**
* Constructor
*
* @param object &$subject The object to observe
* @param array $config An optional associative array of configuration settings.
* Recognized key values include 'name', 'group', 'params', 'language'
* (this list is not meant to be comprehensive).
*/
public function __construct($subject, array $config = array())
{
parent::__construct($subject, $config);
// Load FOF
if (!defined('FOF30_INCLUDED') && !@include_once(JPATH_LIBRARIES . '/fof30/include.php'))
{
$this->enabled = false;
return;
}
// Make sure Akeeba LoginGuard is installed
try
{
if (
!file_exists(JPATH_ADMINISTRATOR . '/components/com_loginguard') ||
!ComponentHelper::isInstalled('com_loginguard') ||
!ComponentHelper::isEnabled('com_loginguard')
)
{
throw new RuntimeException('Akeeba LoginGuard is not installed');
}
$this->container = Container::getInstance('com_loginguard');
}
catch (Exception $e)
{
$this->enabled = false;
}
// PHP version check
$this->enabled = version_compare(PHP_VERSION, '7.1.0', 'ge');
// Parse settings
$this->neverTSVUserGroups = $this->container->params->get('neverTSVUserGroups', []);
if (!is_array($this->neverTSVUserGroups))
{
$this->neverTSVUserGroups = [];
}
$this->forceTSVUserGroups = $this->container->params->get('forceTSVUserGroups', []);
if (!is_array($this->forceTSVUserGroups))
{
$this->forceTSVUserGroups = [];
}
}
/**
* MAGIC TRICK. If you have enabled Joomla's Privacy Consent you'd end up with an infinite redirection loop. That's
* because Joomla! did a partial copy of my original research code on captive Joomla! logins. They did no implement
* configurable exceptions since they do not know or care about third party extensions -- even when it's the same
* extensions they copied code from.
*
* Since fixing Joomla's code is not an option we'll have to work around it based on our knowledge of real world
* Joomla usage and how the beast truly works under the hood. It really helps that yours truly was the guy who
* refactored the plugin system to use proper events for Joomla! 4 AND the person who invented the captive login
* code pattern for Joomla :)
*
* In this episode of Crazy Stuff Nicholas Has To Do To Get Basic Functionality Working we will explore how to use
* PHP Reflection to detect the offending Joomla! Privacy Consent system plugin and snuff it out before it can issue
* its redirections. I invented captive login, I know how to work around it.
*
* @since 3.0.3
* @throws Exception
*/
public function onAfterInitialise()
{
$app = Factory::getApplication();
$option = $app->input->getCmd('option', null);
/**
* If we're going to need to perform a redirection and Joomla's privacy consent is also enabled we will snuff it
* so it doesn't cause an infinite redirection loop. The correct solution would be Joomla! allowing users to
* specify exceptions to the captive login but having its developers think of that requires them to use the CMS
* in the real world which, as we know, is not the case. No problem. I've made a career working around the
* Joomla! core, haven't I?
*/
if (
($this->willNeedRedirect() || ($option == 'com_loginguard'))
&& version_compare(JVERSION, '3.8.999', 'gt'))
{
$this->snuffJoomlaPrivacyConsent();
}
}
/**
* Gets triggered right after Joomla has finished with the SEF routing and before it has the chance to dispatch the
* application (load any components).
*
* @return void
*
* @throws Exception
*/
public function onAfterRoute()
{
if (!$this->willNeedRedirect())
{
return;
}
// Get the session objects
try
{
$session = Factory::getSession();
}
catch (Exception $e)
{
// Can't get access to the session? Must be under CLI which is not supported.
return;
}
// Make sure we are logged in
try
{
$app = Factory::getApplication();
// Joomla! 3: make sure the user identity is loaded. This MUST NOT be called in Joomla! 4, though.
if (version_compare(JVERSION, '3.99999.99999', 'lt'))
{
$app->loadIdentity();
}
$user = $app->getIdentity();
}
catch (\Exception $e)
{
// This would happen if we are in CLI or under an old Joomla! version. Either case is not supported.
return;
}
// We only kick in when the user has actually set up TFA or must definitely enable TFA.
$needsTFA = $this->needsTFA($user);
$disabledTSV = $this->disabledTSV($user);
$mandatoryTSV = $this->mandatoryTSV($user);
if ($needsTFA && !$disabledTSV)
{
// Save the current URL, but only if we haven't saved a URL or if the saved URL is NOT internal to the site.
$return_url = $session->get('return_url', '', 'com_loginguard');
if (empty($return_url) || !Uri::isInternal($return_url))
{
$session->set('return_url', Uri::getInstance()->toString(array('scheme', 'user', 'pass', 'host', 'port', 'path', 'query', 'fragment')), 'com_loginguard');
}
// Redirect
$url = Route::_('index.php?option=com_loginguard&view=captive', false);
$app->redirect($url, 307);
return;
}
// If we're here someone just logged in but does not have TFA set up. Just flag him as logged in and continue.
$session->set('tfa_checked', 1, 'com_loginguard');
// If we don't have TFA set up yet AND the user plugin had set up a redirection we will honour it
$redirectionUrl = $session->get('postloginredirect', null, 'com_loginguard');
// If the user is in a group that requires TFA we will redirect them to the setup page
if (!$needsTFA && $mandatoryTSV)
{
// First unset the flag to make sure the redirection will apply until they conform to the mandatory TFA
$session->set('tfa_checked', 0, 'com_loginguard');
// Now set a flag which forces rechecking TSV for this user
$session->set('recheck_mandatory_tsv', 1, 'com_loginguard');
// Then redirect them to the setup page
$this->redirectToTSVSetup();
}
if (!$needsTFA && $redirectionUrl && !$disabledTSV)
{
$session->set('postloginredirect', null, 'com_loginguard');
Factory::getApplication()->redirect($redirectionUrl);
}
}
/**
* Hooks on the Joomla! login event. Detects silent logins and disables the Two Step Verification captive page in
* this case.
*
* @param array $options Passed by Joomla. user: a User object; responseType: string, authentication response
* type.
*/
public function onUserAfterLogin($options)
{
// Always reset the browser ID to avoid session poisoning attacks
$session = Factory::getSession();
$session->set('browserId', null, 'com_loginguard');
$session->set('browserIdCodeLoaded', false, 'com_loginguard');
// Should I show 2SV even on silent logins? Default: 1 (yes, show)
$switch = $this->params->get('2svonsilent', 1);
if ($switch == 1)
{
return;
}
// Make sure I have a valid user
/** @var User $user */
$user = $options['user'];
if (!is_object($user) || !($user instanceof User))
{
return;
}
// Make sure this is a silent login
if (!$this->isSilentLogin($user, $options['responseType']))
{
return;
}
// Set the flag indicating that 2SV is already checked.
$session->set('tfa_checked', 1, 'com_loginguard');
}
/**
* Does the current user need to complete TFA authentication before being allowed to access the site?
*
* @param User $user The user object
*
* @return bool
*/
private function needsTFA(User $user)
{
/** @var \Akeeba\LoginGuard\Site\Model\Tfa $tfaModel */
$tfaModel = $this->container->factory->model('Tfa')->tmpInstance();
// Get the user's TFA records
$records = $tfaModel->user_id($user->id)->get(true);
// No TFA methods? Then we obviously don't need to display a captive login page.
if ($records->count() < 1)
{
return false;
}
// Let's get a list of all currently active TFA methods
$tfaMethods = Tfa::getTfaMethods();
// If not TFA method is active we can't really display a captive login page.
if (empty($tfaMethods))
{
return false;
}
// Get a list of just the method names
$methodNames = [];
foreach ($tfaMethods as $tfaMethod)
{
$methodNames[] = $tfaMethod['name'];
}
// Filter the records based on currently active TFA methods
foreach ($records as $record)
{
if (in_array($record->method, $methodNames))
{
// We found an active method. Show the captive page.
return true;
}
}
// No viable TFA method found. We won't show the captive page.
return false;
}
/**
* Checks if we are running under a CLI script or inside an administrator session
*
* @return array
*
* @throws Exception
*/
protected function isCliAdmin()
{
$isAdmin = false;
try
{
if (is_null(Factory::$application))
{
$isCLI = true;
}
else
{
$app = Factory::getApplication();
$isCLI = $app instanceof \Exception || $app instanceof CliApplication;
}
}
catch (\Exception $e)
{
$isCLI = true;
}
if (!$isCLI && Factory::$application)
{
$isAdmin = Factory::getApplication()->isClient('administrator');
}
return [$isCLI, $isAdmin];
}
/**
* Does the user belong in a group indicating TSV should be disabled for them?
*
* @param JUser|User $user
*
* @return bool
*/
private function disabledTSV($user)
{
// If the user belongs to a "never check for TSV" user group they are exempt from TSV
$userGroups = $user->getAuthorisedGroups();
$belongsToTSVUserGroups = array_intersect($this->neverTSVUserGroups, $userGroups);
return !empty($belongsToTSVUserGroups);
}
/**
* Does the user belong in a group indicating TSV is required for them?
*
* @param JUser|User $user
*
* @return bool
*/
private function mandatoryTSV($user)
{
// If the user belongs to a "never check for TSV" user group they are exempt from TSV
$userGroups = $user->getAuthorisedGroups();
$belongsToTSVUserGroups = array_intersect($this->forceTSVUserGroups, $userGroups);
return !empty($belongsToTSVUserGroups);
}
/**
* Redirect the user to the Two Step Verification method setup page.
*
* @return void
*
* @since 3.0.1
*/
private function redirectToTSVSetup()
{
try
{
$app = Factory::getApplication();
}
catch (\Exception $e)
{
// This would happen if we are in CLI or under an old Joomla! version. Either case is not supported.
return;
}
// If we are in a LoginGuard page do not redirect
$option = strtolower($app->input->getCmd('option'));
if ($option == 'com_loginguard')
{
return;
}
// Otherwise redirect to the LoginGuard TSV setup page after enqueueing a message
$url = 'index.php?option=com_loginguard&view=Methods';
$app->redirect($url, 307);
}
/**
* Check whether we'll need to do a redirection to the captive page.
*
* @return bool
*
* @since 3.0.4
*
* @throws Exception
*/
private function willNeedRedirect()
{
// If the requirements are not met do not proceed
if (!$this->enabled)
{
return false;
}
// Get the session objects
try
{
$session = Factory::getSession();
}
catch (Exception $e)
{
// Can't get access to the session? Must be under CLI which is not supported.
return false;
}
/**
* We only kick in if the session flag is not set AND the user is not flagged for monitoring of their TSV status
*
* In case a user belongs to a group which requires TSV to be always enabled and they logged in without having
* TSV enabled we have the recheck flag. This prevents the user from enabling and immediately disabling TSV,
* circumventing the requirement for TSV.
*/
$tfaChecked = $session->get('tfa_checked', 0, 'com_loginguard');
$tfaRecheck = $session->get('recheck_mandatory_tsv', 0, 'com_loginguard');
if ($tfaChecked && !$tfaRecheck)
{
return false;
}
// Make sure we are logged in
try
{
$app = Factory::getApplication();
// Joomla! 3: make sure the user identity is loaded. This MUST NOT be called in Joomla! 4, though.
if (version_compare(JVERSION, '3.99999.99999', 'lt'))
{
$app->loadIdentity();
}
$user = $app->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 ($user->get('guest'))
{
return false;
}
/**
* Special handling when the requireReset flag is set on the user account.
*
* Joomla checks the requireReset flag on the user account in the application's doExecute method. If it is set
* it will call CMSApplication::checkUserRequireReset() which issues a redirection for the user to reset their
* password.
*
* One easy option here is to say "if the user must reset their password don't show the 2SV captive page"
* Unfortunately, that would be a bad idea because of the naive and insecure manner Joomla goes about the forced
* password reset. Instead of going through the actual password reset (“Forgot your password?”) page it instead
* redirects the user the user profile editor page! This allows the logged in user to view and change everything
* in the user profile, including disabling and changing the 2SV options. Considering that forced password reset
* is meant to be primarily used when we suspect that the user's account has been compromised this creates a
* grave security risk. The attacker in possession of the username and password can trick a Super User into
* forcing a password reset, thereby allowing them to bypass Two Step Verification and take over the user
* account.
*
* Instead, we unset the requireReset user flag for the duration of the page load when this method here is
* called. This prevents Joomla from redirecting. As a result you need to go through Two Step Verification as
* per usual. Once you do that the tfa_checked flag is set in the session and this method never reaches this
* point of execution where we unset the requireReset flag. Therefore Joomla now sees the requireReset flag and
* shows you the user profile edit page. Now it's safe to do so since you have already proven your identity by
* means of Two Step Verification i.e. there's no doubt we should let you make any kind of user account change.
*
* @see \Joomla\CMS\Application\SiteApplication::doExecute()
* @see \Joomla\CMS\Application\CMSApplication::checkUserRequireReset()
*/
if ($user->get('requireReset', 0))
{
$user->set('requireReset', 0);
}
[$isCLI, $isAdmin] = $this->isCliAdmin();
// TFA is not applicable under CLI
if ($isCLI)
{
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'))
{
return false;
}
$needsTFA = $this->needsTFA($user);
if ($tfaChecked && $tfaRecheck && $needsTFA)
{
return false;
}
// We only kick in if the option and task are not the ones of the captive page
$option = strtolower($app->input->getCmd('option'));
$task = strtolower($app->input->getCmd('task'));
$view = strtolower($app->input->getCmd('view'));
if ($option == 'com_loginguard')
{
// In case someone gets any funny ideas...
$app->input->set('tmpl', 'index');
$app->input->set('format', 'html');
$app->input->set('layout', null);
if (empty($view) && (strpos($task, '.') !== false))
{
[$view, $task] = explode('.', $task, 2);
}
// The captive login page is always allowed
if ($view === 'captive')
{
return false;
}
// These views are only allowed if you do not have 2SV enabled *or* if you have already logged in.
if (!$needsTFA && in_array($view, array('ajax', 'method', 'methods')))
{
return false;
}
}
// Allow the frontend user to log out (in case they forgot their TFA code or something)
if (!$isAdmin && ($option == 'com_users') && ($task == 'user.logout'))
{
return false;
}
// Allow the backend user to log out (in case they forgot their TFA code or something)
if ($isAdmin && ($option == 'com_login') && ($task == 'logout'))
{
return false;
}
/**
* Allow com_ajax. This is required for cookie acceptance in the following scenario. Your session has expired,
* therefore you need to re-apply TFA. Moreover, your cookie acceptance cookie has also expired and you need to
* accept the site's cookies again.
*/
if ($option == 'com_ajax')
{
return false;
}
return true;
}
/**
* Kills the Joomla Privacy Consent plugin when we are showing the Two Step Verification.
*
* JPC uses captive login code copied from our DataCompliance component. However, they removed the exceptions we
* have for other captive logins. As a result the JPC captive login interfered with LoginGuard's captive login,
* causing an infinite redirection.
*
* Due to complete lack of support for exceptions, this method here does something evil. It hunts down the observer
* (plugin hook) installed by the JPC plugin and removes it from the loaded plugins. This prevents the redirection
* of the captive login. THIS IS NOT THE BEST WAY TO DO THINGS. You should NOT ever, EVER!!!! copy this code. I am
* someone who has spent 15+ years dealing with Joomla's core code and I know what I'm doing, why I'm doing it and,
* most importantly, how it can possibly break. don't go about merrily copying this code if you do not understand
* how Joomla event dispatching works. You'll break shit and I'm not to blame. Thank you!
*
* @since 3.0.4
* @throws ReflectionException
*/
private function snuffJoomlaPrivacyConsent()
{
/**
* The privacy suite is not ported to Joomla! 4 yet.
*/
if (version_compare(JVERSION, '3.9999.9999', 'ge'))
{
return;
}
// The broken Joomla! consent plugin is not activated
if (!class_exists('PlgSystemPrivacyconsent'))
{
return;
}
// Get the events dispatcher and find which observer is the offending plugin
$dispatcher = JEventDispatcher::getInstance();
$refDispatcher = new ReflectionObject($dispatcher);
$refObservers = $refDispatcher->getProperty('_observers');
$refObservers->setAccessible(true);
$observers = $refObservers->getValue($dispatcher);
$jConsentObserverId = 0;
foreach ($observers as $id => $o)
{
if (!is_object($o))
{
continue;
}
if ($o instanceof \PlgSystemPrivacyconsent)
{
$jConsentObserverId = $id;
break;
}
}
// Nope. Cannot find the offending plugin.
if ($jConsentObserverId == 0)
{
return;
}
// Now we need to remove the offending plugin from the onAfterRoute event.
$refMethods = $refDispatcher->getProperty('_methods');
$refMethods->setAccessible(true);
$methods = $refMethods->getValue($dispatcher);
$methods['onafterroute'] = array_filter($methods['onafterroute'], function($id) use ($jConsentObserverId) {
return $id != $jConsentObserverId;
});
$refMethods->setValue($dispatcher, $methods);
}
/**
* Suppress Two Step Verification when Joomla performs a silent login (cookie, social login / single sign-on, GMail,
* LDAP). In these cases the login risk has been managed externally.
*
* For your reference, the Joomla authentication response types are as follows:
*
* - Joomla: username and password login. We recommend using 2SV with it.
* - Cookie: "Remember Me" cookie with a secure, single use token and other safeguards for the user session.
* - GMail: login with GMail credentials (probably no longer works)
* - LDAP: Joomla's LDAP plugin
* - SocialLogin: Akeeba Social Login (login with Facebook etc)
*
* @param User $user
* @param string $responseType
*
* @return bool
*
* @since 3.1.0
*/
private function isSilentLogin(User $user, $responseType)
{
// Fail early if the user is not properly logged in.
if (!is_object($user) || $user->guest)
{
return false;
}
// Get the custom Joomla login responses we will consider "silent"
$rawCustomResponses = $this->params->get('silentresponses', '');
$customResponses = explode(',', $rawCustomResponses);
$customResponses = array_map('trim', $customResponses);
$customResponses = array_filter($customResponses, function ($x) {
return !empty($x);
});
$silentResponses = array_unique($customResponses);
// If all else fails, use our default list (Joomla's Remember Me cookie and Akeeba SocialLogin)
if (empty($silentResponses))
{
$silentResponses = array('cookie', 'sociallogin');
}
// Is it a silent login after all?
if (is_string($responseType) && !empty($responseType) && in_array(strtolower($responseType), $silentResponses))
{
return true;
}
return false;
}
}