Completed
Branch master (d7c4e6)
by
unknown
29:20
created

doesRequestNeedsSubmitButton()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 5
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 8
rs 9.4285
1
<?php
2
3
use MediaWiki\Auth\AuthenticationRequest;
4
use MediaWiki\Auth\AuthenticationResponse;
5
use MediaWiki\Auth\AuthManager;
6
use MediaWiki\Logger\LoggerFactory;
7
use 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
abstract class AuthManagerSpecialPage extends SpecialPage {
15
	/** @var string[] The list of actions this special page deals with. Subclasses should override
16
	 * this. */
17
	protected static $allowedActions = [
18
		AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE,
19
		AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE,
20
		AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
21
		AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK,
22
	];
23
24
	/** @var array Customized messages */
25
	protected static $messages = [];
26
27
	/** @var string one of the AuthManager::ACTION_* constants. */
28
	protected $authAction;
29
30
	/** @var AuthenticationRequest[] */
31
	protected $authRequests;
32
33
	/** @var string Subpage of the special page. */
34
	protected $subPage;
35
36
	/** @var bool True if the current request is a result of returning from a redirect flow. */
37
	protected $isReturn;
38
39
	/** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */
40
	protected $savedRequest;
41
42
	/**
43
	 * Change the form descriptor that determines how a field will look in the authentication form.
44
	 * Called from fieldInfoToFormDescriptor().
45
	 * @param AuthenticationRequest[] $requests
46
	 * @param string $fieldInfo Field information array (union of all
47
	 *    AuthenticationRequest::getFieldInfo() responses).
48
	 * @param array $formDescriptor HTMLForm descriptor. The special key 'weight' can be set to
49
	 *    change the order of the fields.
50
	 * @param string $action Authentication type (one of the AuthManager::ACTION_* constants)
51
	 * @return bool
52
	 */
53
	public function onAuthChangeFormFields(
54
		array $requests, array $fieldInfo, array &$formDescriptor, $action
55
	) {
56
		return true;
57
	}
58
59
	protected function getLoginSecurityLevel() {
60
		return $this->getName();
61
	}
62
63
	public function getRequest() {
64
		return $this->savedRequest ?: $this->getContext()->getRequest();
65
	}
66
67
	/**
68
	 * Override the POST data, GET data from the real request is preserved.
69
	 *
70
	 * Used to preserve POST data over a HTTP redirect.
71
	 *
72
	 * @param array $data
73
	 * @param bool $wasPosted
74
	 */
75
	protected function setRequest( array $data, $wasPosted = null ) {
76
		$request = $this->getContext()->getRequest();
77
		if ( $wasPosted === null ) {
78
			$wasPosted = $request->wasPosted();
79
		}
80
		$this->savedRequest = new DerivativeRequest( $request, $data + $request->getQueryValues(),
81
			$wasPosted );
82
	}
83
84
	protected function beforeExecute( $subPage ) {
85
		$this->getOutput()->disallowUserJs();
86
87
		return $this->handleReturnBeforeExecute( $subPage )
88
			&& $this->handleReauthBeforeExecute( $subPage );
89
	}
90
91
	/**
92
	 * Handle redirection from the /return subpage.
93
	 *
94
	 * This is used in the redirect flow where we need
95
	 * to be able to process data that was sent via a GET request. We set the /return subpage as
96
	 * the reentry point so we know we need to treat GET as POST, but we don't want to handle all
97
	 * future GETs as POSTs so we need to normalize the URL. (Also we don't want to show any
98
	 * received parameters around in the URL; they are ugly and might be sensitive.)
99
	 *
100
	 * Thus when on the /return subpage, we stash the request data in the session, redirect, then
101
	 * use the session to detect that we have been redirected, recover the data and replace the
102
	 * real WebRequest with a fake one that contains the saved data.
103
	 *
104
	 * @param string $subPage
105
	 * @return bool False if execution should be stopped.
106
	 */
107
	protected function handleReturnBeforeExecute( $subPage ) {
108
		$authManager = AuthManager::singleton();
109
		$key = 'AuthManagerSpecialPage:return:' . $this->getName();
110
111
		if ( $subPage === 'return' ) {
112
			$this->loadAuth( $subPage );
113
			$preservedParams = $this->getPreservedParams( false );
114
115
			// FIXME save POST values only from request
116
			$authData = array_diff_key( $this->getRequest()->getValues(),
117
				$preservedParams, [ 'title' => 1 ] );
118
			$authManager->setAuthenticationSessionData( $key, $authData );
119
120
			$url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS );
121
			$this->getOutput()->redirect( $url );
122
			return false;
123
		}
124
125
		$authData = $authManager->getAuthenticationSessionData( $key );
126
		if ( $authData ) {
127
			$authManager->removeAuthenticationSessionData( $key );
128
			$this->isReturn = true;
129
			$this->setRequest( $authData, true );
130
		}
131
132
		return true;
133
	}
134
135
	/**
136
	 * Handle redirection when the user needs to (re)authenticate.
137
	 *
138
	 * Send the user to the login form if needed; in case the request was a POST, stash in the
139
	 * session and simulate it once the user gets back.
140
	 *
141
	 * @param string $subPage
142
	 * @return bool False if execution should be stopped.
143
	 * @throws ErrorPageError When the user is not allowed to use this page.
144
	 */
145
	protected function handleReauthBeforeExecute( $subPage ) {
146
		$authManager = AuthManager::singleton();
147
		$request = $this->getRequest();
148
		$key = 'AuthManagerSpecialPage:reauth:' . $this->getName();
149
150
		$securityLevel = $this->getLoginSecurityLevel();
151
		if ( $securityLevel ) {
152
			$securityStatus = AuthManager::singleton()
153
				->securitySensitiveOperationStatus( $securityLevel );
154
			if ( $securityStatus === AuthManager::SEC_REAUTH ) {
155
				$queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] );
156
157
				if ( $request->wasPosted() ) {
158
					// unique ID in case the same special page is open in multiple browser tabs
159
					$uniqueId = MWCryptRand::generateHex( 6 );
160
					$key = $key . ':' . $uniqueId;
161
162
					$queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams;
163
					$authData = array_diff_key( $request->getValues(),
164
							$this->getPreservedParams( false ), [ 'title' => 1 ] );
165
					$authManager->setAuthenticationSessionData( $key, $authData );
166
				}
167
168
				$title = SpecialPage::getTitleFor( 'Userlogin' );
169
				$url = $title->getFullURL( [
170
					'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
171
					'returntoquery' => wfArrayToCgi( $queryParams ),
172
					'force' => $securityLevel,
173
				], false, PROTO_HTTPS );
174
175
				$this->getOutput()->redirect( $url );
176
				return false;
177
			} elseif ( $securityStatus !== AuthManager::SEC_OK ) {
178
				throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' );
179
			}
180
		}
181
182
		$uniqueId = $request->getVal( 'authUniqueId' );
183
		if ( $uniqueId ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uniqueId of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
184
			$key = $key . ':' . $uniqueId;
185
			$authData = $authManager->getAuthenticationSessionData( $key );
186
			if ( $authData ) {
187
				$authManager->removeAuthenticationSessionData( $key );
188
				$this->setRequest( $authData, true );
189
			}
190
		}
191
192
		return true;
193
	}
194
195
	/**
196
	 * Get the default action for this special page, if none is given via URL/POST data.
197
	 * Subclasses should override this (or override loadAuth() so this is never called).
198
	 * @param string $subPage Subpage of the special page.
199
	 * @return string an AuthManager::ACTION_* constant.
200
	 */
201
	protected function getDefaultAction( $subPage ) {
202
		throw new BadMethodCallException( 'Subclass did not implement getDefaultAction' );
203
	}
204
205
	/**
206
	 * Return custom message key.
207
	 * Allows subclasses to customize messages.
208
	 * @return string
209
	 */
210
	protected function messageKey( $defaultKey ) {
211
		return array_key_exists( $defaultKey, static::$messages )
212
			? static::$messages[$defaultKey] : $defaultKey;
213
	}
214
215
	/**
216
	 * Allows blacklisting certain request types.
217
	 * @return array A list of AuthenticationRequest subclass names
218
	 */
219
	protected function getRequestBlacklist() {
220
		return [];
221
	}
222
223
	/**
224
	 * Load or initialize $authAction, $authRequests and $subPage.
225
	 * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
226
	 * @param string $subPage Subpage of the special page.
227
	 * @param string $authAction Override auth action specified in request (this is useful
228
	 *    when the form needs to be changed from <action> to <action>_CONTINUE after a successful
229
	 *    authentication step)
230
	 * @param bool $reset Regenerate the requests even if a cached version is available
231
	 */
232
	protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
233
		// Do not load if already loaded, to cut down on the number of getAuthenticationRequests
234
		// calls. This is important for requests which have hidden information so any
235
		// getAuthenticationRequests call would mean putting data into some cache.
236
		if (
237
			!$reset && $this->subPage === $subPage && $this->authAction
238
			&& ( !$authAction || $authAction === $this->authAction )
0 ignored issues
show
Bug Best Practice introduced by
The expression $authAction of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
239
		) {
240
			return;
241
		}
242
243
		$request = $this->getRequest();
244
		$this->subPage = $subPage;
245
		$this->authAction = $authAction ?: $request->getText( 'authAction' );
246
		if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
247
			$this->authAction = $this->getDefaultAction( $subPage );
248
			if ( $request->wasPosted() ) {
249
				$continueAction = $this->getContinueAction( $this->authAction );
250
				if ( in_array( $continueAction, static::$allowedActions, true ) ) {
251
					$this->authAction = $continueAction;
252
				}
253
			}
254
		}
255
256
		$allReqs = AuthManager::singleton()->getAuthenticationRequests(
257
			$this->authAction, $this->getUser() );
258
		$this->authRequests = array_filter( $allReqs, function ( $req ) use ( $subPage ) {
259
			return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
260
		} );
261
	}
262
263
	/**
264
	 * Returns true if this is not the first step of the authentication.
265
	 * @return bool
266
	 */
267
	protected function isContinued() {
268
		return in_array( $this->authAction, [
269
			AuthManager::ACTION_LOGIN_CONTINUE,
270
			AuthManager::ACTION_CREATE_CONTINUE,
271
			AuthManager::ACTION_LINK_CONTINUE,
272
		], true );
273
	}
274
275
	/**
276
	 * Gets the _CONTINUE version of an action.
277
	 * @param string $action An AuthManager::ACTION_* constant.
278
	 * @return string An AuthManager::ACTION_*_CONTINUE constant.
279
	 */
280
	protected function getContinueAction( $action ) {
281
		switch ( $action ) {
282
			case AuthManager::ACTION_LOGIN:
283
				$action = AuthManager::ACTION_LOGIN_CONTINUE;
284
				break;
285
			case AuthManager::ACTION_CREATE:
286
				$action = AuthManager::ACTION_CREATE_CONTINUE;
287
				break;
288
			case AuthManager::ACTION_LINK:
289
				$action = AuthManager::ACTION_LINK_CONTINUE;
290
				break;
291
		}
292
		return $action;
293
	}
294
295
	/**
296
	 * Checks whether AuthManager is ready to perform the action.
297
	 * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
298
	 * the caller's responsibility.
299
	 * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
300
	 * @return bool
301
	 * @throws LogicException if $action is invalid
302
	 */
303
	protected function isActionAllowed( $action ) {
304
		$authManager = AuthManager::singleton();
305
		if ( !in_array( $action, static::$allowedActions, true ) ) {
306
			throw new InvalidArgumentException( 'invalid action: ' . $action );
307
		}
308
309
		// calling getAuthenticationRequests can be expensive, avoid if possible
310
		$requests = ( $action === $this->authAction ) ? $this->authRequests
311
			: $authManager->getAuthenticationRequests( $action );
312
		if ( !$requests ) {
313
			// no provider supports this action in the current state
314
			return false;
315
		}
316
317
		switch ( $action ) {
318
			case AuthManager::ACTION_LOGIN:
319
			case AuthManager::ACTION_LOGIN_CONTINUE:
320
				return $authManager->canAuthenticateNow();
321
			case AuthManager::ACTION_CREATE:
322
			case AuthManager::ACTION_CREATE_CONTINUE:
323
				return $authManager->canCreateAccounts();
324
			case AuthManager::ACTION_LINK:
325
			case AuthManager::ACTION_LINK_CONTINUE:
326
				return $authManager->canLinkAccounts();
327
			case AuthManager::ACTION_CHANGE:
328
			case AuthManager::ACTION_REMOVE:
329
			case AuthManager::ACTION_UNLINK:
330
				return true;
331
			default:
332
				// should never reach here but makes static code analyzers happy
333
				throw new InvalidArgumentException( 'invalid action: ' . $action );
334
		}
335
	}
