1<?php 2 3use MediaWiki\Auth\AuthenticationRequest; 4use MediaWiki\Auth\AuthenticationResponse; 5use MediaWiki\Auth\AuthManager; 6use MediaWiki\Logger\LoggerFactory; 7use MediaWiki\Session\Token; 8 9/** 10 * A special page subclass for authentication-related special pages. It generates a form from 11 * a set of AuthenticationRequest objects, submits the result to AuthManager and 12 * partially handles the response. 13 * 14 * @note Call self::setAuthManager from special page constructor when extending 15 * 16 * @stable to extend 17 */ 18abstract class AuthManagerSpecialPage extends SpecialPage { 19 /** @var string[] The list of actions this special page deals with. Subclasses should override 20 * this. 21 */ 22 protected static $allowedActions = [ 23 AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE, 24 AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE, 25 AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE, 26 AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK, 27 ]; 28 29 /** @var array Customized messages */ 30 protected static $messages = []; 31 32 /** @var string one of the AuthManager::ACTION_* constants. */ 33 protected $authAction; 34 35 /** @var AuthenticationRequest[] */ 36 protected $authRequests; 37 38 /** @var string Subpage of the special page. */ 39 protected $subPage; 40 41 /** @var bool True if the current request is a result of returning from a redirect flow. */ 42 protected $isReturn; 43 44 /** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */ 45 protected $savedRequest; 46 47 /** 48 * Change the form descriptor that determines how a field will look in the authentication form. 49 * Called from fieldInfoToFormDescriptor(). 50 * @stable to override 51 * 52 * @param AuthenticationRequest[] $requests 53 * @param array $fieldInfo Field information array (union of all 54 * AuthenticationRequest::getFieldInfo() responses). 55 * @param array &$formDescriptor HTMLForm descriptor. The special key 'weight' can be set to 56 * change the order of the fields. 57 * @param string $action Authentication type (one of the AuthManager::ACTION_* constants) 58 */ 59 public function onAuthChangeFormFields( 60 array $requests, array $fieldInfo, array &$formDescriptor, $action 61 ) { 62 } 63 64 /** 65 * @stable to override 66 * @return bool|string 67 */ 68 protected function getLoginSecurityLevel() { 69 return $this->getName(); 70 } 71 72 public function getRequest() { 73 return $this->savedRequest ?: $this->getContext()->getRequest(); 74 } 75 76 /** 77 * Override the POST data, GET data from the real request is preserved. 78 * 79 * Used to preserve POST data over a HTTP redirect. 80 * 81 * @stable to override 82 * 83 * @param array $data 84 * @param bool|null $wasPosted 85 */ 86 protected function setRequest( array $data, $wasPosted = null ) { 87 $request = $this->getContext()->getRequest(); 88 if ( $wasPosted === null ) { 89 $wasPosted = $request->wasPosted(); 90 } 91 $this->savedRequest = new DerivativeRequest( $request, $data + $request->getQueryValues(), 92 $wasPosted ); 93 } 94 95 /** 96 * @stable to override 97 * @param string|null $subPage 98 * 99 * @return bool|void 100 */ 101 protected function beforeExecute( $subPage ) { 102 $this->getOutput()->disallowUserJs(); 103 104 return $this->handleReturnBeforeExecute( $subPage ) 105 && $this->handleReauthBeforeExecute( $subPage ); 106 } 107 108 /** 109 * Handle redirection from the /return subpage. 110 * 111 * This is used in the redirect flow where we need 112 * to be able to process data that was sent via a GET request. We set the /return subpage as 113 * the reentry point so we know we need to treat GET as POST, but we don't want to handle all 114 * future GETs as POSTs so we need to normalize the URL. (Also we don't want to show any 115 * received parameters around in the URL; they are ugly and might be sensitive.) 116 * 117 * Thus when on the /return subpage, we stash the request data in the session, redirect, then 118 * use the session to detect that we have been redirected, recover the data and replace the 119 * real WebRequest with a fake one that contains the saved data. 120 * 121 * @param string $subPage 122 * @return bool False if execution should be stopped. 123 */ 124 protected function handleReturnBeforeExecute( $subPage ) { 125 $authManager = $this->getAuthManager(); 126 $key = 'AuthManagerSpecialPage:return:' . $this->getName(); 127 128 if ( $subPage === 'return' ) { 129 $this->loadAuth( $subPage ); 130 $preservedParams = $this->getPreservedParams( false ); 131 132 // FIXME save POST values only from request 133 $authData = array_diff_key( $this->getRequest()->getValues(), 134 $preservedParams, [ 'title' => 1 ] ); 135 $authManager->setAuthenticationSessionData( $key, $authData ); 136 137 $url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS ); 138 $this->getOutput()->redirect( $url ); 139 return false; 140 } 141 142 $authData = $authManager->getAuthenticationSessionData( $key ); 143 if ( $authData ) { 144 $authManager->removeAuthenticationSessionData( $key ); 145 $this->isReturn = true; 146 $this->setRequest( $authData, true ); 147 } 148 149 return true; 150 } 151 152 /** 153 * Handle redirection when the user needs to (re)authenticate. 154 * 155 * Send the user to the login form if needed; in case the request was a POST, stash in the 156 * session and simulate it once the user gets back. 157 * 158 * @param string $subPage 159 * @return bool False if execution should be stopped. 160 * @throws ErrorPageError When the user is not allowed to use this page. 161 */ 162 protected function handleReauthBeforeExecute( $subPage ) { 163 $authManager = $this->getAuthManager(); 164 $request = $this->getRequest(); 165 $key = 'AuthManagerSpecialPage:reauth:' . $this->getName(); 166 167 $securityLevel = $this->getLoginSecurityLevel(); 168 if ( $securityLevel ) { 169 $securityStatus = $authManager->securitySensitiveOperationStatus( $securityLevel ); 170 if ( $securityStatus === AuthManager::SEC_REAUTH ) { 171 $queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] ); 172 173 if ( $request->wasPosted() ) { 174 // unique ID in case the same special page is open in multiple browser tabs 175 $uniqueId = MWCryptRand::generateHex( 6 ); 176 $key .= ':' . $uniqueId; 177 178 $queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams; 179 $authData = array_diff_key( $request->getValues(), 180 $this->getPreservedParams( false ), [ 'title' => 1 ] ); 181 $authManager->setAuthenticationSessionData( $key, $authData ); 182 } 183 184 $title = SpecialPage::getTitleFor( 'Userlogin' ); 185 $url = $title->getFullURL( [ 186 'returnto' => $this->getFullTitle()->getPrefixedDBkey(), 187 'returntoquery' => wfArrayToCgi( $queryParams ), 188 'force' => $securityLevel, 189 ], false, PROTO_HTTPS ); 190 191 $this->getOutput()->redirect( $url ); 192 return false; 193 } elseif ( $securityStatus !== AuthManager::SEC_OK ) { 194 throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' ); 195 } 196 } 197 198 $uniqueId = $request->getVal( 'authUniqueId' ); 199 if ( $uniqueId ) { 200 $key .= ':' . $uniqueId; 201 $authData = $authManager->getAuthenticationSessionData( $key ); 202 if ( $authData ) { 203 $authManager->removeAuthenticationSessionData( $key ); 204 $this->setRequest( $authData, true ); 205 } 206 } 207 208 return true; 209 } 210 211 /** 212 * Get the default action for this special page, if none is given via URL/POST data. 213 * Subclasses should override this (or override loadAuth() so this is never called). 214 * @stable to override 215 * @param string $subPage Subpage of the special page. 216 * @return string an AuthManager::ACTION_* constant. 217 */ 218 abstract protected function getDefaultAction( $subPage ); 219 220 /** 221 * Return custom message key. 222 * Allows subclasses to customize messages. 223 * @param string $defaultKey 224 * @return string 225 */ 226 protected function messageKey( $defaultKey ) { 227 return array_key_exists( $defaultKey, static::$messages ) 228 ? static::$messages[$defaultKey] : $defaultKey; 229 } 230 231 /** 232 * Allows blacklisting certain request types. 233 * @stable to override 234 * @return array A list of AuthenticationRequest subclass names 235 */ 236 protected function getRequestBlacklist() { 237 return []; 238 } 239 240 /** 241 * Load or initialize $authAction, $authRequests and $subPage. 242 * Subclasses should call this from execute() or otherwise ensure the variables are initialized. 243 * @stable to override 244 * @param string $subPage Subpage of the special page. 245 * @param string|null $authAction Override auth action specified in request (this is useful 246 * when the form needs to be changed from <action> to <action>_CONTINUE after a successful 247 * authentication step) 248 * @param bool $reset Regenerate the requests even if a cached version is available 249 */ 250 protected function loadAuth( $subPage, $authAction = null, $reset = false ) { 251 // Do not load if already loaded, to cut down on the number of getAuthenticationRequests 252 // calls. This is important for requests which have hidden information so any 253 // getAuthenticationRequests call would mean putting data into some cache. 254 if ( 255 !$reset && $this->subPage === $subPage && $this->authAction 256 && ( !$authAction || $authAction === $this->authAction ) 257 ) { 258 return; 259 } 260 261 $request = $this->getRequest(); 262 $this->subPage = $subPage; 263 $this->authAction = $authAction ?: $request->getText( 'authAction' ); 264 if ( !in_array( $this->authAction, static::$allowedActions, true ) ) { 265 $this->authAction = $this->getDefaultAction( $subPage ); 266 if ( $request->wasPosted() ) { 267 $continueAction = $this->getContinueAction( $this->authAction ); 268 if ( in_array( $continueAction, static::$allowedActions, true ) ) { 269 $this->authAction = $continueAction; 270 } 271 } 272 } 273 274 $allReqs = $this->getAuthManager()->getAuthenticationRequests( 275 $this->authAction, $this->getUser() ); 276 $this->authRequests = array_filter( $allReqs, function ( $req ) { 277 return !in_array( get_class( $req ), $this->getRequestBlacklist(), true ); 278 } ); 279 } 280 281 /** 282 * Returns true if this is not the first step of the authentication. 283 * @return bool 284 */ 285 protected function isContinued() { 286 return in_array( $this->authAction, [ 287 AuthManager::ACTION_LOGIN_CONTINUE, 288 AuthManager::ACTION_CREATE_CONTINUE, 289 AuthManager::ACTION_LINK_CONTINUE, 290 ], true ); 291 } 292 293 /** 294 * Gets the _CONTINUE version of an action. 295 * @param string $action An AuthManager::ACTION_* constant. 296 * @return string An AuthManager::ACTION_*_CONTINUE constant. 297 */ 298 protected function getContinueAction( $action ) { 299 switch ( $action ) { 300 case AuthManager::ACTION_LOGIN: 301 $action = AuthManager::ACTION_LOGIN_CONTINUE; 302 break; 303 case AuthManager::ACTION_CREATE: 304 $action = AuthManager::ACTION_CREATE_CONTINUE; 305 break; 306 case AuthManager::ACTION_LINK: 307 $action = AuthManager::ACTION_LINK_CONTINUE; 308 break; 309 } 310 return $action; 311 } 312 313 /** 314 * Checks whether AuthManager is ready to perform the action. 315 * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is 316 * the caller's responsibility. 317 * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions 318 * @return bool 319 * @throws LogicException if $action is invalid 320 */ 321 protected function isActionAllowed( $action ) { 322 $authManager = $this->getAuthManager(); 323 if ( !in_array( $action, static::$allowedActions, true ) ) { 324 throw new InvalidArgumentException( 'invalid action: ' . $action ); 325 } 326 327 // calling getAuthenticationRequests can be expensive, avoid if possible 328 $requests = ( $action === $this->authAction ) ? $this->authRequests 329 : $authManager->getAuthenticationRequests( $action ); 330 if ( !$requests ) { 331 // no provider supports this action in the current state 332 return false; 333 } 334 335 switch ( $action ) { 336 case AuthManager::ACTION_LOGIN: 337 case AuthManager::ACTION_LOGIN_CONTINUE: 338 return $authManager->canAuthenticateNow(); 339 case AuthManager::ACTION_CREATE: 340 case AuthManager::ACTION_CREATE_CONTINUE: 341 return $authManager->canCreateAccounts(); 342 case AuthManager::ACTION_LINK: 343 case AuthManager::ACTION_LINK_CONTINUE: 344 return $authManager->canLinkAccounts(); 345 case AuthManager::ACTION_CHANGE: 346 case AuthManager::ACTION_REMOVE: 347 case AuthManager::ACTION_UNLINK: 348 return true; 349 default: 350 // should never reach here but makes static code analyzers happy 351 throw new InvalidArgumentException( 'invalid action: ' . $action ); 352 } 353 } 354 355 /** 356 * @param string $action One of the AuthManager::ACTION_* constants 357 * @param AuthenticationRequest[] $requests 358 * @return AuthenticationResponse 359 * @throws LogicException if $action is invalid 360 */ 361 protected function performAuthenticationStep( $action, array $requests ) { 362 if ( !in_array( $action, static::$allowedActions, true ) ) { 363 throw new InvalidArgumentException( 'invalid action: ' . $action ); 364 } 365 366 $authManager = $this->getAuthManager(); 367 $returnToUrl = $this->getPageTitle( 'return' ) 368 ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS ); 369 370 switch ( $action ) { 371 case AuthManager::ACTION_LOGIN: 372 return $authManager->beginAuthentication( $requests, $returnToUrl ); 373 case AuthManager::ACTION_LOGIN_CONTINUE: 374 return $authManager->continueAuthentication( $requests ); 375 case AuthManager::ACTION_CREATE: 376 return $authManager->beginAccountCreation( $this->getUser(), $requests, 377 $returnToUrl ); 378 case AuthManager::ACTION_CREATE_CONTINUE: 379 return $authManager->continueAccountCreation( $requests ); 380 case AuthManager::ACTION_LINK: 381 return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl ); 382 case AuthManager::ACTION_LINK_CONTINUE: 383 return $authManager->continueAccountLink( $requests ); 384 case AuthManager::ACTION_CHANGE: 385 case AuthManager::ACTION_REMOVE: 386 case AuthManager::ACTION_UNLINK: 387 if ( count( $requests ) > 1 ) { 388 throw new InvalidArgumentException( 'only one auth request can be changed at a time' ); 389 } elseif ( !$requests ) { 390 throw new InvalidArgumentException( 'no auth request' ); 391 } 392 $req = reset( $requests ); 393 $status = $authManager->allowsAuthenticationDataChange( $req ); 394 $this->getHookRunner()->onChangeAuthenticationDataAudit( $req, $status ); 395 if ( !$status->isGood() ) { 396 return AuthenticationResponse::newFail( $status->getMessage() ); 397 } 398 $authManager->changeAuthenticationData( $req ); 399 return AuthenticationResponse::newPass(); 400 default: 401 // should never reach here but makes static code analyzers happy 402 throw new InvalidArgumentException( 'invalid action: ' . $action ); 403 } 404 } 405 406 /** 407 * Attempts to do an authentication step with the submitted data. 408 * Subclasses should probably call this from execute(). 409 * @return false|Status 410 * - false if there was no submit at all 411 * - a good Status wrapping an AuthenticationResponse if the form submit was successful. 412 * This does not necessarily mean that the authentication itself was successful; see the 413 * response for that. 414 * - a bad Status for form errors. 415 */ 416 protected function trySubmit() { 417 $status = false; 418 419 $form = $this->getAuthForm( $this->authRequests, $this->authAction ); 420 $form->setSubmitCallback( [ $this, 'handleFormSubmit' ] ); 421 422 if ( $this->getRequest()->wasPosted() ) { 423 // handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users 424 $requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() ); 425 $sessionToken = $this->getToken(); 426 if ( $sessionToken->wasNew() ) { 427 return Status::newFatal( $this->messageKey( 'authform-newtoken' ) ); 428 } elseif ( !$requestTokenValue ) { 429 return Status::newFatal( $this->messageKey( 'authform-notoken' ) ); 430 } elseif ( !$sessionToken->match( $requestTokenValue ) ) { 431 return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) ); 432 } 433 434 $form->prepareForm(); 435 $status = $form->trySubmit(); 436 437 // HTMLForm submit return values are a mess; let's ensure it is false or a Status 438 // FIXME this probably should be in HTMLForm 439 if ( $status === true ) { 440 // not supposed to happen since our submit handler should always return a Status 441 throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' ); 442 } elseif ( $status === false ) { 443 // form was not submitted; nothing to do 444 } elseif ( $status instanceof Status ) { 445 // already handled by the form; nothing to do 446 } elseif ( $status instanceof StatusValue ) { 447 // in theory not an allowed return type but nothing stops the submit handler from 448 // accidentally returning it so best check and fix 449 $status = Status::wrap( $status ); 450 } elseif ( is_string( $status ) ) { 451 $status = Status::newFatal( new RawMessage( '$1', [ $status ] ) ); 452 } elseif ( is_array( $status ) ) { 453 if ( is_string( reset( $status ) ) ) { 454 $status = Status::newFatal( ...$status ); 455 } elseif ( is_array( reset( $status ) ) ) { 456 $ret = Status::newGood(); 457 foreach ( $status as $message ) { 458 $ret->fatal( ...$message ); 459 } 460 $status = $ret; 461 } else { 462 throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: ' 463 . 'first element of array is ' . gettype( reset( $status ) ) ); 464 } 465 } else { 466 // not supposed to happen but HTMLForm does not actually verify the return type 467 // from the submit callback; better safe then sorry 468 throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: ' 469 . gettype( $status ) ); 470 } 471 472 if ( ( !$status || !$status->isOK() ) && $this->isReturn ) { 473 // This is awkward. There was a form validation error, which means the data was not 474 // passed to AuthManager. Normally we would display the form with an error message, 475 // but for the data we received via the redirect flow that would not be helpful at all. 476 // Let's just submit the data to AuthManager directly instead. 477 LoggerFactory::getInstance( 'authentication' ) 478 ->warning( 'Validation error on return', [ 'data' => $form->mFieldData, 479 'status' => $status->getWikiText( false, false, 'en' ) ] ); 480 $status = $this->handleFormSubmit( $form->mFieldData ); 481 } 482 } 483 484 $changeActions = [ 485 AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK 486 ]; 487 if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) { 488 $this->getHookRunner()->onChangeAuthenticationDataAudit( reset( $this->authRequests ), $status ); 489 } 490 491 return $status; 492 } 493 494 /** 495 * Submit handler callback for HTMLForm 496 * @internal 497 * @param array $data Submitted data 498 * @return Status 499 */ 500 public function handleFormSubmit( $data ) { 501 $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data ); 502 $response = $this->performAuthenticationStep( $this->authAction, $requests ); 503 504 // we can't handle FAIL or similar as failure here since it might require changing the form 505 return Status::newGood( $response ); 506 } 507 508 /** 509 * Returns URL query parameters which can be used to reload the page (or leave and return) while 510 * preserving all information that is necessary for authentication to continue. These parameters 511 * will be preserved in the action URL of the form and in the return URL for redirect flow. 512 * @stable to override 513 * @param bool $withToken Include CSRF token 514 * @return array 515 */ 516 protected function getPreservedParams( $withToken = false ) { 517 $params = []; 518 if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) { 519 $params['authAction'] = $this->getContinueAction( $this->authAction ); 520 } 521 if ( $withToken ) { 522 $params[$this->getTokenName()] = $this->getToken()->toString(); 523 } 524 return $params; 525 } 526 527 /** 528 * Generates a HTMLForm descriptor array from a set of authentication requests. 529 * @stable to override 530 * @param AuthenticationRequest[] $requests 531 * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants) 532 * @return array[] 533 */ 534 protected function getAuthFormDescriptor( $requests, $action ) { 535 $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests ); 536 $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action ); 537 538 $this->addTabIndex( $formDescriptor ); 539 540 return $formDescriptor; 541 } 542 543 /** 544 * @stable to override 545 * @param AuthenticationRequest[] $requests 546 * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants) 547 * @return HTMLForm 548 */ 549 protected function getAuthForm( array $requests, $action ) { 550 $formDescriptor = $this->getAuthFormDescriptor( $requests, $action ); 551 $context = $this->getContext(); 552 if ( $context->getRequest() !== $this->getRequest() ) { 553 // We have overridden the request, need to make sure the form uses that too. 554 $context = new DerivativeContext( $this->getContext() ); 555 $context->setRequest( $this->getRequest() ); 556 } 557 $form = HTMLForm::factory( 'ooui', $formDescriptor, $context ); 558 $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) ); 559 $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() ); 560 $form->addHiddenField( 'authAction', $this->authAction ); 561 $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) ); 562 563 return $form; 564 } 565 566 /** 567 * Display the form. 568 * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit() 569 */ 570 protected function displayForm( $status ) { 571 if ( $status instanceof StatusValue ) { 572 $status = Status::wrap( $status ); 573 } 574 $form = $this->getAuthForm( $this->authRequests, $this->authAction ); 575 $form->prepareForm()->displayForm( $status ); 576 } 577 578 /** 579 * Returns true if the form built from the given AuthenticationRequests needs a submit button. 580 * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using 581 * one of those custom buttons is the only way to proceed, there is no point in displaying the 582 * default button which won't do anything useful. 583 * @stable to override 584 * 585 * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the 586 * form will be built 587 * @return bool 588 */ 589 protected function needsSubmitButton( array $requests ) { 590 $customSubmitButtonPresent = false; 591 592 // Secondary and preauth providers always need their data; they will not care what button 593 // is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers; 594 // that's the point in being optional. Se we need to check whether all primary providers 595 // have their own buttons and whether there is at least one button present. 596 foreach ( $requests as $req ) { 597 if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) { 598 if ( $this->hasOwnSubmitButton( $req ) ) { 599 $customSubmitButtonPresent = true; 600 } else { 601 return true; 602 } 603 } 604 } 605 return !$customSubmitButtonPresent; 606 } 607 608 /** 609 * Checks whether the given AuthenticationRequest has its own submit button. 610 * @param AuthenticationRequest $req 611 * @return bool 612 */ 613 protected function hasOwnSubmitButton( AuthenticationRequest $req ) { 614 foreach ( $req->getFieldInfo() as $field => $info ) { 615 if ( $info['type'] === 'button' ) { 616 return true; 617 } 618 } 619 return false; 620 } 621 622 /** 623 * Adds a sequential tabindex starting from 1 to all form elements. This way the user can 624 * use the tab key to traverse the form without having to step through all links and such. 625 * @param array[] &$formDescriptor 626 */ 627 protected function addTabIndex( &$formDescriptor ) { 628 $i = 1; 629 foreach ( $formDescriptor as $field => &$definition ) { 630 $class = false; 631 if ( array_key_exists( 'class', $definition ) ) { 632 $class = $definition['class']; 633 } elseif ( array_key_exists( 'type', $definition ) ) { 634 $class = HTMLForm::$typeMappings[$definition['type']]; 635 } 636 if ( $class !== HTMLInfoField::class ) { 637 $definition['tabindex'] = $i; 638 $i++; 639 } 640 } 641 } 642 643 /** 644 * Returns the CSRF token. 645 * @stable to override 646 * @return Token 647 */ 648 protected function getToken() { 649 return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:' 650 . $this->getName() ); 651 } 652 653 /** 654 * Returns the name of the CSRF token (under which it should be found in the POST or GET data). 655 * @stable to override 656 * @return string 657 */ 658 protected function getTokenName() { 659 return 'wpAuthToken'; 660 } 661 662 /** 663 * Turns a field info array into a form descriptor. Behavior can be modified by the 664 * AuthChangeFormFields hook. 665 * @param AuthenticationRequest[] $requests 666 * @param array $fieldInfo Field information, in the format used by 667 * AuthenticationRequest::getFieldInfo() 668 * @param string $action One of the AuthManager::ACTION_* constants 669 * @return array A form descriptor that can be passed to HTMLForm 670 */ 671 protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) { 672 $formDescriptor = []; 673 foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) { 674 $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName ); 675 } 676 677 $requestSnapshot = serialize( $requests ); 678 $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action ); 679 $this->getHookRunner()->onAuthChangeFormFields( $requests, $fieldInfo, 680 $formDescriptor, $action ); 681 if ( $requestSnapshot !== serialize( $requests ) ) { 682 LoggerFactory::getInstance( 'authentication' )->warning( 683 'AuthChangeFormFields hook changed auth requests' ); 684 } 685 686 // Process the special 'weight' property, which is a way for AuthChangeFormFields hook 687 // subscribers (who only see one field at a time) to influence ordering. 688 self::sortFormDescriptorFields( $formDescriptor ); 689 690 return $formDescriptor; 691 } 692 693 /** 694 * Maps an authentication field configuration for a single field (as returned by 695 * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor. 696 * @param array $singleFieldInfo 697 * @param string $fieldName 698 * @return array 699 */ 700 protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) { 701 $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] ); 702 $descriptor = [ 703 'type' => $type, 704 // Do not prefix input name with 'wp'. This is important for the redirect flow. 705 'name' => $fieldName, 706 ]; 707 708 if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) { 709 $descriptor['default'] = $singleFieldInfo['label']->plain(); 710 } elseif ( $type !== 'submit' ) { 711 $descriptor += array_filter( [ 712 // help-message is omitted as it is usually not really useful for a web interface 713 'label-message' => self::getField( $singleFieldInfo, 'label' ), 714 ] ); 715 716 if ( isset( $singleFieldInfo['options'] ) ) { 717 $descriptor['options'] = array_flip( array_map( static function ( $message ) { 718 /** @var Message $message */ 719 return $message->parse(); 720 }, $singleFieldInfo['options'] ) ); 721 } 722 723 if ( isset( $singleFieldInfo['value'] ) ) { 724 $descriptor['default'] = $singleFieldInfo['value']; 725 } 726 727 if ( empty( $singleFieldInfo['optional'] ) ) { 728 $descriptor['required'] = true; 729 } 730 } 731 732 return $descriptor; 733 } 734 735 /** 736 * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight 737 * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.) 738 * Keep order if weights are equal. 739 * @param array &$formDescriptor 740 */ 741 protected static function sortFormDescriptorFields( array &$formDescriptor ) { 742 $i = 0; 743 foreach ( $formDescriptor as &$field ) { 744 $field['__index'] = $i++; 745 } 746 uasort( $formDescriptor, function ( $first, $second ) { 747 return self::getField( $first, 'weight', 0 ) <=> self::getField( $second, 'weight', 0 ) 748 ?: $first['__index'] <=> $second['__index']; 749 } ); 750 foreach ( $formDescriptor as &$field ) { 751 unset( $field['__index'] ); 752 } 753 } 754 755 /** 756 * Get an array value, or a default if it does not exist. 757 * @param array $array 758 * @param string $fieldName 759 * @param mixed|null $default 760 * @return mixed 761 */ 762 protected static function getField( array $array, $fieldName, $default = null ) { 763 if ( array_key_exists( $fieldName, $array ) ) { 764 return $array[$fieldName]; 765 } else { 766 return $default; 767 } 768 } 769 770 /** 771 * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types 772 * @param string $type 773 * @return string 774 * @throws \LogicException 775 */ 776 protected static function mapFieldInfoTypeToFormDescriptorType( $type ) { 777 $map = [ 778 'string' => 'text', 779 'password' => 'password', 780 'select' => 'select', 781 'checkbox' => 'check', 782 'multiselect' => 'multiselect', 783 'button' => 'submit', 784 'hidden' => 'hidden', 785 'null' => 'info', 786 ]; 787 if ( !array_key_exists( $type, $map ) ) { 788 throw new \LogicException( 'invalid field type: ' . $type ); 789 } 790 return $map[$type]; 791 } 792 793 /** 794 * Apply defaults to a form descriptor, without creating non-existend fields. 795 * 796 * Overrides $formDescriptor fields with their $defaultFormDescriptor equivalent, but 797 * only if the field is defined in $fieldInfo, uses the special 'basefield' property to 798 * refer to a $fieldInfo field, or it is not a real field (e.g. help text). Applies some 799 * common-sense behaviors to ensure related fields are overridden in a consistent manner. 800 * @param array $fieldInfo 801 * @param array $formDescriptor 802 * @param array $defaultFormDescriptor 803 * @return array 804 */ 805 protected static function mergeDefaultFormDescriptor( 806 array $fieldInfo, array $formDescriptor, array $defaultFormDescriptor 807 ) { 808 // keep the ordering from $defaultFormDescriptor where there is no explicit weight 809 foreach ( $defaultFormDescriptor as $fieldName => $defaultField ) { 810 // remove everything that is not in the fieldinfo, is not marked as a supplemental field 811 // to something in the fieldinfo, and is not an info field or a submit button 812 if ( 813 !isset( $fieldInfo[$fieldName] ) 814 && ( 815 !isset( $defaultField['baseField'] ) 816 || !isset( $fieldInfo[$defaultField['baseField']] ) 817 ) 818 && ( 819 !isset( $defaultField['type'] ) 820 || !in_array( $defaultField['type'], [ 'submit', 'info' ], true ) 821 ) 822 ) { 823 $defaultFormDescriptor[$fieldName] = null; 824 continue; 825 } 826 827 // default message labels should always take priority 828 $requestField = $formDescriptor[$fieldName] ?? []; 829 if ( 830 isset( $defaultField['label'] ) 831 || isset( $defaultField['label-message'] ) 832 || isset( $defaultField['label-raw'] ) 833 ) { 834 unset( $requestField['label'], $requestField['label-message'], $defaultField['label-raw'] ); 835 } 836 837 $defaultFormDescriptor[$fieldName] += $requestField; 838 } 839 840 return array_filter( $defaultFormDescriptor + $formDescriptor ); 841 } 842} 843