[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/libraries/src/Application/ -> MultiFactorAuthenticationHandler.php (source)

   1  <?php
   2  
   3  /**
   4   * Joomla! Content Management System
   5   *
   6   * @copyright  (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
   7   * @license    GNU General Public License version 2 or later; see LICENSE.txt
   8   */
   9  
  10  namespace Joomla\CMS\Application;
  11  
  12  use Exception;
  13  use Joomla\CMS\Component\ComponentHelper;
  14  use Joomla\CMS\Date\Date;
  15  use Joomla\CMS\Encrypt\Aes;
  16  use Joomla\CMS\Factory;
  17  use Joomla\CMS\Language\Text;
  18  use Joomla\CMS\Router\Route;
  19  use Joomla\CMS\Table\User as UserTable;
  20  use Joomla\CMS\Uri\Uri;
  21  use Joomla\CMS\User\User;
  22  use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
  23  use Joomla\Component\Users\Administrator\Table\MfaTable;
  24  use Joomla\Database\DatabaseDriver;
  25  use Joomla\Database\ParameterType;
  26  use RuntimeException;
  27  
  28  // phpcs:disable PSR1.Files.SideEffects
  29  \defined('_JEXEC') or die;
  30  // phpcs:enable PSR1.Files.SideEffects
  31  
  32  /**
  33   * Implements the code required for integrating with Joomla's Multi-factor Authentication.
  34   *
  35   * Please keep in mind that Joomla's MFA, like any MFA method, is designed to be user-interactive.
  36   * Moreover, it's meant to be used in an HTML- and JavaScript-aware execution environment i.e. a web
  37   * browser, web view or similar.
  38   *
  39   * If your application is designed to work non-interactively (e.g. a JSON API application) or
  40   * outside and HTML- and JavaScript-aware execution environments (e.g. CLI) you MUST NOT use this
  41   * trait. Authentication should be either implicit (e.g. CLI) or using sufficiently secure non-
  42   * interactive methods (tokens, certificates, ...).
  43   *
  44   * Regarding the Joomla CMS itself, only the SiteApplication (frontend) and AdministratorApplication
  45   * (backend) applications use this trait because of this reason. The CLI application is implicitly
  46   * authorised at the highest level, whereas the ApiApplication encourages the use of tokens for
  47   * authentication.
  48   *
  49   * @since 4.2.0
  50   */
  51  trait MultiFactorAuthenticationHandler
  52  {
  53      /**
  54       * Handle the redirection to the Multi-factor Authentication captive login or setup page.
  55       *
  56       * @return  boolean  True if we are currently handling a Multi-factor Authentication captive page.
  57       * @throws  Exception
  58       * @since   4.2.0
  59       */
  60      protected function isHandlingMultiFactorAuthentication(): bool
  61      {
  62          // Multi-factor Authentication checks take place only for logged in users.
  63          try {
  64              $user = $this->getIdentity() ?? null;
  65          } catch (Exception $e) {
  66              return false;
  67          }
  68  
  69          if (!($user instanceof User) || $user->guest) {
  70              return false;
  71          }
  72  
  73          // If there is no need for a redirection I must not proceed
  74          if (!$this->needsMultiFactorAuthenticationRedirection()) {
  75              return false;
  76          }
  77  
  78          /**
  79           * Automatically migrate from legacy MFA, if needed.
  80           *
  81           * We prefer to do a user-by-user migration instead of migrating everybody on Joomla update
  82           * for practical reasons. On a site with hundreds or thousands of users the migration could
  83           * take several minutes, causing Joomla Update to time out.
  84           *
  85           * Instead, every time we are in a captive Multi-factor Authentication page (captive MFA login
  86           * or captive forced MFA setup) we spend a few milliseconds to check if a migration is
  87           * necessary. If it's necessary, we perform it.
  88           *
  89           * The captive pages don't load any content or modules, therefore the few extra milliseconds
  90           * we spend here are not a big deal. A failed all-users migration which would stop Joomla
  91           * Update dead in its tracks would, however, be a big deal (broken sites). Moreover, a
  92           * migration that has to be initiated by the site owner would also be a big deal — if they
  93           * did not know they need to do it none of their users who had previously enabled MFA would
  94           * now have it enabled!
  95           *
  96           * To paraphrase Otto von Bismarck: programming, like politics, is the art of the possible,
  97           * the attainable -- the art of the next best.
  98           */
  99          $this->migrateFromLegacyMFA();
 100  
 101          // We only kick in when the user has actually set up MFA or must definitely enable MFA.
 102          $userOptions        = ComponentHelper::getParams('com_users');
 103          $neverMFAUserGroups = $userOptions->get('neverMFAUserGroups', []);
 104          $forceMFAUserGroups = $userOptions->get('forceMFAUserGroups', []);
 105          $isMFADisallowed    = count(
 106              array_intersect(
 107                  is_array($neverMFAUserGroups) ? $neverMFAUserGroups : [],
 108                  $user->getAuthorisedGroups()
 109              )
 110          ) >= 1;
 111          $isMFAMandatory     = count(
 112              array_intersect(
 113                  is_array($forceMFAUserGroups) ? $forceMFAUserGroups : [],
 114                  $user->getAuthorisedGroups()
 115              )
 116          ) >= 1;
 117          $isMFADisallowed = $isMFADisallowed && !$isMFAMandatory;
 118          $isMFAPending    = $this->isMultiFactorAuthenticationPending();
 119          $session         = $this->getSession();
 120          $isNonHtml       = $this->input->getCmd('format', 'html') !== 'html';
 121  
 122          // Prevent non-interactive (non-HTML) content from being loaded until MFA is validated.
 123          if ($isMFAPending && $isNonHtml) {
 124              throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
 125          }
 126  
 127          if ($isMFAPending && !$isMFADisallowed) {
 128              /**
 129               * Saves the current URL as the return URL if all of the following conditions apply
 130               * - It is not a URL to com_users' MFA feature itself
 131               * - A return URL does not already exist, is imperfect or external to the site
 132               *
 133               * If no return URL has been set up and the current URL is com_users' MFA feature
 134               * we will save the home page as the redirect target.
 135               */
 136              $returnUrl       = $session->get('com_users.return_url', '');
 137  
 138              if (empty($returnUrl) || !Uri::isInternal($returnUrl)) {
 139                  $returnUrl = $this->isMultiFactorAuthenticationPage()
 140                      ? Uri::base()
 141                      : Uri::getInstance()->toString(['scheme', 'user', 'pass', 'host', 'port', 'path', 'query', 'fragment']);
 142                  $session->set('com_users.return_url', $returnUrl);
 143              }
 144  
 145              // Redirect
 146              $this->redirect(Route::_('index.php?option=com_users&view=captive', false), 307);
 147          }
 148  
 149          // If we're here someone just logged in but does not have MFA set up. Just flag him as logged in and continue.
 150          $session->set('com_users.mfa_checked', 1);
 151  
 152          // If the user is in a group that requires MFA we will redirect them to the setup page.
 153          if (!$isMFAPending && $isMFAMandatory) {
 154              // First unset the flag to make sure the redirection will apply until they conform to the mandatory MFA
 155              $session->set('com_users.mfa_checked', 0);
 156  
 157              // Now set a flag which forces rechecking MFA for this user
 158              $session->set('com_users.mandatory_mfa_setup', 1);
 159  
 160              // Then redirect them to the setup page
 161              if (!$this->isMultiFactorAuthenticationPage()) {
 162                  $url = Route::_('index.php?option=com_users&view=methods', false);
 163                  $this->redirect($url, 307);
 164              }
 165          }
 166  
 167          // Do I need to redirect the user to the MFA setup page after they have fully logged in?
 168          $hasRejectedMultiFactorAuthenticationSetup = $this->hasRejectedMultiFactorAuthenticationSetup() && !$isMFAMandatory;
 169  
 170          if (
 171              !$isMFAPending && !$isMFADisallowed && ($userOptions->get('mfaredirectonlogin', 0) == 1)
 172              && !$user->guest  && !$hasRejectedMultiFactorAuthenticationSetup && !empty(MfaHelper::getMfaMethods())
 173          ) {
 174              $this->redirect(
 175                  $userOptions->get('mfaredirecturl', '') ?:
 176                      Route::_('index.php?option=com_users&view=methods&layout=firsttime', false)
 177              );
 178          }
 179  
 180          return true;
 181      }
 182  
 183      /**
 184       * Does the current user need to complete MFA authentication before being allowed to access the site?
 185       *
 186       * @return  boolean
 187       * @throws  Exception
 188       * @since   4.2.0
 189       */
 190      private function isMultiFactorAuthenticationPending(): bool
 191      {
 192          $user = $this->getIdentity();
 193  
 194          if (empty($user) || $user->guest) {
 195              return false;
 196          }
 197  
 198          // Get the user's MFA records
 199          $records = MfaHelper::getUserMfaRecords($user->id);
 200  
 201          // No MFA Methods? Then we obviously don't need to display a Captive login page.
 202          if (count($records) < 1) {
 203              return false;
 204          }
 205  
 206          // Let's get a list of all currently active MFA Methods
 207          $mfaMethods = MfaHelper::getMfaMethods();
 208  
 209          // If no MFA Method is active we can't really display a Captive login page.
 210          if (empty($mfaMethods)) {
 211              return false;
 212          }
 213  
 214          // Get a list of just the Method names
 215          $methodNames = [];
 216  
 217          foreach ($mfaMethods as $mfaMethod) {
 218              $methodNames[] = $mfaMethod['name'];
 219          }
 220  
 221          // Filter the records based on currently active MFA Methods
 222          foreach ($records as $record) {
 223              if (in_array($record->method, $methodNames)) {
 224                  // We found an active Method. Show the Captive page.
 225                  return true;
 226              }
 227          }
 228  
 229          // No viable MFA Method found. We won't show the Captive page.
 230          return false;
 231      }
 232  
 233      /**
 234       * Check whether we'll need to do a redirection to the Multi-factor Authentication captive page.
 235       *
 236       * @return  boolean
 237       * @since 4.2.0
 238       */
 239      private function needsMultiFactorAuthenticationRedirection(): bool
 240      {
 241          $isAdmin = $this->isClient('administrator');
 242  
 243          /**
 244           * We only kick in if the session flag is not set AND the user is not flagged for monitoring of their MFA status
 245           *
 246           * In case a user belongs to a group which requires MFA to be always enabled and they logged in without having
 247           * MFA enabled we have the recheck flag. This prevents the user from enabling and immediately disabling MFA,
 248           * circumventing the requirement for MFA.
 249           */
 250          $session             = $this->getSession();
 251          $isMFAComplete       = $session->get('com_users.mfa_checked', 0) != 0;
 252          $isMFASetupMandatory = $session->get('com_users.mandatory_mfa_setup', 0) != 0;
 253  
 254          if ($isMFAComplete && !$isMFASetupMandatory) {
 255              return false;
 256          }
 257  
 258          // Make sure we are logged in
 259          try {
 260              $user = $this->getIdentity();
 261          } catch (Exception $e) {
 262              // This would happen if we are in CLI or under an old Joomla! version. Either case is not supported.
 263              return false;
 264          }
 265  
 266          // The plugin only needs to kick in when you have logged in
 267          if (empty($user) || $user->guest) {
 268              return false;
 269          }
 270  
 271          // If we are in the administrator section we only kick in when the user has backend access privileges
 272          if ($isAdmin && !$user->authorise('core.login.admin')) {
 273              // @todo How exactly did you end up here if you didn't have the core.login.admin privilege to begin with?!
 274              return false;
 275          }
 276  
 277          // Do not redirect if we are already in a MFA management or captive page
 278          if ($this->isMultiFactorAuthenticationPage()) {
 279              return false;
 280          }
 281  
 282          $option       = strtolower($this->input->getCmd('option', ''));
 283          $task         = strtolower($this->input->getCmd('task', ''));
 284  
 285          // Allow the frontend user to log out (in case they forgot their MFA code or something)
 286          if (!$isAdmin && ($option == 'com_users') && in_array($task, ['user.logout', 'user.menulogout'])) {
 287              return false;
 288          }
 289  
 290          // Allow the backend user to log out (in case they forgot their MFA code or something)
 291          if ($isAdmin && ($option == 'com_login') && ($task == 'logout')) {
 292              return false;
 293          }
 294  
 295          // Allow the Joomla update finalisation to run
 296          if ($isAdmin && $option === 'com_joomlaupdate' && in_array($task, ['update.finalise', 'update.cleanup', 'update.finaliseconfirm'])) {
 297              return false;
 298          }
 299  
 300          return true;
 301      }
 302  
 303      /**
 304       * Is this a page concerning the Multi-factor Authentication feature?
 305       *
 306       * @param   bool  $onlyCaptive  Should I only check for the MFA captive page?
 307       *
 308       * @return  boolean
 309       * @since   4.2.0
 310       */
 311      public function isMultiFactorAuthenticationPage(bool $onlyCaptive = false): bool
 312      {
 313          $option = $this->input->get('option');
 314          $task   = $this->input->get('task');
 315          $view   = $this->input->get('view');
 316  
 317          if ($option !== 'com_users') {
 318              return false;
 319          }
 320  
 321          $allowedViews = ['captive', 'method', 'methods', 'callback'];
 322          $allowedTasks = [
 323              'captive.display', 'captive.captive', 'captive.validate',
 324              'methods.display',
 325          ];
 326  
 327          if (!$onlyCaptive) {
 328              $allowedTasks = array_merge(
 329                  $allowedTasks,
 330                  [
 331                      'method.display', 'method.add', 'method.edit', 'method.regenerateBackupCodes',
 332                      'method.delete', 'method.save', 'methods.disable', 'methods.doNotShowThisAgain',
 333                  ]
 334              );
 335          }
 336  
 337          return in_array($view, $allowedViews) || in_array($task, $allowedTasks);
 338      }
 339  
 340      /**
 341       * Does the user have a "don't show this again" flag?
 342       *
 343       * @return  boolean
 344       * @since   4.2.0
 345       */
 346      private function hasRejectedMultiFactorAuthenticationSetup(): bool
 347      {
 348          $user       = $this->getIdentity();
 349          $profileKey = 'mfa.dontshow';
 350          /** @var DatabaseDriver $db */
 351          $db         = Factory::getContainer()->get('DatabaseDriver');
 352          $query      = $db->getQuery(true)
 353              ->select($db->quoteName('profile_value'))
 354              ->from($db->quoteName('#__user_profiles'))
 355              ->where($db->quoteName('user_id') . ' = :userId')
 356              ->where($db->quoteName('profile_key') . ' = :profileKey')
 357              ->bind(':userId', $user->id, ParameterType::INTEGER)
 358              ->bind(':profileKey', $profileKey);
 359  
 360          try {
 361              $result = $db->setQuery($query)->loadResult();
 362          } catch (Exception $e) {
 363              $result = 1;
 364          }
 365  
 366          return $result == 1;
 367      }
 368  
 369      /**
 370       * Automatically migrates a user's legacy MFA records into the new Captive MFA format.
 371       *
 372       * @return  void
 373       * @since 4.2.0
 374       */
 375      private function migrateFromLegacyMFA(): void
 376      {
 377          $user = $this->getIdentity();
 378  
 379          if (!($user instanceof User) || $user->guest || $user->id <= 0) {
 380              return;
 381          }
 382  
 383          /** @var DatabaseDriver $db */
 384          $db         = Factory::getContainer()->get('DatabaseDriver');
 385  
 386          $userTable = new UserTable($db);
 387  
 388          if (!$userTable->load($user->id) || empty($userTable->otpKey)) {
 389              return;
 390          }
 391  
 392          [$otpMethod, $otpKey] = explode(':', $userTable->otpKey, 2);
 393          $secret       = $this->get('secret');
 394          $otpKey       = $this->decryptLegacyTFAString($secret, $otpKey);
 395          $otep         = $this->decryptLegacyTFAString($secret, $userTable->otep);
 396          $config       = @json_decode($otpKey, true);
 397          $hasConverted = true;
 398  
 399          if (!empty($config)) {
 400              switch ($otpMethod) {
 401                  case 'totp':
 402                      $this->getLanguage()->load('plg_multifactorauth_totp', JPATH_ADMINISTRATOR);
 403  
 404                      (new MfaTable($db))->save(
 405                          [
 406                              'user_id'    => $user->id,
 407                              'title'      => Text::_('PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE'),
 408                              'method'     => 'totp',
 409                              'default'    => 0,
 410                              'created_on' => Date::getInstance()->toSql(),
 411                              'last_used'  => null,
 412                              'options'    => ['key' => $config['code']],
 413                          ]
 414                      );
 415                      break;
 416  
 417                  case 'yubikey':
 418                      $this->getLanguage()->load('plg_multifactorauth_yubikey', JPATH_ADMINISTRATOR);
 419  
 420                      (new MfaTable($db))->save(
 421                          [
 422                              'user_id'    => $user->id,
 423                              'title'      => sprintf("%s %s", Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'), $config['yubikey']),
 424                              'method'     => 'yubikey',
 425                              'default'    => 0,
 426                              'created_on' => Date::getInstance()->toSql(),
 427                              'last_used'  => null,
 428                              'options'    => ['id' => $config['yubikey']],
 429                          ]
 430                      );
 431                      break;
 432  
 433                  default:
 434                      $hasConverted = false;
 435                      break;
 436              }
 437          }
 438  
 439          // Convert the emergency codes
 440          if ($hasConverted && !empty(@json_decode($otep, true))) {
 441              // Delete any other record with the same user_id and Method.
 442              $method = 'emergencycodes';
 443              $userId = $user->id;
 444              $query  = $db->getQuery(true)
 445                  ->delete($db->qn('#__user_mfa'))
 446                  ->where($db->qn('user_id') . ' = :user_id')
 447                  ->where($db->qn('method') . ' = :method')
 448                  ->bind(':user_id', $userId, ParameterType::INTEGER)
 449                  ->bind(':method', $method);
 450              $db->setQuery($query)->execute();
 451  
 452              // Migrate data
 453              (new MfaTable($db))->save(
 454                  [
 455                      'user_id'    => $user->id,
 456                      'title'      => Text::_('COM_USERS_USER_BACKUPCODES'),
 457                      'method'     => 'backupcodes',
 458                      'default'    => 0,
 459                      'created_on' => Date::getInstance()->toSql(),
 460                      'last_used'  => null,
 461                      'options'    => @json_decode($otep, true),
 462                  ]
 463              );
 464          }
 465  
 466          // Remove the legacy MFA
 467          $update = (object) [
 468              'id'     => $user->id,
 469              'otpKey' => '',
 470              'otep'   => '',
 471          ];
 472          $db->updateObject('#__users', $update, ['id']);
 473      }
 474  
 475      /**
 476       * Tries to decrypt the legacy MFA configuration.
 477       *
 478       * @param   string   $secret            Site's secret key
 479       * @param   string   $stringToDecrypt   Base64-encoded and encrypted, JSON-encoded information
 480       *
 481       * @return  string  Decrypted, but JSON-encoded, information
 482       *
 483       * @see     https://github.com/joomla/joomla-cms/pull/12497
 484       * @since   4.2.0
 485       */
 486      private function decryptLegacyTFAString(string $secret, string $stringToDecrypt): string
 487      {
 488          // Is this already decrypted?
 489          try {
 490              $decrypted = @json_decode($stringToDecrypt, true);
 491          } catch (Exception $e) {
 492              $decrypted = null;
 493          }
 494  
 495          if (!empty($decrypted)) {
 496              return $stringToDecrypt;
 497          }
 498  
 499          // No, we need to decrypt the string
 500          $aes       = new Aes($secret, 256);
 501          $decrypted = $aes->decryptString($stringToDecrypt);
 502  
 503          if (!is_string($decrypted) || empty($decrypted)) {
 504              $aes->setPassword($secret, true);
 505  
 506              $decrypted = $aes->decryptString($stringToDecrypt);
 507          }
 508  
 509          if (!is_string($decrypted) || empty($decrypted)) {
 510              return '';
 511          }
 512  
 513          // Remove the null padding added during encryption
 514          return rtrim($decrypted, "\0");
 515      }
 516  }


Generated: Wed Sep 7 05:41:13 2022 Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer