[ 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 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 }
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 |