[ Index ] |
PHP Cross Reference of Joomla 4.2.2 documentation |
[Summary view] [Print] [Text view]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Wed Sep 7 05:41:13 2022 | Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer |