[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/plugins/multifactorauth/webauthn/src/Extension/ -> Webauthn.php (source)

   1  <?php
   2  
   3  /**
   4   * @package     Joomla.Plugin
   5   * @subpackage  Multifactorauth.webauthn
   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\Webauthn\Extension;
  12  
  13  use Exception;
  14  use Joomla\CMS\Application\CMSApplication;
  15  use Joomla\CMS\Event\MultiFactor\Captive;
  16  use Joomla\CMS\Event\MultiFactor\GetMethod;
  17  use Joomla\CMS\Event\MultiFactor\GetSetup;
  18  use Joomla\CMS\Event\MultiFactor\SaveSetup;
  19  use Joomla\CMS\Event\MultiFactor\Validate;
  20  use Joomla\CMS\Factory;
  21  use Joomla\CMS\Language\Text;
  22  use Joomla\CMS\Plugin\CMSPlugin;
  23  use Joomla\CMS\Plugin\PluginHelper;
  24  use Joomla\CMS\Uri\Uri;
  25  use Joomla\CMS\User\User;
  26  use Joomla\CMS\User\UserFactoryInterface;
  27  use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
  28  use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
  29  use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
  30  use Joomla\Component\Users\Administrator\Table\MfaTable;
  31  use Joomla\Event\SubscriberInterface;
  32  use Joomla\Input\Input;
  33  use Joomla\Plugin\Multifactorauth\Webauthn\Helper\Credentials;
  34  use RuntimeException;
  35  use Webauthn\PublicKeyCredentialRequestOptions;
  36  
  37  // phpcs:disable PSR1.Files.SideEffects
  38  \defined('_JEXEC') or die;
  39  // phpcs:enable PSR1.Files.SideEffects
  40  
  41  /**
  42   * Joomla Multi-factor Authentication plugin for WebAuthn
  43   *
  44   * @since 4.2.0
  45   */
  46  class Webauthn extends CMSPlugin implements SubscriberInterface
  47  {
  48      /**
  49       * Auto-load the plugin's language files
  50       *
  51       * @var    boolean
  52       * @since  4.2.0
  53       */
  54      protected $autoloadLanguage = true;
  55  
  56      /**
  57       * The MFA Method name handled by this plugin
  58       *
  59       * @var   string
  60       * @since  4.2.0
  61       */
  62      private $mfaMethodName = 'webauthn';
  63  
  64      /**
  65       * Returns an array of events this subscriber will listen to.
  66       *
  67       * @return  array
  68       *
  69       * @since  4.2.0
  70       */
  71      public static function getSubscribedEvents(): array
  72      {
  73          return [
  74              'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
  75              'onUserMultifactorCaptive'   => 'onUserMultifactorCaptive',
  76              'onUserMultifactorGetSetup'  => 'onUserMultifactorGetSetup',
  77              'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
  78              'onUserMultifactorValidate'  => 'onUserMultifactorValidate',
  79          ];
  80      }
  81  
  82      /**
  83       * Gets the identity of this MFA Method
  84       *
  85       * @param   GetMethod  $event  The event we are handling
  86       *
  87       * @return  void
  88       * @since   4.2.0
  89       */
  90      public function onUserMultifactorGetMethod(GetMethod $event): void
  91      {
  92          $event->addResult(
  93              new MethodDescriptor(
  94                  [
  95                      'name'               => $this->mfaMethodName,
  96                      'display'            => Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_DISPLAYEDAS'),
  97                      'shortinfo'          => Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_SHORTINFO'),
  98                      'image'              => 'media/plg_multifactorauth_webauthn/images/webauthn.svg',
  99                      'allowMultiple'      => true,
 100                      'allowEntryBatching' => true,
 101                  ]
 102              )
 103          );
 104      }
 105  
 106      /**
 107       * Returns the information which allows Joomla to render the MFA setup page. This is the page
 108       * which allows the user to add or modify a MFA Method for their user account. If the record
 109       * does not correspond to your plugin return an empty array.
 110       *
 111       * @param   GetSetup  $event  The event we are handling
 112       *
 113       * @return  void
 114       * @throws  Exception
 115       * @since   4.2.0
 116       */
 117      public function onUserMultifactorGetSetup(GetSetup $event): void
 118      {
 119          /**
 120           * @var   MfaTable $record The record currently selected by the user.
 121           */
 122          $record = $event['record'];
 123  
 124          // Make sure we are actually meant to handle this Method
 125          if ($record->method != $this->mfaMethodName) {
 126              return;
 127          }
 128  
 129          // Get some values assuming that we are NOT setting up U2F (the key is already registered)
 130          $submitClass = '';
 131          $submitIcon  = 'icon icon-ok';
 132          $submitText  = 'JSAVE';
 133          $preMessage  = Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_CONFIGURED');
 134          $type        = 'input';
 135          $html        = '';
 136          $hiddenData  = [];
 137  
 138          /**
 139           * If there are no authenticators set up yet I need to show a different message and take a different action when
 140           * my user clicks the submit button.
 141           */
 142          if (!is_array($record->options) || empty($record->options['credentialId'] ?? '')) {
 143              $document = $this->getApplication()->getDocument();
 144              $wam      = $document->getWebAssetManager();
 145              $wam->getRegistry()->addExtensionRegistryFile('plg_multifactorauth_webauthn');
 146  
 147              $layoutPath = PluginHelper::getLayoutPath('multifactorauth', 'webauthn');
 148              ob_start();
 149              include $layoutPath;
 150              $html = ob_get_clean();
 151              $type = 'custom';
 152  
 153              // Load JS translations
 154              Text::script('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD');
 155  
 156              $document->addScriptOptions('com_users.pagetype', 'setup', false);
 157  
 158              // Save the WebAuthn request to the session
 159              $user                    = Factory::getApplication()->getIdentity()
 160                  ?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
 161              $hiddenData['pkRequest'] = base64_encode(Credentials::requestAttestation($user));
 162  
 163              // Special button handling
 164              $submitClass = "multifactorauth_webauthn_setup";
 165              $submitIcon  = 'icon icon-lock';
 166              $submitText  = 'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_REGISTERKEY';
 167  
 168              // Message to display
 169              $preMessage = Text::sprintf(
 170                  'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_INSTRUCTIONS',
 171                  Text::_($submitText)
 172              );
 173          }
 174  
 175          $event->addResult(
 176              new SetupRenderOptions(
 177                  [
 178                      'default_title' => Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_DISPLAYEDAS'),
 179                      'pre_message'   => $preMessage,
 180                      'hidden_data'   => $hiddenData,
 181                      'field_type'    => $type,
 182                      'input_type'    => 'hidden',
 183                      'html'          => $html,
 184                      'show_submit'   => true,
 185                      'submit_class'  => $submitClass,
 186                      'submit_icon'   => $submitIcon,
 187                      'submit_text'   => $submitText,
 188                  ]
 189              )
 190          );
 191      }
 192  
 193      /**
 194       * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
 195       * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
 196       * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
 197       * an empty array.
 198       *
 199       * @param   SaveSetup  $event  The event we are handling
 200       *
 201       * @return  void The configuration data to save to the database
 202       * @since   4.2.0
 203       */
 204      public function onUserMultifactorSaveSetup(SaveSetup $event): void
 205      {
 206          /**
 207           * @var   MfaTable $record The record currently selected by the user.
 208           * @var   Input    $input  The user input you are going to take into account.
 209           */
 210          $record = $event['record'];
 211          $input  = $event['input'];
 212  
 213          // Make sure we are actually meant to handle this Method
 214          if ($record->method != $this->mfaMethodName) {
 215              return;
 216          }
 217  
 218          // Editing an existing authenticator: only the title is saved
 219          if (is_array($record->options) && !empty($record->options['credentialId'] ?? '')) {
 220              $event->addResult($record->options);
 221  
 222              return;
 223          }
 224  
 225          $code                = $input->get('code', null, 'base64');
 226          $session             = $this->getApplication()->getSession();
 227          $registrationRequest = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions', null);
 228  
 229          // If there was no registration request BUT there is a registration response throw an error
 230          if (empty($registrationRequest) && !empty($code)) {
 231              throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
 232          }
 233  
 234          // If there is no registration request (and there isn't a registration response) we are just saving the title.
 235          if (empty($registrationRequest)) {
 236              $event->addResult($record->options);
 237  
 238              return;
 239          }
 240  
 241          // In any other case try to authorize the registration
 242          try {
 243              $publicKeyCredentialSource = Credentials::verifyAttestation($code);
 244          } catch (Exception $err) {
 245              throw new RuntimeException($err->getMessage(), 403);
 246          } finally {
 247              // Unset the request data from the session.
 248              $session->set('plg_multifactorauth_webauthn.publicKeyCredentialCreationOptions', null);
 249              $session->set('plg_multifactorauth_webauthn.registration_user_id', null);
 250          }
 251  
 252          // Return the configuration to be serialized
 253          $event->addResult(
 254              [
 255                  'credentialId' => base64_encode($publicKeyCredentialSource->getAttestedCredentialData()->getCredentialId()),
 256                  'pubkeysource' => json_encode($publicKeyCredentialSource),
 257                  'counter'      => 0,
 258              ]
 259          );
 260      }
 261  
 262      /**
 263       * Returns the information which allows Joomla to render the Captive MFA page. This is the page
 264       * which appears right after you log in and asks you to validate your login with MFA.
 265       *
 266       * @param   Captive  $event  The event we are handling
 267       *
 268       * @return  void
 269       * @throws Exception
 270       * @since   4.2.0
 271       */
 272      public function onUserMultifactorCaptive(Captive $event): void
 273      {
 274          /**
 275           * @var   MfaTable $record The record currently selected by the user.
 276           */
 277          $record = $event['record'];
 278  
 279          // Make sure we are actually meant to handle this Method
 280          if ($record->method != $this->mfaMethodName) {
 281              return;
 282          }
 283  
 284          /**
 285           * The following code looks stupid. An explanation is in order.
 286           *
 287           * What we normally want to do is save the authentication data returned by getAuthenticateData into the session.
 288           * This is what is sent to the authenticator through the Javascript API and signed. The signature is posted back
 289           * to the form as the "code" which is read by onUserMultifactorauthValidate. That Method will read the authentication
 290           * data from the session and pass it along with the key registration data (from the database) and the
 291           * authentication response (the "code" submitted in the form) to the WebAuthn library for validation.
 292           *
 293           * Validation will work as long as the challenge recorded in the encrypted AUTHENTICATION RESPONSE matches, upon
 294           * decryption, the challenge recorded in the AUTHENTICATION DATA.
 295           *
 296           * I observed that for whatever stupid reason the browser was sometimes sending TWO requests to the server's
 297           * Captive login page but only rendered the FIRST. This meant that the authentication data sent to the key had
 298           * already been overwritten in the session by the "invisible" second request. As a result the challenge would
 299           * not match and we'd get a validation error.
 300           *
 301           * The code below will attempt to read the authentication data from the session first. If it exists it will NOT
 302           * try to replace it (technically it replaces it with a copy of the same data - same difference!). If nothing
 303           * exists in the session, however, it WILL store the (random seeded) result of the getAuthenticateData Method.
 304           * Therefore the first request to the Captive login page will store a new set of authentication data whereas the
 305           * second, "invisible", request will just reuse the same data as the first request, fixing the observed issue in
 306           * a way that doesn't compromise security.
 307           *
 308           * In case you are wondering, yes, the data is removed from the session in the onUserMultifactorauthValidate Method.
 309           * In fact it's the first thing we do after reading it, preventing constant reuse of the same set of challenges.
 310           *
 311           * That was fun to debug - for "poke your eyes with a rusty fork" values of fun.
 312           */
 313  
 314          $session          = $this->getApplication()->getSession();
 315          $pkOptionsEncoded = $session->get('plg_multifactorauth_webauthn.publicKeyCredentialRequestOptions', null);
 316  
 317          $force = $this->getApplication()->input->getInt('force', 0);
 318  
 319          try {
 320              if ($force) {
 321                  throw new RuntimeException('Expected exception (good): force a new key request');
 322              }
 323  
 324              if (empty($pkOptionsEncoded)) {
 325                  throw new RuntimeException('Expected exception (good): we do not have a pending key request');
 326              }
 327  
 328              $serializedOptions = base64_decode($pkOptionsEncoded);
 329              $pkOptions         = unserialize($serializedOptions);
 330  
 331              if (!is_object($pkOptions) || empty($pkOptions) || !($pkOptions instanceof PublicKeyCredentialRequestOptions)) {
 332                  throw new RuntimeException('The pending key request is corrupt; a new one will be created');
 333              }
 334  
 335              $pkRequest = json_encode($pkOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
 336          } catch (Exception $e) {
 337              $pkRequest = Credentials::requestAssertion($record->user_id);
 338          }
 339  
 340          $document = $this->getApplication()->getDocument();
 341          $wam      = $document->getWebAssetManager();
 342          $wam->getRegistry()->addExtensionRegistryFile('plg_multifactorauth_webauthn');
 343  
 344          try {
 345              /** @var CMSApplication $app */
 346              $app = Factory::getApplication();
 347              $app->getDocument()->addScriptOptions('com_users.authData', base64_encode($pkRequest), false);
 348              $layoutPath = PluginHelper::getLayoutPath('multifactorauth', 'webauthn');
 349              ob_start();
 350              include $layoutPath;
 351              $html = ob_get_clean();
 352          } catch (Exception $e) {
 353              return;
 354          }
 355  
 356          // Load JS translations
 357          Text::script('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD');
 358          Text::script('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NO_STORED_CREDENTIAL');
 359  
 360          $document->addScriptOptions('com_users.pagetype', 'validate', false);
 361  
 362          $event->addResult(
 363              new CaptiveRenderOptions(
 364                  [
 365                      'pre_message'        => Text::sprintf(
 366                          'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_INSTRUCTIONS',
 367                          Text::_('PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_VALIDATEKEY')
 368                      ),
 369                      'field_type'         => 'custom',
 370                      'input_type'         => 'hidden',
 371                      'placeholder'        => '',
 372                      'label'              => '',
 373                      'html'               => $html,
 374                      'post_message'       => '',
 375                      'hide_submit'        => false,
 376                      'submit_icon'        => 'icon icon-lock',
 377                      'submit_text'        => 'PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_VALIDATEKEY',
 378                      'allowEntryBatching' => true,
 379                  ]
 380              )
 381          );
 382      }
 383  
 384      /**
 385       * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
 386       * Authentication page. If the record does not correspond to your plugin return FALSE.
 387       *
 388       * @param   Validate  $event  The event we are handling
 389       *
 390       * @return  void
 391       * @since   4.2.0
 392       */
 393      public function onUserMultifactorValidate(Validate $event): void
 394      {
 395          // This method is only available on HTTPS
 396          if (Uri::getInstance()->getScheme() !== 'https') {
 397              $event->addResult(false);
 398  
 399              return;
 400          }
 401  
 402          /**
 403           * @var   MfaTable $record The MFA Method's record you're validating against
 404           * @var   User     $user   The user record
 405           * @var   string   $code   The submitted code
 406           */
 407          $record = $event['record'];
 408          $user   = $event['user'];
 409          $code   = $event['code'];
 410  
 411          // Make sure we are actually meant to handle this Method
 412          if ($record->method != $this->mfaMethodName) {
 413              $event->addResult(false);
 414  
 415              return;
 416          }
 417  
 418          // Double check the MFA Method is for the correct user
 419          if ($user->id != $record->user_id) {
 420              $event->addResult(false);
 421  
 422              return;
 423          }
 424  
 425          try {
 426              Credentials::verifyAssertion($code);
 427          } catch (Exception $e) {
 428              try {
 429                  $this->getApplication()->enqueueMessage($e->getMessage(), 'error');
 430              } catch (Exception $e) {
 431              }
 432  
 433              $event->addResult(false);
 434  
 435              return;
 436          }
 437  
 438          $event->addResult(true);
 439      }
 440  }


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