[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/plugins/system/webauthn/src/ -> CredentialRepository.php (source)

   1  <?php
   2  
   3  /**
   4   * @package     Joomla.Plugin
   5   * @subpackage  System.Webauthn
   6   *
   7   * @copyright   (C) 2020 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\System\Webauthn;
  12  
  13  use Exception;
  14  use InvalidArgumentException;
  15  use Joomla\CMS\Date\Date;
  16  use Joomla\CMS\Encrypt\Aes;
  17  use Joomla\CMS\Factory;
  18  use Joomla\CMS\Language\Text;
  19  use Joomla\CMS\User\UserFactoryInterface;
  20  use Joomla\Database\DatabaseAwareInterface;
  21  use Joomla\Database\DatabaseAwareTrait;
  22  use Joomla\Database\DatabaseDriver;
  23  use Joomla\Database\DatabaseInterface;
  24  use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
  25  use Joomla\Registry\Registry;
  26  use JsonException;
  27  use RuntimeException;
  28  use Throwable;
  29  use Webauthn\PublicKeyCredentialSource;
  30  use Webauthn\PublicKeyCredentialSourceRepository;
  31  use Webauthn\PublicKeyCredentialUserEntity;
  32  
  33  // phpcs:disable PSR1.Files.SideEffects
  34  \defined('_JEXEC') or die;
  35  // phpcs:enable PSR1.Files.SideEffects
  36  
  37  /**
  38   * Handles the storage of WebAuthn credentials in the database
  39   *
  40   * @since   4.0.0
  41   */
  42  final class CredentialRepository implements PublicKeyCredentialSourceRepository, DatabaseAwareInterface
  43  {
  44      use DatabaseAwareTrait;
  45  
  46      /**
  47       * Public constructor.
  48       *
  49       * @param   DatabaseInterface|null  $db  The database driver object to use for persistence.
  50       *
  51       * @since   4.2.0
  52       */
  53      public function __construct(DatabaseInterface $db = null)
  54      {
  55          $this->setDatabase($db);
  56      }
  57  
  58      /**
  59       * Returns a PublicKeyCredentialSource object given the public key credential ID
  60       *
  61       * @param   string  $publicKeyCredentialId  The identified of the public key credential we're searching for
  62       *
  63       * @return  PublicKeyCredentialSource|null
  64       *
  65       * @since   4.0.0
  66       */
  67      public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
  68      {
  69          /** @var DatabaseDriver $db */
  70          $db           = $this->getDatabase();
  71          $credentialId = base64_encode($publicKeyCredentialId);
  72          $query        = $db->getQuery(true)
  73              ->select($db->qn('credential'))
  74              ->from($db->qn('#__webauthn_credentials'))
  75              ->where($db->qn('id') . ' = :credentialId')
  76              ->bind(':credentialId', $credentialId);
  77  
  78          $encrypted = $db->setQuery($query)->loadResult();
  79  
  80          if (empty($encrypted)) {
  81              return null;
  82          }
  83  
  84          $json = $this->decryptCredential($encrypted);
  85  
  86          try {
  87              return PublicKeyCredentialSource::createFromArray(json_decode($json, true));
  88          } catch (Throwable $e) {
  89              return null;
  90          }
  91      }
  92  
  93      /**
  94       * Returns all PublicKeyCredentialSource objects given a user entity. We only use the `id` property of the user
  95       * entity, cast to integer, as the Joomla user ID by which records are keyed in the database table.
  96       *
  97       * @param   PublicKeyCredentialUserEntity  $publicKeyCredentialUserEntity  Public key credential user entity record
  98       *
  99       * @return  PublicKeyCredentialSource[]
 100       *
 101       * @since  4.0.0
 102       */
 103      public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
 104      {
 105          /** @var DatabaseDriver $db */
 106          $db         = $this->getDatabase();
 107          $userHandle = $publicKeyCredentialUserEntity->getId();
 108          $query      = $db->getQuery(true)
 109              ->select('*')
 110              ->from($db->qn('#__webauthn_credentials'))
 111              ->where($db->qn('user_id') . ' = :user_id')
 112              ->bind(':user_id', $userHandle);
 113  
 114          try {
 115              $records = $db->setQuery($query)->loadAssocList();
 116          } catch (Exception $e) {
 117              return [];
 118          }
 119  
 120          /**
 121           * Converts invalid credential records to PublicKeyCredentialSource objects, or null if they
 122           * are invalid.
 123           *
 124           * This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to
 125           * figure out the correct indentation :)
 126           *
 127           * @param   array  $record  The record to convert
 128           *
 129           * @return  PublicKeyCredentialSource|null
 130           */
 131          $recordsMapperClosure = function ($record) {
 132              try {
 133                  $json = $this->decryptCredential($record['credential']);
 134                  $data = json_decode($json, true);
 135              } catch (JsonException $e) {
 136                  return null;
 137              }
 138  
 139              if (empty($data)) {
 140                  return null;
 141              }
 142  
 143              try {
 144                  return PublicKeyCredentialSource::createFromArray($data);
 145              } catch (InvalidArgumentException $e) {
 146                  return null;
 147              }
 148          };
 149  
 150          $records = array_map($recordsMapperClosure, $records);
 151  
 152          /**
 153           * Filters the list of records to only keep valid entries.
 154           *
 155           * Only array members that are PublicKeyCredentialSource objects survive the filter.
 156           *
 157           * This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to
 158           * figure out the correct indentation :)
 159           *
 160           * @param  PublicKeyCredentialSource|mixed  $record  The record to filter
 161           *
 162           * @return boolean
 163           */
 164          $filterClosure = function ($record) {
 165              return !\is_null($record) && \is_object($record) && ($record instanceof PublicKeyCredentialSource);
 166          };
 167  
 168          return array_filter($records, $filterClosure);
 169      }
 170  
 171      /**
 172       * Add or update an attested credential for a given user.
 173       *
 174       * @param   PublicKeyCredentialSource  $publicKeyCredentialSource  The public key credential
 175       *                                                                 source to store
 176       *
 177       * @return  void
 178       *
 179       * @throws Exception
 180       * @since   4.0.0
 181       */
 182      public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
 183      {
 184          // Default values for saving a new credential source
 185          /** @var Webauthn $plugin */
 186          $plugin              = Factory::getApplication()->bootPlugin('webauthn', 'system');
 187          $knownAuthenticators = $plugin->getAuthenticationHelper()->getKnownAuthenticators();
 188          $aaguid              = (string) ($publicKeyCredentialSource->getAaguid() ?? '');
 189          $defaultName         = ($knownAuthenticators[$aaguid] ?? $knownAuthenticators[''])->description;
 190          $credentialId        = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
 191          $user                = Factory::getApplication()->getIdentity();
 192          $o                   = (object) [
 193              'id'         => $credentialId,
 194              'user_id'    => $this->getHandleFromUserId($user->id),
 195              'label'      => Text::sprintf(
 196                  'PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL',
 197                  $defaultName,
 198                  $this->formatDate('now')
 199              ),
 200              'credential' => json_encode($publicKeyCredentialSource),
 201          ];
 202          $update              = false;
 203  
 204          /** @var DatabaseDriver $db */
 205          $db     = $this->getDatabase();
 206  
 207          // Try to find an existing record
 208          try {
 209              $query     = $db->getQuery(true)
 210                  ->select('*')
 211                  ->from($db->qn('#__webauthn_credentials'))
 212                  ->where($db->qn('id') . ' = :credentialId')
 213                  ->bind(':credentialId', $credentialId);
 214              $oldRecord = $db->setQuery($query)->loadObject();
 215  
 216              if (\is_null($oldRecord)) {
 217                  throw new Exception('This is a new record');
 218              }
 219  
 220              /**
 221               * Sanity check. The existing credential source must have the same user handle as the one I am trying to
 222               * save. Otherwise something fishy is going on.
 223               */
 224              if ($oldRecord->user_id != $publicKeyCredentialSource->getUserHandle()) {
 225                  throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE'));
 226              }
 227  
 228              $o->user_id = $oldRecord->user_id;
 229              $o->label   = $oldRecord->label;
 230              $update     = true;
 231          } catch (Exception $e) {
 232          }
 233  
 234          $o->credential = $this->encryptCredential($o->credential);
 235  
 236          if ($update) {
 237              $db->updateObject('#__webauthn_credentials', $o, ['id']);
 238  
 239              return;
 240          }
 241  
 242          /**
 243           * This check is deliberately skipped for updates. When logging in the underlying library will try to save the
 244           * credential source. This is necessary to update the last known authenticator signature counter which prevents
 245           * replay attacks. When we are saving a new record, though, we have to make sure we are not a guest user. Hence
 246           * the check below.
 247           */
 248          if ((\is_null($user) || $user->guest)) {
 249              throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CANT_STORE_FOR_GUEST'));
 250          }
 251  
 252          $db->insertObject('#__webauthn_credentials', $o);
 253      }
 254  
 255      /**
 256       * Get all credential information for a given user ID. This is meant to only be used for displaying records.
 257       *
 258       * @param   int  $userId  The user ID
 259       *
 260       * @return  array
 261       *
 262       * @since   4.0.0
 263       */
 264      public function getAll(int $userId): array
 265      {
 266          /** @var DatabaseDriver $db */
 267          $db         = $this->getDatabase();
 268          $userHandle = $this->getHandleFromUserId($userId);
 269          $query      = $db->getQuery(true)
 270              ->select('*')
 271              ->from($db->qn('#__webauthn_credentials'))
 272              ->where($db->qn('user_id') . ' = :user_id')
 273              ->bind(':user_id', $userHandle);
 274  
 275          try {
 276              $results = $db->setQuery($query)->loadAssocList();
 277          } catch (Exception $e) {
 278              return [];
 279          }
 280  
 281          if (empty($results)) {
 282              return [];
 283          }
 284  
 285          /**
 286           * Decodes the credentials on each record.
 287           *
 288           * @param   array  $record  The record to convert
 289           *
 290           * @return  array
 291           * @since   4.2.0
 292           */
 293          $recordsMapperClosure = function ($record) {
 294              try {
 295                  $json = $this->decryptCredential($record['credential']);
 296                  $data = json_decode($json, true);
 297              } catch (JsonException $e) {
 298                  $record['credential'] = null;
 299  
 300                  return $record;
 301              }
 302  
 303              if (empty($data)) {
 304                  $record['credential'] = null;
 305  
 306                  return $record;
 307              }
 308  
 309              try {
 310                  $record['credential'] = PublicKeyCredentialSource::createFromArray($data);
 311  
 312                  return $record;
 313              } catch (InvalidArgumentException $e) {
 314                  $record['credential'] = null;
 315  
 316                  return $record;
 317              }
 318          };
 319  
 320          return array_map($recordsMapperClosure, $results);
 321      }
 322  
 323      /**
 324       * Do we have stored credentials under the specified Credential ID?
 325       *
 326       * @param   string  $credentialId  The ID of the credential to check for existence
 327       *
 328       * @return  boolean
 329       *
 330       * @since   4.0.0
 331       */
 332      public function has(string $credentialId): bool
 333      {
 334          /** @var DatabaseDriver $db */
 335          $db           = $this->getDatabase();
 336          $credentialId = base64_encode($credentialId);
 337          $query        = $db->getQuery(true)
 338              ->select('COUNT(*)')
 339              ->from($db->qn('#__webauthn_credentials'))
 340              ->where($db->qn('id') . ' = :credentialId')
 341              ->bind(':credentialId', $credentialId);
 342  
 343          try {
 344              $count = $db->setQuery($query)->loadResult();
 345  
 346              return $count > 0;
 347          } catch (Exception $e) {
 348              return false;
 349          }
 350      }
 351  
 352      /**
 353       * Update the human readable label of a credential
 354       *
 355       * @param   string  $credentialId  The credential ID
 356       * @param   string  $label         The human readable label to set
 357       *
 358       * @return  void
 359       *
 360       * @since   4.0.0
 361       */
 362      public function setLabel(string $credentialId, string $label): void
 363      {
 364          /** @var DatabaseDriver $db */
 365          $db           = $this->getDatabase();
 366          $credentialId = base64_encode($credentialId);
 367          $o            = (object) [
 368              'id'    => $credentialId,
 369              'label' => $label,
 370          ];
 371  
 372          $db->updateObject('#__webauthn_credentials', $o, ['id'], false);
 373      }
 374  
 375      /**
 376       * Remove stored credentials
 377       *
 378       * @param   string  $credentialId  The credentials ID to remove
 379       *
 380       * @return  void
 381       *
 382       * @since   4.0.0
 383       */
 384      public function remove(string $credentialId): void
 385      {
 386          if (!$this->has($credentialId)) {
 387              return;
 388          }
 389  
 390          /** @var DatabaseDriver $db */
 391          $db           = $this->getDatabase();
 392          $credentialId = base64_encode($credentialId);
 393          $query        = $db->getQuery(true)
 394              ->delete($db->qn('#__webauthn_credentials'))
 395              ->where($db->qn('id') . ' = :credentialId')
 396              ->bind(':credentialId', $credentialId);
 397  
 398          $db->setQuery($query)->execute();
 399      }
 400  
 401      /**
 402       * Return the user handle for the stored credential given its ID.
 403       *
 404       * The user handle must not be personally identifiable. Per https://w3c.github.io/webauthn/#user-handle it is
 405       * acceptable to have a salted hash with a salt private to our server, e.g. Joomla's secret. The only immutable
 406       * information in Joomla is the user ID so that's what we will be using.
 407       *
 408       * @param   string  $credentialId  The credential ID to get the user handle for
 409       *
 410       * @return  string
 411       *
 412       * @since   4.0.0
 413       */
 414      public function getUserHandleFor(string $credentialId): string
 415      {
 416          $publicKeyCredentialSource = $this->findOneByCredentialId($credentialId);
 417  
 418          if (empty($publicKeyCredentialSource)) {
 419              return '';
 420          }
 421  
 422          return $publicKeyCredentialSource->getUserHandle();
 423      }
 424  
 425      /**
 426       * Return a user handle given an integer Joomla user ID. We use the HMAC-SHA-256 of the user ID with the site's
 427       * secret as the key. Using it instead of SHA-512 is on purpose! WebAuthn only allows user handles up to 64 bytes
 428       * long.
 429       *
 430       * @param   int  $id  The user ID to convert
 431       *
 432       * @return  string  The user handle (HMAC-SHA-256 of the user ID)
 433       *
 434       * @since   4.0.0
 435       */
 436      public function getHandleFromUserId(int $id): string
 437      {
 438          $key  = $this->getEncryptionKey();
 439          $data = sprintf('%010u', $id);
 440  
 441          return hash_hmac('sha256', $data, $key, false);
 442      }
 443  
 444      /**
 445       * Get the user ID from the user handle
 446       *
 447       * This is a VERY inefficient method. Since the user handle is an HMAC-SHA-256 of the user ID we can't just go
 448       * directly from a handle back to an ID. We have to iterate all user IDs, calculate their handles and compare them
 449       * to the given handle.
 450       *
 451       * To prevent a lengthy infinite loop in case of an invalid user handle we don't iterate the entire 2+ billion valid
 452       * 32-bit integer range. We load the user IDs of active users (not blocked, not pending activation) and iterate
 453       * through them.
 454       *
 455       * To avoid memory outage on large sites with thousands of active user records we load up to 10000 users at a time.
 456       * Each block of 10,000 user IDs takes about 60-80 msec to iterate. On a site with 200,000 active users this method
 457       * will take less than 1.5 seconds. This is slow but not impractical, even on crowded shared hosts with a quarter of
 458       * the performance of my test subject (a mid-range, shared hosting server).
 459       *
 460       * @param   string|null  $userHandle  The user handle which will be converted to a user ID.
 461       *
 462       * @return  integer|null
 463       * @since   4.2.0
 464       */
 465      public function getUserIdFromHandle(?string $userHandle): ?int
 466      {
 467          if (empty($userHandle)) {
 468              return null;
 469          }
 470  
 471          /** @var DatabaseDriver $db */
 472          $db = $this->getDatabase();
 473  
 474          // Check that the userHandle does exist in the database
 475          $query = $db->getQuery(true)
 476              ->select('COUNT(*)')
 477              ->from($db->qn('#__webauthn_credentials'))
 478              ->where($db->qn('user_id') . ' = ' . $db->q($userHandle));
 479  
 480          try {
 481              $numRecords = $db->setQuery($query)->loadResult();
 482          } catch (Exception $e) {
 483              return null;
 484          }
 485  
 486          if (is_null($numRecords) || ($numRecords < 1)) {
 487              return null;
 488          }
 489  
 490          // Prepare the query
 491          $query = $db->getQuery(true)
 492              ->select([$db->qn('id')])
 493              ->from($db->qn('#__users'))
 494              ->where($db->qn('block') . ' = 0')
 495              ->where(
 496                  '(' .
 497                  $db->qn('activation') . ' IS NULL OR ' .
 498                  $db->qn('activation') . ' = 0 OR ' .
 499                  $db->qn('activation') . ' = ' . $db->q('') .
 500                  ')'
 501              );
 502  
 503          $key   = $this->getEncryptionKey();
 504          $start = 0;
 505          $limit = 10000;
 506  
 507          while (true) {
 508              try {
 509                  $ids = $db->setQuery($query, $start, $limit)->loadColumn();
 510              } catch (Exception $e) {
 511                  return null;
 512              }
 513  
 514              if (empty($ids)) {
 515                  return null;
 516              }
 517  
 518              foreach ($ids as $userId) {
 519                  $data       = sprintf('%010u', $userId);
 520                  $thisHandle = hash_hmac('sha256', $data, $key, false);
 521  
 522                  if ($thisHandle == $userHandle) {
 523                      return $userId;
 524                  }
 525              }
 526  
 527              $start += $limit;
 528          }
 529      }
 530  
 531      /**
 532       * Encrypt the credential source before saving it to the database
 533       *
 534       * @param   string   $credential  The unencrypted, JSON-encoded credential source
 535       *
 536       * @return  string  The encrypted credential source, base64 encoded
 537       *
 538       * @since   4.0.0
 539       */
 540      private function encryptCredential(string $credential): string
 541      {
 542          $key = $this->getEncryptionKey();
 543  
 544          if (empty($key)) {
 545              return $credential;
 546          }
 547  
 548          $aes = new Aes($key, 256);
 549  
 550          return $aes->encryptString($credential);
 551      }
 552  
 553      /**
 554       * Decrypt the credential source if it was already encrypted in the database
 555       *
 556       * @param   string  $credential  The encrypted credential source, base64 encoded
 557       *
 558       * @return  string  The decrypted, JSON-encoded credential source
 559       *
 560       * @since   4.0.0
 561       */
 562      private function decryptCredential(string $credential): string
 563      {
 564          $key = $this->getEncryptionKey();
 565  
 566          if (empty($key)) {
 567              return $credential;
 568          }
 569  
 570          // Was the credential stored unencrypted (e.g. the site's secret was empty)?
 571          if ((strpos($credential, '{') !== false) && (strpos($credential, '"publicKeyCredentialId"') !== false)) {
 572              return $credential;
 573          }
 574  
 575          $aes = new Aes($key, 256);
 576  
 577          return $aes->decryptString($credential);
 578      }
 579  
 580      /**
 581       * Get the site's secret, used as an encryption key
 582       *
 583       * @return  string
 584       *
 585       * @since   4.0.0
 586       */
 587      private function getEncryptionKey(): string
 588      {
 589          try {
 590              $app = Factory::getApplication();
 591              /** @var Registry $config */
 592              $config = $app->getConfig();
 593              $secret = $config->get('secret', '');
 594          } catch (Exception $e) {
 595              $secret = '';
 596          }
 597  
 598          return $secret;
 599      }
 600  
 601      /**
 602       * Format a date for display.
 603       *
 604       * The $tzAware parameter defines whether the formatted date will be timezone-aware. If set to false the formatted
 605       * date will be rendered in the UTC timezone. If set to true the code will automatically try to use the logged in
 606       * user's timezone or, if none is set, the site's default timezone (Server Timezone). If set to a positive integer
 607       * the same thing will happen but for the specified user ID instead of the currently logged in user.
 608       *
 609       * @param   string|\DateTime  $date     The date to format
 610       * @param   string|null       $format   The format string, default is Joomla's DATE_FORMAT_LC6 (usually "Y-m-d
 611       *                                      H:i:s")
 612       * @param   bool              $tzAware  Should the format be timezone aware? See notes above.
 613       *
 614       * @return  string
 615       * @since   4.2.0
 616       */
 617      private function formatDate($date, ?string $format = null, bool $tzAware = true): string
 618      {
 619          $utcTimeZone = new \DateTimeZone('UTC');
 620          $jDate       = new Date($date, $utcTimeZone);
 621  
 622          // Which timezone should I use?
 623          $tz = null;
 624  
 625          if ($tzAware !== false) {
 626              $userId = is_bool($tzAware) ? null : (int) $tzAware;
 627  
 628              try {
 629                  $tzDefault = Factory::getApplication()->get('offset');
 630              } catch (\Exception $e) {
 631                  $tzDefault = 'GMT';
 632              }
 633  
 634              $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId ?? 0);
 635              $tz   = $user->getParam('timezone', $tzDefault);
 636          }
 637  
 638          if (!empty($tz)) {
 639              try {
 640                  $userTimeZone = new \DateTimeZone($tz);
 641  
 642                  $jDate->setTimezone($userTimeZone);
 643              } catch (\Exception $e) {
 644                  // Nothing. Fall back to UTC.
 645              }
 646          }
 647  
 648          if (empty($format)) {
 649              $format = Text::_('DATE_FORMAT_LC6');
 650          }
 651  
 652          return $jDate->format($format, true);
 653      }
 654  }


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