[ Index ] |
PHP Cross Reference of Joomla 4.2.2 documentation |
[Summary view] [Print] [Text view]
1 <?php 2 3 /** 4 * @package Joomla.Plugin 5 * @subpackage Multifactorauth.email 6 * 7 * @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org> 8 * @license GNU General Public License version 2 or later; see LICENSE.txt 9 */ 10 11 namespace Joomla\Plugin\Multifactorauth\Email\Extension; 12 13 use Exception; 14 use Joomla\CMS\Encrypt\Totp; 15 use Joomla\CMS\Event\MultiFactor\BeforeDisplayMethods; 16 use Joomla\CMS\Event\MultiFactor\Captive; 17 use Joomla\CMS\Event\MultiFactor\GetMethod; 18 use Joomla\CMS\Event\MultiFactor\GetSetup; 19 use Joomla\CMS\Event\MultiFactor\SaveSetup; 20 use Joomla\CMS\Event\MultiFactor\Validate; 21 use Joomla\CMS\Factory; 22 use Joomla\CMS\Input\Input; 23 use Joomla\CMS\Language\Text; 24 use Joomla\CMS\Log\Log; 25 use Joomla\CMS\Mail\Exception\MailDisabledException; 26 use Joomla\CMS\Mail\MailTemplate; 27 use Joomla\CMS\MVC\Factory\MVCFactoryInterface; 28 use Joomla\CMS\Plugin\CMSPlugin; 29 use Joomla\CMS\Uri\Uri; 30 use Joomla\CMS\User\User; 31 use Joomla\CMS\User\UserFactoryInterface; 32 use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions; 33 use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor; 34 use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions; 35 use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper; 36 use Joomla\Component\Users\Administrator\Table\MfaTable; 37 use Joomla\Event\SubscriberInterface; 38 use PHPMailer\PHPMailer\Exception as phpMailerException; 39 use RuntimeException; 40 41 use function count; 42 43 // phpcs:disable PSR1.Files.SideEffects 44 \defined('_JEXEC') or die; 45 // phpcs:enable PSR1.Files.SideEffects 46 47 /** 48 * Joomla! Multi-factor Authentication using a Validation Code sent by Email. 49 * 50 * Requires entering a 6-digit code sent to the user through email. These codes change automatically 51 * on a frequency set in the plugin options (30 seconds to 5 minutes, default 2 minutes). 52 * 53 * @since 4.2.0 54 */ 55 class Email extends CMSPlugin implements SubscriberInterface 56 { 57 /** 58 * Generated OTP length. Constant: 6 numeric digits. 59 * 60 * @since 4.2.0 61 */ 62 private const CODE_LENGTH = 6; 63 64 /** 65 * Length of the secret key used for generating the OTPs. Constant: 20 characters. 66 * 67 * @since 4.2.0 68 */ 69 private const SECRET_KEY_LENGTH = 20; 70 71 /** 72 * Forbid registration of legacy (Joomla 3) event listeners. 73 * 74 * @var boolean 75 * @since 4.2.0 76 * 77 * @deprecated 78 */ 79 protected $allowLegacyListeners = false; 80 81 /** 82 * Autoload this plugin's language files 83 * 84 * @var boolean 85 * @since 4.2.0 86 */ 87 protected $autoloadLanguage = true; 88 89 /** 90 * The MFA Method name handled by this plugin 91 * 92 * @var string 93 * @since 4.2.0 94 */ 95 private $mfaMethodName = 'email'; 96 97 /** 98 * Returns an array of events this subscriber will listen to. 99 * 100 * @return array 101 * 102 * @since 4.2.0 103 */ 104 public static function getSubscribedEvents(): array 105 { 106 return [ 107 'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod', 108 'onUserMultifactorCaptive' => 'onUserMultifactorCaptive', 109 'onUserMultifactorGetSetup' => 'onUserMultifactorGetSetup', 110 'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup', 111 'onUserMultifactorValidate' => 'onUserMultifactorValidate', 112 'onUserMultifactorBeforeDisplayMethods' => 'onUserMultifactorBeforeDisplayMethods', 113 ]; 114 } 115 116 /** 117 * Gets the identity of this MFA Method 118 * 119 * @param GetMethod $event The event we are handling 120 * 121 * @return void 122 * @since 4.2.0 123 */ 124 public function onUserMultifactorGetMethod(GetMethod $event): void 125 { 126 $event->addResult( 127 new MethodDescriptor( 128 [ 129 'name' => $this->mfaMethodName, 130 'display' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'), 131 'shortinfo' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_SHORTINFO'), 132 'image' => 'media/plg_multifactorauth_email/images/email.svg', 133 ] 134 ) 135 ); 136 } 137 138 /** 139 * Returns the information which allows Joomla to render the Captive MFA page. This is the page 140 * which appears right after you log in and asks you to validate your login with MFA. 141 * 142 * @param Captive $event The event we are handling 143 * 144 * @return void 145 * @since 4.2.0 146 */ 147 public function onUserMultifactorCaptive(Captive $event): void 148 { 149 /** 150 * @var MfaTable $record The record currently selected by the user. 151 */ 152 $record = $event['record']; 153 154 // Make sure we are actually meant to handle this Method 155 if ($record->method != $this->mfaMethodName) { 156 return; 157 } 158 159 // Load the options from the record (if any) 160 $options = $this->decodeRecordOptions($record); 161 $key = $options['key'] ?? ''; 162 163 // Send an email message with a new code and ask the user to enter it. 164 $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($record->user_id); 165 166 try { 167 $this->sendCode($key, $user); 168 } catch (Exception $e) { 169 return; 170 } 171 172 $event->addResult( 173 new CaptiveRenderOptions( 174 [ 175 // Custom HTML to display above the MFA form 176 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_PRE_MESSAGE'), 177 // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML) 178 'field_type' => 'input', 179 // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type. 180 'input_type' => 'text', 181 // The attributes for the HTML input box. 182 'input_attributes' => [ 183 'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric" 184 ], 185 // Placeholder text for the HTML input box. Leave empty if you don't need it. 186 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_SETUP_PLACEHOLDER'), 187 // Label to show above the HTML input box. Leave empty if you don't need it. 188 'label' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_LABEL'), 189 // Custom HTML. Only used when field_type = custom. 190 'html' => '', 191 // Custom HTML to display below the MFA form 192 'post_message' => '', 193 // Should I hide the default Submit button? 194 'hide_submit' => false, 195 // Is this MFA method validating against all configured authenticators of the same type? 196 'allowEntryBatching' => false, 197 ] 198 ) 199 ); 200 } 201 202 /** 203 * Returns the information which allows Joomla to render the MFA setup page. This is the page 204 * which allows the user to add or modify a MFA Method for their user account. If the record 205 * does not correspond to your plugin return an empty array. 206 * 207 * @param GetSetup $event The event we are handling 208 * 209 * @return void 210 * @throws Exception 211 * @since 4.2.0 212 */ 213 public function onUserMultifactorGetSetup(GetSetup $event): void 214 { 215 /** @var MfaTable $record The record currently selected by the user. */ 216 $record = $event['record']; 217 218 // Make sure we are actually meant to handle this Method 219 if ($record->method != $this->mfaMethodName) { 220 return; 221 } 222 223 // Load the options from the record (if any) 224 $options = $this->decodeRecordOptions($record); 225 $key = $options['key'] ?? ''; 226 $isKeyAlreadySetup = !empty($key); 227 228 // If there's a key in the session use that instead. 229 $session = $this->getApplication()->getSession(); 230 $session->get('plg_multifactorauth_email.emailcode.key', $key); 231 232 // Initialize objects 233 $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900); 234 $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH); 235 236 // If there's still no key in the options, generate one and save it in the session 237 if (!$isKeyAlreadySetup) { 238 $key = $totp->generateSecret(); 239 240 $session->set('plg_multifactorauth_email.emailcode.key', $key); 241 $session->set('plg_multifactorauth_email.emailcode.user_id', $record->user_id); 242 243 $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($record->user_id); 244 245 $this->sendCode($key, $user); 246 247 $event->addResult( 248 new SetupRenderOptions( 249 [ 250 'default_title' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'), 251 'hidden_data' => [ 252 'key' => $key, 253 ], 254 'field_type' => 'input', 255 'input_type' => 'text', 256 'input_attributes' => [ 257 'pattern' => "{0,9}", 'maxlength' => "6", 'inputmode' => "numeric" 258 ], 259 'input_value' => '', 260 'placeholder' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_SETUP_PLACEHOLDER'), 261 'pre_message' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_PRE_MESSAGE'), 262 'label' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_LABEL'), 263 ] 264 ) 265 ); 266 } else { 267 $event->addResult( 268 new SetupRenderOptions( 269 [ 270 'default_title' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'), 271 'input_type' => 'hidden', 272 'html' => '', 273 ] 274 ) 275 ); 276 } 277 } 278 279 /** 280 * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If 281 * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The 282 * message of the exception will be displayed to the user. If the record does not correspond to your plugin return 283 * an empty array. 284 * 285 * @param SaveSetup $event The event we are handling 286 * 287 * @return void The configuration data to save to the database 288 * @since 4.2.0 289 */ 290 public function onUserMultifactorSaveSetup(SaveSetup $event): void 291 { 292 /** 293 * @var MfaTable $record The record currently selected by the user. 294 * @var Input $input The user input you are going to take into account. 295 */ 296 $record = $event['record']; 297 $input = $event['input']; 298 299 // Make sure we are actually meant to handle this Method 300 if ($record->method != $this->mfaMethodName) { 301 return; 302 } 303 304 // Load the options from the record (if any) 305 $options = $this->decodeRecordOptions($record); 306 $key = $options['key'] ?? ''; 307 $isKeyAlreadySetup = !empty($key); 308 $session = $this->getApplication()->getSession(); 309 310 // If there is no key in the options fetch one from the session 311 if (empty($key)) { 312 $key = $session->get('plg_multifactorauth_email.emailcode.key', null); 313 } 314 315 // If there is still no key in the options throw an error 316 if (empty($key)) { 317 throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); 318 } 319 320 /** 321 * If the code is empty but the key already existed in $options someone is simply changing the title / default 322 * Method status. We can allow this and stop checking anything else now. 323 */ 324 $code = $input->getCmd('code'); 325 326 if (empty($code) && $isKeyAlreadySetup) { 327 $event->addResult($options); 328 329 return; 330 } 331 332 // In any other case validate the submitted code 333 $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900); 334 $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH); 335 $isValid = $totp->checkCode((string) $key, (string) $code); 336 337 if (!$isValid) { 338 throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_EMAIL_ERR_INVALID_CODE'), 500); 339 } 340 341 // The code is valid. Unset the key from the session. 342 $session->set('plg_multifactorauth_email.emailcode.key', null); 343 344 // Return the configuration to be serialized 345 $event->addResult(['key' => $key]); 346 } 347 348 /** 349 * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor 350 * Authentication page. If the record does not correspond to your plugin return FALSE. 351 * 352 * @param Validate $event The event we are handling 353 * 354 * @return void 355 * @since 4.2.0 356 */ 357 public function onUserMultifactorValidate(Validate $event): void 358 { 359 /** 360 * @var MfaTable $record The MFA Method's record you're validating against 361 * @var User $user The user record 362 * @var string|null $code The submitted code 363 */ 364 $record = $event['record']; 365 $user = $event['user']; 366 $code = $event['code']; 367 368 // Make sure we are actually meant to handle this Method 369 if ($record->method != $this->mfaMethodName) { 370 $event->addResult(false); 371 372 return; 373 } 374 375 // Double check the MFA Method is for the correct user 376 if ($user->id != $record->user_id) { 377 $event->addResult(false); 378 379 return; 380 } 381 382 // Load the options from the record (if any) 383 $options = $this->decodeRecordOptions($record); 384 $key = $options['key'] ?? ''; 385 386 // If there is no key in the options throw an error 387 if (empty($key)) { 388 $event->addResult(false); 389 390 return; 391 } 392 393 // Check the MFA code for validity 394 $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900); 395 $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH); 396 397 $event->addResult($totp->checkCode($key, (string) $code)); 398 } 399 400 /** 401 * Executes before showing the MFA Methods for the user. Used for the Force Enable feature. 402 * 403 * @param BeforeDisplayMethods $event The event we are handling 404 * 405 * @return void 406 * @throws Exception 407 * @since 4.2.0 408 */ 409 public function onUserMultifactorBeforeDisplayMethods(BeforeDisplayMethods $event): void 410 { 411 /** @var ?User $user */ 412 $user = $event['user']; 413 414 // Is the forced enable feature activated? 415 if ($this->params->get('force_enable', 0) != 1) { 416 return; 417 } 418 419 // Get MFA Methods for this user 420 $userMfaRecords = MfaHelper::getUserMfaRecords($user->id); 421 422 // If there are no Methods go back 423 if (count($userMfaRecords) < 1) { 424 return; 425 } 426 427 // If the only Method is backup codes go back 428 if (count($userMfaRecords) == 1) { 429 /** @var MfaTable $record */ 430 $record = reset($userMfaRecords); 431 432 if ($record->method == 'backupcodes') { 433 return; 434 } 435 } 436 437 // If I already have the email Method go back 438 $emailRecords = array_filter( 439 $userMfaRecords, 440 function (MfaTable $record) { 441 return $record->method == 'email'; 442 } 443 ); 444 445 if (count($emailRecords)) { 446 return; 447 } 448 449 // Add the email Method 450 try { 451 /** @var MVCFactoryInterface $factory */ 452 $factory = $this->getApplication()->bootComponent('com_users')->getMVCFactory(); 453 /** @var MfaTable $record */ 454 $record = $factory->createTable('Mfa', 'Administrator'); 455 $record->reset(); 456 457 $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900); 458 $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH); 459 460 $record->save( 461 [ 462 'method' => 'email', 463 'title' => Text::_('PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS'), 464 'options' => [ 465 'key' => ($totp)->generateSecret(), 466 ], 467 'default' => 0, 468 'user_id' => $user->id, 469 ] 470 ); 471 } catch (Exception $event) { 472 // Fail gracefully 473 } 474 } 475 476 /** 477 * Decodes the options from a record into an options object. 478 * 479 * @param MfaTable $record The record to decode 480 * 481 * @return array 482 * @since 4.2.0 483 */ 484 private function decodeRecordOptions(MfaTable $record): array 485 { 486 $options = [ 487 'key' => '', 488 ]; 489 490 if (!empty($record->options)) { 491 $recordOptions = $record->options; 492 493 $options = array_merge($options, $recordOptions); 494 } 495 496 return $options; 497 } 498 499 /** 500 * Creates a new TOTP code based on secret key $key and sends it to the user via email. 501 * 502 * @param string $key The TOTP secret key 503 * @param User|null $user The Joomla! user to use 504 * 505 * @return void 506 * @throws Exception 507 * @since 4.2.0 508 */ 509 private function sendCode(string $key, ?User $user = null) 510 { 511 static $alreadySent = false; 512 513 // Make sure we have a user 514 if (!is_object($user) || !($user instanceof User)) { 515 $user = $this->getApplication()->getIdentity() 516 ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0); 517 } 518 519 if ($alreadySent) { 520 return; 521 } 522 523 $alreadySent = true; 524 525 // Get the API objects 526 $timeStep = min(max((int) $this->params->get('timestep', 120), 30), 900); 527 $totp = new Totp($timeStep, self::CODE_LENGTH, self::SECRET_KEY_LENGTH); 528 529 // Create the list of variable replacements 530 $code = $totp->getCode($key); 531 532 $replacements = [ 533 'code' => $code, 534 'sitename' => $this->getApplication()->get('sitename'), 535 'siteurl' => Uri::base(), 536 'username' => $user->username, 537 'email' => $user->email, 538 'fullname' => $user->name, 539 ]; 540 541 try { 542 $jLanguage = $this->getApplication()->getLanguage(); 543 $mailer = new MailTemplate('plg_multifactorauth_email.mail', $jLanguage->getTag()); 544 $mailer->addRecipient($user->email, $user->name); 545 $mailer->addTemplateData($replacements); 546 547 $didSend = $mailer->send(); 548 } catch (MailDisabledException | phpMailerException $exception) { 549 try { 550 Log::add(Text::_($exception->getMessage()), Log::WARNING, 'jerror'); 551 } catch (RuntimeException $exception) { 552 $this->getApplication()->enqueueMessage(Text::_($exception->errorMessage()), 'warning'); 553 } 554 } 555 556 try { 557 // The user somehow managed to not install the mail template. I'll send the email the traditional way. 558 if (isset($didSend) && !$didSend) { 559 $subject = Text::_('PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT'); 560 $body = Text::_('PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY'); 561 562 foreach ($replacements as $key => $value) { 563 $subject = str_replace('{' . strtoupper($key) . '}', $value, $subject); 564 $body = str_replace('{' . strtoupper($key) . '}', $value, $body); 565 } 566 567 $mailer = Factory::getMailer(); 568 $mailer->setSubject($subject); 569 $mailer->setBody($body); 570 $mailer->addRecipient($user->email, $user->name); 571 572 $mailer->Send(); 573 } 574 } catch (MailDisabledException | phpMailerException $exception) { 575 try { 576 Log::add(Text::_($exception->getMessage()), Log::WARNING, 'jerror'); 577 } catch (RuntimeException $exception) { 578 $this->getApplication()->enqueueMessage(Text::_($exception->errorMessage()), 'warning'); 579 } 580 } 581 } 582 }
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 |