[ Index ] |
PHP Cross Reference of Joomla 4.2.2 documentation |
[Summary view] [Print] [Text view]
1 <?php 2 3 /** 4 * @package Joomla.Plugin 5 * @subpackage 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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Wed Sep 7 05:41:13 2022 | Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer |