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->getAuthority(), $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