336
337
	/**
338
	 * @param string $action One of the AuthManager::ACTION_* constants
339
	 * @param AuthenticationRequest[] $requests
340
	 * @return AuthenticationResponse
341
	 * @throws LogicException if $action is invalid
342
	 */
343
	protected function performAuthenticationStep( $action, array $requests ) {
344
		if ( !in_array( $action, static::$allowedActions, true ) ) {
345
			throw new InvalidArgumentException( 'invalid action: ' . $action );
346
		}
347
348
		$authManager = AuthManager::singleton();
349
		$returnToUrl = $this->getPageTitle( 'return' )
350
			->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
351
352
		switch ( $action ) {
353
			case AuthManager::ACTION_LOGIN:
354
				return $authManager->beginAuthentication( $requests, $returnToUrl );
355
			case AuthManager::ACTION_LOGIN_CONTINUE:
356
				return $authManager->continueAuthentication( $requests );
357
			case AuthManager::ACTION_CREATE:
358
				return $authManager->beginAccountCreation( $this->getUser(), $requests,
359
					$returnToUrl );
360
			case AuthManager::ACTION_CREATE_CONTINUE:
361
				return $authManager->continueAccountCreation( $requests );
362
			case AuthManager::ACTION_LINK:
363
				return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
364
			case AuthManager::ACTION_LINK_CONTINUE:
365
				return $authManager->continueAccountLink( $requests );
366
			case AuthManager::ACTION_CHANGE:
367
			case AuthManager::ACTION_REMOVE:
368
			case AuthManager::ACTION_UNLINK:
369
				if ( count( $requests ) > 1 ) {
370
					throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
371
				} elseif ( !$requests ) {
372
					throw new InvalidArgumentException( 'no auth request' );
373
				}
374
				$req = reset( $requests );
375
				$status = $authManager->allowsAuthenticationDataChange( $req );
0 ignored issues
show
Security Bug introduced by
It seems like $req defined by reset($requests) on line 374 can also be of type false; however, MediaWiki\Auth\AuthManag...henticationDataChange() does only seem to accept object<MediaWiki\Auth\AuthenticationRequest>, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
376
				Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
377
				if ( !$status->isGood() ) {
378
					return AuthenticationResponse::newFail( $status->getMessage() );
379
				}
380
				$authManager->changeAuthenticationData( $req );
0 ignored issues
show
Security Bug introduced by
It seems like $req defined by reset($requests) on line 374 can also be of type false; however, MediaWiki\Auth\AuthManag...ngeAuthenticationData() does only seem to accept object<MediaWiki\Auth\AuthenticationRequest>, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
381
				return AuthenticationResponse::newPass();
382
			default:
383
				// should never reach here but makes static code analyzers happy
384
				throw new InvalidArgumentException( 'invalid action: ' . $action );
385
		}
386
	}
387
388
	/**
389
	 * Attempts to do an authentication step with the submitted data.
390
	 * Subclasses should probably call this from execute().
391
	 * @return false|Status
392
	 *    - false if there was no submit at all
393
	 *    - a good Status wrapping an AuthenticationResponse if the form submit was successful.
394
	 *      This does not necessarily mean that the authentication itself was successful; see the
395
	 *      response for that.
396
	 *    - a bad Status for form errors.
397
	 */
398
	protected function trySubmit() {
399
		$status = false;
400
401
		$form = $this->getAuthForm( $this->authRequests, $this->authAction );
402
		$form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
403
404
		if ( $this->getRequest()->wasPosted() ) {
405
			// handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
406
			$requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
407
			$sessionToken = $this->getToken();
408
			if ( $sessionToken->wasNew() ) {
409
				return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
410
			} elseif ( !$requestTokenValue ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $requestTokenValue of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
411
				return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
412
			} elseif ( !$sessionToken->match( $requestTokenValue ) ) {
413
				return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
414
			}
415
416
			$form->prepareForm();
417
			$status = $form->trySubmit();
418
419
			// HTMLForm submit return values are a mess; let's ensure it is false or a Status
420
			// FIXME this probably should be in HTMLForm
421
			if ( $status === true ) {
422
				// not supposed to happen since our submit handler should always return a Status
423
				throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
424
			} elseif ( $status === false ) {
0 ignored issues
show
Unused Code introduced by
This elseif statement is empty, and could be removed.

This check looks for the bodies of elseif statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These elseif bodies can be removed. If you have an empty elseif but statements in the else branch, consider inverting the condition.

Loading history...
425
				// form was not submitted; nothing to do
426
			} elseif ( $status instanceof Status ) {
0 ignored issues
show
Unused Code introduced by
This elseif statement is empty, and could be removed.

This check looks for the bodies of elseif statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These elseif bodies can be removed. If you have an empty elseif but statements in the else branch, consider inverting the condition.

Loading history...
427
				// already handled by the form; nothing to do
428
			} elseif ( $status instanceof StatusValue ) {
429
				// in theory not an allowed return type but nothing stops the submit handler from
430
				// accidentally returning it so best check and fix
431
				$status = Status::wrap( $status );
432
			} elseif ( is_string( $status ) ) {
433
				$status = Status::newFatal( new RawMessage( '$1', $status ) );
434
			} elseif ( is_array( $status ) ) {
435
				if ( is_string( reset( $status ) ) ) {
436
					$status = call_user_func_array( 'Status::newFatal', $status );
437
				} elseif ( is_array( reset( $status ) ) ) {
438
					$status = Status::newGood();
439
					foreach ( $status as $message ) {
0 ignored issues
show
Bug introduced by
The expression $status of type object<Status> is not traversable.
Loading history...
440
						call_user_func_array( [ $status, 'fatal' ], $message );
441
					}
442
				} else {
443
					throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
444
						. 'first element of array is ' . gettype( reset( $status ) ) );
445
				}
446
			} else {
447
				// not supposed to happen but HTMLForm does not actually verify the return type
448
				// from the submit callback; better safe then sorry
449
				throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
450
					. gettype( $status ) );
451
			}
452
453
			if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
454
				// This is awkward. There was a form validation error, which means the data was not
455
				// passed to AuthManager. Normally we would display the form with an error message,
456
				// but for the data we received via the redirect flow that would not be helpful at all.
457
				// Let's just submit the data to AuthManager directly instead.
458
				LoggerFactory::getInstance( 'authentication' )
459
					->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
460
						'status' => $status->getWikiText() ] );
461
				$status = $this->handleFormSubmit( $form->mFieldData );
462
			}
463
		}
464
465
		$changeActions = [
466
			AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
467
		];
468
		if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
469
			Hooks::run( 'ChangeAuthenticationDataAudit', [ reset( $this->authRequests ), $status ] );
470
		}
471
472
		return $status;
473
	}
474
475
	/**
476
	 * Submit handler callback for HTMLForm
477
	 * @private
478
	 * @param $data array Submitted data
479
	 * @return Status
480
	 */
481 View Code Duplication
	public function handleFormSubmit( $data ) {
482
		$requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
483
		$response = $this->performAuthenticationStep( $this->authAction, $requests );
484
485
		// we can't handle FAIL or similar as failure here since it might require changing the form
486
		return Status::newGood( $response );
487
	}
488
489
	/**
490
	 * Returns URL query parameters which can be used to reload the page (or leave and return) while
491
	 * preserving all information that is necessary for authentication to continue. These parameters
492
	 * will be preserved in the action URL of the form and in the return URL for redirect flow.
493
	 * @param bool $withToken Include CSRF token
494
	 * @return array
495
	 */
496
	protected function getPreservedParams( $withToken = false ) {
497
		$params = [];
498
		if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
499
			$params['authAction'] = $this->getContinueAction( $this->authAction );
500
		}
501
		if ( $withToken ) {
502
			$params[$this->getTokenName()] = $this->getToken()->toString();
503
		}
