[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/plugins/multifactorauth/yubikey/src/Extension/ -> Yubikey.php (source)

   1  <?php
   2  
   3  /**
   4   * @package     Joomla.Plugin
   5   * @subpackage  Multifactorauth.yubikey
   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\Yubikey\Extension;
  12  
  13  use Exception;
  14  use Joomla\CMS\Event\MultiFactor\Captive;
  15  use Joomla\CMS\Event\MultiFactor\GetMethod;
  16  use Joomla\CMS\Event\MultiFactor\GetSetup;
  17  use Joomla\CMS\Event\MultiFactor\SaveSetup;
  18  use Joomla\CMS\Event\MultiFactor\Validate;
  19  use Joomla\CMS\Http\HttpFactory;
  20  use Joomla\CMS\Language\Text;
  21  use Joomla\CMS\Plugin\CMSPlugin;
  22  use Joomla\CMS\Uri\Uri;
  23  use Joomla\CMS\User\User;
  24  use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
  25  use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
  26  use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
  27  use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
  28  use Joomla\Component\Users\Administrator\Table\MfaTable;
  29  use Joomla\Event\SubscriberInterface;
  30  use Joomla\Input\Input;
  31  use RuntimeException;
  32  
  33  // phpcs:disable PSR1.Files.SideEffects
  34  \defined('_JEXEC') or die;
  35  // phpcs:enable PSR1.Files.SideEffects
  36  
  37  /**
  38   * Joomla! Multi-factor Authentication using Yubikey Plugin
  39   *
  40   * @since 4.2.0
  41   */
  42  class Yubikey extends CMSPlugin implements SubscriberInterface
  43  {
  44      /**
  45       * Affects constructor behavior. If true, language files will be loaded automatically.
  46       *
  47       * @var    boolean
  48       * @since  3.2
  49       */
  50      protected $autoloadLanguage = true;
  51  
  52      /**
  53       * The MFA Method name handled by this plugin
  54       *
  55       * @var   string
  56       * @since 4.2.0
  57       */
  58      private $mfaMethodName = 'yubikey';
  59  
  60      /**
  61       * Should I try to detect and register legacy event listeners?
  62       *
  63       * @var   boolean
  64       * @since 4.2.0
  65       *
  66       * @deprecated
  67       */
  68      protected $allowLegacyListeners = false;
  69  
  70      /**
  71       * Returns an array of events this subscriber will listen to.
  72       *
  73       * @return  array
  74       *
  75       * @since   4.2.0
  76       */
  77      public static function getSubscribedEvents(): array
  78      {
  79          return [
  80              'onUserMultifactorGetMethod' => 'onUserMultifactorGetMethod',
  81              'onUserMultifactorCaptive'   => 'onUserMultifactorCaptive',
  82              'onUserMultifactorGetSetup'  => 'onUserMultifactorGetSetup',
  83              'onUserMultifactorSaveSetup' => 'onUserMultifactorSaveSetup',
  84              'onUserMultifactorValidate'  => 'onUserMultifactorValidate',
  85          ];
  86      }
  87  
  88  
  89      /**
  90       * Gets the identity of this MFA Method
  91       *
  92       * @param   GetMethod  $event  The event we are handling
  93       *
  94       * @return  void
  95       * @since   4.2.0
  96       */
  97      public function onUserMultifactorGetMethod(GetMethod $event): void
  98      {
  99          $event->addResult(
 100              new MethodDescriptor(
 101                  [
 102                      'name'               => $this->mfaMethodName,
 103                      'display'            => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'),
 104                      'shortinfo'          => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_SHORTINFO'),
 105                      'image'              => 'media/plg_multifactorauth_yubikey/images/yubikey.svg',
 106                      'allowEntryBatching' => true,
 107                  ]
 108              )
 109          );
 110      }
 111  
 112      /**
 113       * Returns the information which allows Joomla to render the Captive MFA page. This is the page
 114       * which appears right after you log in and asks you to validate your login with MFA.
 115       *
 116       * @param   Captive  $event  The event we are handling
 117       *
 118       * @return  void
 119       * @since   4.2.0
 120       */
 121      public function onUserMultifactorCaptive(Captive $event): void
 122      {
 123          /**
 124           * @var   MfaTable $record The record currently selected by the user.
 125           */
 126          $record = $event['record'];
 127  
 128          // Make sure we are actually meant to handle this Method
 129          if ($record->method != $this->mfaMethodName) {
 130              return;
 131          }
 132  
 133          $event->addResult(
 134              new CaptiveRenderOptions(
 135                  [
 136                      // Custom HTML to display above the MFA form
 137                      'pre_message'        => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_CAPTIVE_PROMPT'),
 138                      // How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
 139                      'field_type'         => 'input',
 140                      // The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
 141                      'input_type'         => 'text',
 142                      // Placeholder text for the HTML input box. Leave empty if you don't need it.
 143                      'placeholder'        => '',
 144                      // Label to show above the HTML input box. Leave empty if you don't need it.
 145                      'label'              => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_CODE_LABEL'),
 146                      // Custom HTML. Only used when field_type = custom.
 147                      'html'               => '',
 148                      // Custom HTML to display below the MFA form
 149                      'post_message'       => '',
 150                      // Allow authentication against all entries of this MFA Method.
 151                      'allowEntryBatching' => 1,
 152                  ]
 153              )
 154          );
 155      }
 156  
 157      /**
 158       * Returns the information which allows Joomla to render the MFA setup page. This is the page
 159       * which allows the user to add or modify a MFA Method for their user account. If the record
 160       * does not correspond to your plugin return an empty array.
 161       *
 162       * @param   GetSetup  $event  The event we are handling
 163       *
 164       * @return  void
 165       * @since   4.2.0
 166       */
 167      public function onUserMultifactorGetSetup(GetSetup $event): void
 168      {
 169          /**
 170           * @var   MfaTable $record The record currently selected by the user.
 171           */
 172          $record = $event['record'];
 173  
 174          // Make sure we are actually meant to handle this Method
 175          if ($record->method != $this->mfaMethodName) {
 176              return;
 177          }
 178  
 179          // Load the options from the record (if any)
 180          $options = $this->decodeRecordOptions($record);
 181          $keyID   = $options['id'] ?? '';
 182  
 183          if (empty($keyID)) {
 184              $event->addResult(
 185                  new SetupRenderOptions(
 186                      [
 187                          'default_title' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'),
 188                          'pre_message'   => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_INSTRUCTIONS'),
 189                          'field_type'    => 'input',
 190                          'input_type'    => 'text',
 191                          'input_value'   => $keyID,
 192                          'placeholder'   => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_PLACEHOLDER'),
 193                          'label'         => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_LABEL'),
 194                      ]
 195                  )
 196              );
 197          } else {
 198              $event->addResult(
 199                  new SetupRenderOptions(
 200                      [
 201                          'default_title' => Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE'),
 202                          'pre_message'   => Text::sprintf('PLG_MULTIFACTORAUTH_YUBIKEY_LBL_AFTERSETUP_INSTRUCTIONS', $keyID),
 203                          'input_type'    => 'hidden',
 204                      ]
 205                  )
 206              );
 207          }
 208      }
 209  
 210      /**
 211       * Parse the input from the MFA setup page and return the configuration information to be saved to the database. If
 212       * the information is invalid throw a RuntimeException to signal the need to display the editor page again. The
 213       * message of the exception will be displayed to the user. If the record does not correspond to your plugin return
 214       * an empty array.
 215       *
 216       * @param   SaveSetup  $event  The event we are handling
 217       *
 218       * @return  void The configuration data to save to the database
 219       * @throws  Exception
 220       * @since   4.2.0
 221       */
 222      public function onUserMultifactorSaveSetup(SaveSetup $event): void
 223      {
 224          /**
 225           * @var   MfaTable $record The record currently selected by the user.
 226           * @var   Input    $input  The user input you are going to take into account.
 227           */
 228          $record = $event['record'];
 229          $input  = $event['input'];
 230  
 231          // Make sure we are actually meant to handle this Method
 232          if ($record->method != $this->mfaMethodName) {
 233              return;
 234          }
 235  
 236          // Load the options from the record (if any)
 237          $options           = $this->decodeRecordOptions($record);
 238          $keyID             = $options['id'] ?? '';
 239          $isKeyAlreadySetup = !empty($keyID);
 240  
 241          /**
 242           * If the submitted code is 12 characters and identical to our existing key there is no change, perform no
 243           * further checks.
 244           */
 245          $code = $input->getString('code');
 246  
 247          if ($isKeyAlreadySetup || ((strlen($code) == 12) && ($code == $keyID))) {
 248              $event->addResult($options);
 249  
 250              return;
 251          }
 252  
 253          // If an empty code or something other than 44 characters was submitted I'm not having any of this!
 254          if (empty($code) || (strlen($code) != 44)) {
 255              throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED'), 500);
 256          }
 257  
 258          // Validate the code
 259          $isValid = $this->validateYubikeyOtp($code);
 260  
 261          if (!$isValid) {
 262              throw new RuntimeException(Text::_('PLG_MULTIFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED'), 500);
 263          }
 264  
 265          // The code is valid. Keep the Yubikey ID (first twelve characters)
 266          $keyID = substr($code, 0, 12);
 267  
 268          // Return the configuration to be serialized
 269          $event->addResult(['id' => $keyID]);
 270      }
 271  
 272      /**
 273       * Validates the Multi-factor Authentication code submitted by the user in the Multi-Factor
 274       * Authentication page. If the record does not correspond to your plugin return FALSE.
 275       *
 276       * @param   Validate  $event  The event we are handling
 277       *
 278       * @return  void
 279       * @throws  Exception
 280       * @since   4.2.0
 281       */
 282      public function onUserMultifactorValidate(Validate $event): void
 283      {
 284          /**
 285           * @var   MfaTable $record The MFA Method's record you're validating against
 286           * @var   User     $user   The user record
 287           * @var   string   $code   The submitted code
 288           */
 289          $record = $event['record'];
 290          $user   = $event['user'];
 291          $code   = $event['code'];
 292  
 293          // Make sure we are actually meant to handle this Method
 294          if ($record->method != $this->mfaMethodName) {
 295              $event->addResult(false);
 296  
 297              return;
 298          }
 299  
 300          // Double check the MFA Method is for the correct user
 301          if ($user->id != $record->user_id) {
 302              $event->addResult(false);
 303  
 304              return;
 305          }
 306  
 307          try {
 308              $records = MfaHelper::getUserMfaRecords($record->user_id);
 309              $records = array_filter(
 310                  $records,
 311                  function ($rec) use ($record) {
 312                      return $rec->method === $record->method;
 313                  }
 314              );
 315          } catch (Exception $e) {
 316              $records = [];
 317          }
 318  
 319          // Loop all records, stop if at least one matches
 320          $result = array_reduce(
 321              $records,
 322              function (bool $carry, $aRecord) use ($code) {
 323                  return $carry || $this->validateAgainstRecord($aRecord, $code);
 324              },
 325              false
 326          );
 327  
 328          $event->addResult($result);
 329      }
 330  
 331      /**
 332       * Validates a Yubikey OTP against the Yubikey servers
 333       *
 334       * @param   string  $otp  The OTP generated by your Yubikey
 335       *
 336       * @return  boolean  True if it's a valid OTP
 337       * @throws  Exception
 338       * @since   4.2.0
 339       */
 340      private function validateYubikeyOtp(string $otp): bool
 341      {
 342          // Let the user define a client ID and a secret key in the plugin's configuration
 343          $clientID    = $this->params->get('client_id', 1);
 344          $secretKey   = $this->params->get('secret', '');
 345          $serverQueue = trim($this->params->get('servers', ''));
 346  
 347          if (!empty($serverQueue)) {
 348              $serverQueue = explode("\r", $serverQueue);
 349          }
 350  
 351          if (empty($serverQueue)) {
 352              $serverQueue = [
 353                  'https://api.yubico.com/wsapi/2.0/verify',
 354                  'https://api2.yubico.com/wsapi/2.0/verify',
 355                  'https://api3.yubico.com/wsapi/2.0/verify',
 356                  'https://api4.yubico.com/wsapi/2.0/verify',
 357                  'https://api5.yubico.com/wsapi/2.0/verify',
 358              ];
 359          }
 360  
 361          shuffle($serverQueue);
 362  
 363          $gotResponse = false;
 364  
 365          $http     = HttpFactory::getHttp();
 366          $token    = $this->getApplication()->getFormToken();
 367          $nonce    = md5($token . uniqid(random_int(0, mt_getrandmax())));
 368          $response = null;
 369  
 370          while (!$gotResponse && !empty($serverQueue)) {
 371              $server = array_shift($serverQueue);
 372              $uri    = new Uri($server);
 373  
 374              // The client ID for signing the response
 375              $uri->setVar('id', $clientID);
 376  
 377              // The OTP we read from the user
 378              $uri->setVar('otp', $otp);
 379  
 380              // This prevents a REPLAYED_OTP status if the token doesn't change after a user submits an invalid OTP
 381              $uri->setVar('nonce', $nonce);
 382  
 383              // Minimum service level required: 50% (at least 50% of the YubiCloud servers must reply positively for the
 384              // OTP to validate)
 385              $uri->setVar('sl', 50);
 386  
 387              // Timeout waiting for YubiCloud servers to reply: 5 seconds.
 388              $uri->setVar('timeout', 5);
 389  
 390              // Set up the optional HMAC-SHA1 signature for the request.
 391              $this->signRequest($uri, $secretKey);
 392  
 393              if ($uri->hasVar('h')) {
 394                  $uri->setVar('h', urlencode($uri->getVar('h')));
 395              }
 396  
 397              try {
 398                  $response = $http->get($uri->toString(), [], 6);
 399  
 400                  if (!empty($response)) {
 401                      $gotResponse = true;
 402                  } else {
 403                      continue;
 404                  }
 405              } catch (Exception $exc) {
 406                  // No response, continue with the next server
 407                  continue;
 408              }
 409          }
 410  
 411          if (empty($response)) {
 412              $gotResponse = false;
 413          }
 414  
 415          // No server replied; we can't validate this OTP
 416          if (!$gotResponse) {
 417              return false;
 418          }
 419  
 420          // Parse response
 421          $lines = explode("\n", $response->body);
 422          $data  = [];
 423  
 424          foreach ($lines as $line) {
 425              $line  = trim($line);
 426              $parts = explode('=', $line, 2);
 427  
 428              if (count($parts) < 2) {
 429                  continue;
 430              }
 431  
 432              $data[$parts[0]] = $parts[1];
 433          }
 434  
 435          // Validate the signature
 436          $h       = $data['h'] ?? null;
 437          $fakeUri = Uri::getInstance('http://www.example.com');
 438          $fakeUri->setQuery($data);
 439          $this->signRequest($fakeUri, $secretKey);
 440          $calculatedH = $fakeUri->getVar('h', null);
 441  
 442          if ($calculatedH != $h) {
 443              return false;
 444          }
 445  
 446          // Validate the response - We need an OK message reply
 447          if ($data['status'] !== 'OK') {
 448              return false;
 449          }
 450  
 451          // Validate the response - We need a confidence level over 50%
 452          if ($data['sl'] < 50) {
 453              return false;
 454          }
 455  
 456          // Validate the response - The OTP must match
 457          if ($data['otp'] != $otp) {
 458              return false;
 459          }
 460  
 461          // Validate the response - The token must match
 462          if ($data['nonce'] != $nonce) {
 463              return false;
 464          }
 465  
 466          return true;
 467      }
 468  
 469      /**
 470       * Sign the request to YubiCloud.
 471       *
 472       * @param   Uri     $uri     The request URI to sign
 473       * @param   string  $secret  The secret key to sign with
 474       *
 475       * @return  void
 476       * @since   4.2.0
 477       *
 478       * @see     https://developers.yubico.com/yubikey-val/Validation_Protocol_V2.0.html
 479       */
 480      private function signRequest(Uri $uri, string $secret): void
 481      {
 482          // Make sure we have an encoding secret
 483          $secret = trim($secret);
 484  
 485          if (empty($secret)) {
 486              return;
 487          }
 488  
 489          // I will need base64 encoding and decoding
 490          if (!function_exists('base64_encode') || !function_exists('base64_decode')) {
 491              return;
 492          }
 493  
 494          // I need HMAC-SHA-1 support. Therefore I check for HMAC and SHA1 support in the PHP 'hash' extension.
 495          if (!function_exists('hash_hmac') || !function_exists('hash_algos')) {
 496              return;
 497          }
 498  
 499          $algos = hash_algos();
 500  
 501          if (!in_array('sha1', $algos)) {
 502              return;
 503          }
 504  
 505          // Get the parameters
 506          /** @var   array $vars I have to explicitly state the type because the Joomla docblock is wrong :( */
 507          $vars = $uri->getQuery(true);
 508  
 509          // 'h' is the hash and it doesn't participate in the calculation of itself.
 510          if (isset($vars['h'])) {
 511              unset($vars['h']);
 512          }
 513  
 514          // Alphabetically sort the set of key/value pairs by key order.
 515          ksort($vars);
 516  
 517          /**
 518           * Construct a single line with each ordered key/value pair concatenated using &, and each key and value
 519           * concatenated with =. Do not add any line breaks. Do not add whitespace.
 520           *
 521           * Now, if you thought I can't really write PHP code, a.k.a. why not use http_build_query, read on.
 522           *
 523           * The way YubiKey expects the query to be built is UTTERLY WRONG. They are doing string concatenation, not
 524           * URL query building! Therefore you cannot use http_build_query(). Instead, you need to use dumb string
 525           * concatenation. I kid you not. If you want to laugh (or cry) read their Auth_Yubico class. It's 1998 all over
 526           * again.
 527           */
 528          $stringToSign = '';
 529  
 530          foreach ($vars as $k => $v) {
 531              $stringToSign .= '&' . $k . '=' . $v;
 532          }
 533  
 534          $stringToSign = ltrim($stringToSign, '&');
 535  
 536          /**
 537           * Apply the HMAC-SHA-1 algorithm on the line as an octet string using the API key as key (remember to
 538           * base64decode the API key obtained from Yubico).
 539           */
 540          $decodedKey = base64_decode($secret);
 541          $hash       = hash_hmac('sha1', $stringToSign, $decodedKey, true);
 542  
 543          /**
 544           * Base 64 encode the resulting value according to RFC 4648, for example, t2ZMtKeValdA+H0jVpj3LIichn4=
 545           */
 546          $h = base64_encode($hash);
 547  
 548          /**
 549           * Append the value under key h to the message.
 550           */
 551          $uri->setVar('h', $h);
 552      }
 553  
 554      /**
 555       * Decodes the options from a record into an options object.
 556       *
 557       * @param   MfaTable  $record  The record to decode
 558       *
 559       * @return  array
 560       * @since   4.2.0
 561       */
 562      private function decodeRecordOptions(MfaTable $record): array
 563      {
 564          $options = [
 565              'id' => '',
 566          ];
 567  
 568          if (!empty($record->options)) {
 569              $recordOptions = $record->options;
 570  
 571              $options = array_merge($options, $recordOptions);
 572          }
 573  
 574          return $options;
 575      }
 576  
 577      /**
 578       * @param   MfaTable  $record  The record to validate against
 579       * @param   string    $code    The code given to us by the user
 580       *
 581       * @return  boolean
 582       * @throws  Exception
 583       * @since   4.2.0
 584       */
 585      private function validateAgainstRecord(MfaTable $record, string $code): bool
 586      {
 587          // Load the options from the record (if any)
 588          $options = $this->decodeRecordOptions($record);
 589          $keyID   = $options['id'] ?? '';
 590  
 591          // If there is no key in the options throw an error
 592          if (empty($keyID)) {
 593              return false;
 594          }
 595  
 596          // If the submitted code is empty throw an error
 597          if (empty($code)) {
 598              return false;
 599          }
 600  
 601          // If the submitted code length is wrong throw an error
 602          if (strlen($code) != 44) {
 603              return false;
 604          }
 605  
 606          // If the submitted code's key ID does not match the stored throw an error
 607          if (substr($code, 0, 12) != $keyID) {
 608              return false;
 609          }
 610  
 611          // Check the OTP code for validity
 612          return $this->validateYubikeyOtp($code);
 613      }
 614  }


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