[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/plugins/multifactorauth/email/src/Extension/ -> Email.php (source)

   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  }


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