504
		return $params;
505
	}
506
507
	/**
508
	 * Generates a HTMLForm descriptor array from a set of authentication requests.
509
	 * @param AuthenticationRequest[] $requests
510
	 * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
511
	 * @return array
512
	 */
513
	protected function getAuthFormDescriptor( $requests, $action ) {
514
		$fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
515
		$formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
516
517
		$this->addTabIndex( $formDescriptor );
518
519
		return $formDescriptor;
520
	}
521
522
	/**
523
	 * @param AuthenticationRequest[] $requests
524
	 * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
525
	 * @return HTMLForm
526
	 */
527
	protected function getAuthForm( array $requests, $action ) {
528
		$formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
529
		$context = $this->getContext();
530 View Code Duplication
		if ( $context->getRequest() !== $this->getRequest() ) {
531
			// We have overridden the request, need to make sure the form uses that too.
532
			$context = new DerivativeContext( $this->getContext() );
533
			$context->setRequest( $this->getRequest() );
534
		}
535
		$form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
536
		$form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
537
		$form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
538
		$form->addHiddenField( 'authAction', $this->authAction );
539
		$form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
540
541
		return $form;
542
	}
543
544
	/**
545
	 * Display the form.
546
	 * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
547
	 */
548
	protected function displayForm( $status ) {
549
		if ( $status instanceof StatusValue ) {
550
			$status = Status::wrap( $status );
551
		}
552
		$form = $this->getAuthForm( $this->authRequests, $this->authAction );
553
		$form->prepareForm()->displayForm( $status );
554
	}
555
556
	/**
557
	 * Returns true if the form built from the given AuthenticationRequests has fields which take
558
	 * values. If all available providers use the redirect flow, the form might contain nothing
559
	 * but submit buttons, in which case we should not add an extra submit button which does nothing.
560
	 *
561
	 * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
562
	 *  form will be built
563
	 * @return bool
564
	 */
565
	protected function needsSubmitButton( array $requests ) {
566
		foreach ( $requests as $req ) {
567
			if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED &&
568
				$this->doesRequestNeedsSubmitButton( $req )
569
			) {
570
				return true;
571
			}
572
		}
573
		return false;
574
	}
575
576
	/**
577
	 * Checks if the given AuthenticationRequest needs a submit button or not.
578
	 *
579
	 * @param AuthenticationRequest $req The request to check
580
	 * @return bool
581
	 */
582
	protected function doesRequestNeedsSubmitButton( AuthenticationRequest $req ) {
583
		foreach ( $req->getFieldInfo() as $field => $info ) {
584
			if ( $info['type'] === 'button' ) {
585
				return false;
586
			}
587
		}
588
		return true;
589
	}
590
591
	/**
592
	 * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
593
	 * use the tab key to traverse the form without having to step through all links and such.
594
	 * @param $formDescriptor
595
	 */
596
	protected function addTabIndex( &$formDescriptor ) {
597
		$i = 1;
598
		foreach ( $formDescriptor as $field => &$definition ) {
599
			$class = false;
600
			if ( array_key_exists( 'class', $definition ) ) {
601
				$class = $definition['class'];
602
			} elseif ( array_key_exists( 'type', $definition ) ) {
603
				$class = HTMLForm::$typeMappings[$definition['type']];
604
			}
605
			if ( $class !== 'HTMLInfoField' ) {
606
				$definition['tabindex'] = $i;
607
				$i++;
608
			}
609
		}
610
	}
611
612
	/**
613
	 * Returns the CSRF token.
614
	 * @return Token
615
	 */
616
	protected function getToken() {
617
		return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
618
			. $this->getName() );
619
	}
620
621
	/**
622
	 * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
623
	 * @return string
624
	 */
625
	protected function getTokenName() {
626
		return 'wpAuthToken';
627
	}
628
629
	/**
630
	 * Turns a field info array into a form descriptor. Behavior can be modified by the
631
	 * AuthChangeFormFields hook.
632
	 * @param AuthenticationRequest[] $requests
633
	 * @param array $fieldInfo Field information, in the format used by
634
	 *   AuthenticationRequest::getFieldInfo()
635
	 * @param string $action One of the AuthManager::ACTION_* constants
636
	 * @return array A form descriptor that can be passed to HTMLForm
637
	 */
638
	protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
639
		$formDescriptor = [];
640
		foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
641
			$formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
642
		}
643
644
		$requestSnapshot = serialize( $requests );
645
		$this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
646
		\Hooks::run( 'AuthChangeFormFields', [ $requests, $fieldInfo, &$formDescriptor, $action ] );
647
		if ( $requestSnapshot !== serialize( $requests ) ) {
648
			LoggerFactory::getInstance( 'authentication' )->warning(
649
				'AuthChangeFormFields hook changed auth requests' );
650
		}
651
652
		// Process the special 'weight' property, which is a way for AuthChangeFormFields hook
653
		// subscribers (who only see one field at a time) to influence ordering.
654
		self::sortFormDescriptorFields( $formDescriptor );
655
656
		return $formDescriptor;
657
	}
658
659
	/**
660
	 * Maps an authentication field configuration for a single field (as returned by
661
	 * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
662
	 * @param array $singleFieldInfo
663
	 * @return array
664
	 */
665
	protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
666
		$type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
667
		$descriptor = [
668
			'type' => $type,
669
			// Do not prefix input name with 'wp'. This is important for the redirect flow.
670
			'name' => $fieldName,
671
		];
672
673
		if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
674
			$descriptor['default'] = wfMessage( $singleFieldInfo['label'] )->plain();
675
		} elseif ( $type !== 'submit' ) {
676
			$descriptor += array_filter( [
677
				// help-message is omitted as it is usually not really useful for a web interface
678
				'label-message' => self::getField( $singleFieldInfo, 'label' ),
679
			] );
680
681
			if ( isset( $singleFieldInfo['options'] ) ) {
682
				$descriptor['options'] = array_flip( array_map( function ( $message ) {
683
					/** @var $message Message */
684
					return $message->parse();
685
				}, $singleFieldInfo['options'] ) );
686
			}
687
688
			if ( isset( $singleFieldInfo['value'] ) ) {
689
				$descriptor['default'] = $singleFieldInfo['value'];
690
			}
691
692
			if ( empty( $singleFieldInfo['optional'] ) ) {
693
				$descriptor['required'] = true;
694
			}
695
		}
696
697
		return $descriptor;
698
	}
699
700
	/**
701
	 * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
702
	 * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
703
	 * Keep order if weights are equal.
704
	 * @param array $formDescriptor
705
	 * @return array
706
	 */
707
	protected static function sortFormDescriptorFields( array &$formDescriptor ) {
708
		$i = 0;
709
		foreach ( $formDescriptor as &$field ) {
710
			$field['__index'] = $i++;
711
		}
712
		uasort( $formDescriptor, function ( $first, $second ) {
713
			return self::getField( $first, 'weight', 0 ) - self::getField( $second, 'weight', 0 )
714
				?: $first['__index'] - $second['__index'];
715
		} );
716
		foreach ( $formDescriptor as &$field ) {
717
			unset( $field['__index'] );
718
		}
719
	}
720
721
	/**
722
	 * Get an array value, or a default if it does not exist.
723
	 * @param array $array
724
	 * @param string $fieldName
725
	 * @param mixed $default
726
	 * @return mixed
727
	 */
728
	protected static function getField( array $array, $fieldName, $default = null ) {
729
		if ( array_key_exists( $fieldName, $array ) ) {
730
			return $array[$fieldName];
731
		} else {
732
			return $default;
733
		}
734
	}
735
736
	/**
737
	 * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
738
	 * @param string $type
739
	 * @return string
740
	 * @throws \LogicException
741
	 */
742
	protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
743
		$map = [
744
			'string' => 'text',
745
			'password' => 'password',
746
			'select' => 'select',
747
			'checkbox' => 'check',
748
			'multiselect' => 'multiselect',
749
			'button' => 'submit',
750
			'hidden' => 'hidden',
751
			'null' => 'info',
752
		];
753
		if ( !array_key_exists( $type, $map ) ) {
754
			throw new \LogicException( 'invalid field type: ' . $type );
755
		}
756
		return $map[$type];
757
	}
758
}
759