Issues (4122)

Security Analysis    not enabled

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Header Injection
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

includes/specialpage/AuthManagerSpecialPage.php (8 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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 array $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
	 * @param string $defaultKey
209
	 * @return string
210
	 */
211
	protected function messageKey( $defaultKey ) {
212
		return array_key_exists( $defaultKey, static::$messages )
213
			? static::$messages[$defaultKey] : $defaultKey;
214
	}
215
216
	/**
217
	 * Allows blacklisting certain request types.
218
	 * @return array A list of AuthenticationRequest subclass names
219
	 */
220
	protected function getRequestBlacklist() {
221
		return [];
222
	}
223
224
	/**
225
	 * Load or initialize $authAction, $authRequests and $subPage.
226
	 * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
227
	 * @param string $subPage Subpage of the special page.
228
	 * @param string $authAction Override auth action specified in request (this is useful
229
	 *    when the form needs to be changed from <action> to <action>_CONTINUE after a successful
230
	 *    authentication step)
231
	 * @param bool $reset Regenerate the requests even if a cached version is available
232
	 */
233
	protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
234
		// Do not load if already loaded, to cut down on the number of getAuthenticationRequests
235
		// calls. This is important for requests which have hidden information so any
236
		// getAuthenticationRequests call would mean putting data into some cache.
237
		if (
238
			!$reset && $this->subPage === $subPage && $this->authAction
239
			&& ( !$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...
240
		) {
241
			return;
242
		}
243
244
		$request = $this->getRequest();
245
		$this->subPage = $subPage;
246
		$this->authAction = $authAction ?: $request->getText( 'authAction' );
247
		if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
248
			$this->authAction = $this->getDefaultAction( $subPage );
249
			if ( $request->wasPosted() ) {
250
				$continueAction = $this->getContinueAction( $this->authAction );
251
				if ( in_array( $continueAction, static::$allowedActions, true ) ) {
252
					$this->authAction = $continueAction;
253
				}
254
			}
255
		}
256
257
		$allReqs = AuthManager::singleton()->getAuthenticationRequests(
258
			$this->authAction, $this->getUser() );
259
		$this->authRequests = array_filter( $allReqs, function ( $req ) use ( $subPage ) {
260
			return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
261
		} );
262
	}
263
264
	/**
265
	 * Returns true if this is not the first step of the authentication.
266
	 * @return bool
267
	 */
268
	protected function isContinued() {
269
		return in_array( $this->authAction, [
270
			AuthManager::ACTION_LOGIN_CONTINUE,
271
			AuthManager::ACTION_CREATE_CONTINUE,
272
			AuthManager::ACTION_LINK_CONTINUE,
273
		], true );
274
	}
275
276
	/**
277
	 * Gets the _CONTINUE version of an action.
278
	 * @param string $action An AuthManager::ACTION_* constant.
279
	 * @return string An AuthManager::ACTION_*_CONTINUE constant.
280
	 */
281
	protected function getContinueAction( $action ) {
282
		switch ( $action ) {
283
			case AuthManager::ACTION_LOGIN:
284
				$action = AuthManager::ACTION_LOGIN_CONTINUE;
285
				break;
286
			case AuthManager::ACTION_CREATE:
287
				$action = AuthManager::ACTION_CREATE_CONTINUE;
288
				break;
289
			case AuthManager::ACTION_LINK:
290
				$action = AuthManager::ACTION_LINK_CONTINUE;
291
				break;
292
		}
293
		return $action;
294
	}
295
296
	/**
297
	 * Checks whether AuthManager is ready to perform the action.
298
	 * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
299
	 * the caller's responsibility.
300
	 * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
301
	 * @return bool
302
	 * @throws LogicException if $action is invalid
303
	 */
304
	protected function isActionAllowed( $action ) {
305
		$authManager = AuthManager::singleton();
306
		if ( !in_array( $action, static::$allowedActions, true ) ) {
307
			throw new InvalidArgumentException( 'invalid action: ' . $action );
308
		}
309
310
		// calling getAuthenticationRequests can be expensive, avoid if possible
311
		$requests = ( $action === $this->authAction ) ? $this->authRequests
312
			: $authManager->getAuthenticationRequests( $action );
313
		if ( !$requests ) {
314
			// no provider supports this action in the current state
315
			return false;
316
		}
317
318
		switch ( $action ) {
319
			case AuthManager::ACTION_LOGIN:
320
			case AuthManager::ACTION_LOGIN_CONTINUE:
321
				return $authManager->canAuthenticateNow();
322
			case AuthManager::ACTION_CREATE:
323
			case AuthManager::ACTION_CREATE_CONTINUE:
324
				return $authManager->canCreateAccounts();
325
			case AuthManager::ACTION_LINK:
326
			case AuthManager::ACTION_LINK_CONTINUE:
327
				return $authManager->canLinkAccounts();
328
			case AuthManager::ACTION_CHANGE:
329
			case AuthManager::ACTION_REMOVE:
330
			case AuthManager::ACTION_UNLINK:
331
				return true;
332
			default:
333
				// should never reach here but makes static code analyzers happy
334
				throw new InvalidArgumentException( 'invalid action: ' . $action );
335
		}
336
	}
337
338
	/**
339
	 * @param string $action One of the AuthManager::ACTION_* constants
340
	 * @param AuthenticationRequest[] $requests
341
	 * @return AuthenticationResponse
342
	 * @throws LogicException if $action is invalid
343
	 */
344
	protected function performAuthenticationStep( $action, array $requests ) {
345
		if ( !in_array( $action, static::$allowedActions, true ) ) {
346
			throw new InvalidArgumentException( 'invalid action: ' . $action );
347
		}
348
349
		$authManager = AuthManager::singleton();
350
		$returnToUrl = $this->getPageTitle( 'return' )
351
			->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
352
353
		switch ( $action ) {
354
			case AuthManager::ACTION_LOGIN:
355
				return $authManager->beginAuthentication( $requests, $returnToUrl );
356
			case AuthManager::ACTION_LOGIN_CONTINUE:
357
				return $authManager->continueAuthentication( $requests );
358
			case AuthManager::ACTION_CREATE:
359
				return $authManager->beginAccountCreation( $this->getUser(), $requests,
360
					$returnToUrl );
361
			case AuthManager::ACTION_CREATE_CONTINUE:
362
				return $authManager->continueAccountCreation( $requests );
363
			case AuthManager::ACTION_LINK:
364
				return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
365
			case AuthManager::ACTION_LINK_CONTINUE:
366
				return $authManager->continueAccountLink( $requests );
367
			case AuthManager::ACTION_CHANGE:
368
			case AuthManager::ACTION_REMOVE:
369
			case AuthManager::ACTION_UNLINK:
370
				if ( count( $requests ) > 1 ) {
371
					throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
372
				} elseif ( !$requests ) {
373
					throw new InvalidArgumentException( 'no auth request' );
374
				}
375
				$req = reset( $requests );
376
				$status = $authManager->allowsAuthenticationDataChange( $req );
0 ignored issues
show
It seems like $req defined by reset($requests) on line 375 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...
377
				Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
378
				if ( !$status->isGood() ) {
379
					return AuthenticationResponse::newFail( $status->getMessage() );
380
				}
381
				$authManager->changeAuthenticationData( $req );
0 ignored issues
show
It seems like $req defined by reset($requests) on line 375 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...
382
				return AuthenticationResponse::newPass();
383
			default:
384
				// should never reach here but makes static code analyzers happy
385
				throw new InvalidArgumentException( 'invalid action: ' . $action );
386
		}
387
	}
388
389
	/**
390
	 * Attempts to do an authentication step with the submitted data.
391
	 * Subclasses should probably call this from execute().
392
	 * @return false|Status
393
	 *    - false if there was no submit at all
394
	 *    - a good Status wrapping an AuthenticationResponse if the form submit was successful.
395
	 *      This does not necessarily mean that the authentication itself was successful; see the
396
	 *      response for that.
397
	 *    - a bad Status for form errors.
398
	 */
399
	protected function trySubmit() {
400
		$status = false;
401
402
		$form = $this->getAuthForm( $this->authRequests, $this->authAction );
403
		$form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
404
405
		if ( $this->getRequest()->wasPosted() ) {
406
			// handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
407
			$requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
408
			$sessionToken = $this->getToken();
409
			if ( $sessionToken->wasNew() ) {
410
				return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
411
			} 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...
412
				return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
413
			} elseif ( !$sessionToken->match( $requestTokenValue ) ) {
414
				return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
415
			}
416
417
			$form->prepareForm();
418
			$status = $form->trySubmit();
419
420
			// HTMLForm submit return values are a mess; let's ensure it is false or a Status
421
			// FIXME this probably should be in HTMLForm
422
			if ( $status === true ) {
423
				// not supposed to happen since our submit handler should always return a Status
424
				throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
425
			} elseif ( $status === false ) {
0 ignored issues
show
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...
426
				// form was not submitted; nothing to do
427
			} elseif ( $status instanceof Status ) {
0 ignored issues
show
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...
428
				// already handled by the form; nothing to do
429
			} elseif ( $status instanceof StatusValue ) {
430
				// in theory not an allowed return type but nothing stops the submit handler from
431
				// accidentally returning it so best check and fix
432
				$status = Status::wrap( $status );
433
			} elseif ( is_string( $status ) ) {
434
				$status = Status::newFatal( new RawMessage( '$1', $status ) );
435
			} elseif ( is_array( $status ) ) {
436
				if ( is_string( reset( $status ) ) ) {
437
					$status = call_user_func_array( 'Status::newFatal', $status );
438
				} elseif ( is_array( reset( $status ) ) ) {
439
					$status = Status::newGood();
440
					foreach ( $status as $message ) {
0 ignored issues
show
The expression $status of type object<Status> is not traversable.
Loading history...
441
						call_user_func_array( [ $status, 'fatal' ], $message );
442
					}
443
				} else {
444
					throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
445
						. 'first element of array is ' . gettype( reset( $status ) ) );
446
				}
447
			} else {
448
				// not supposed to happen but HTMLForm does not actually verify the return type
449
				// from the submit callback; better safe then sorry
450
				throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
451
					. gettype( $status ) );
452
			}
453
454
			if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
455
				// This is awkward. There was a form validation error, which means the data was not
456
				// passed to AuthManager. Normally we would display the form with an error message,
457
				// but for the data we received via the redirect flow that would not be helpful at all.
458
				// Let's just submit the data to AuthManager directly instead.
459
				LoggerFactory::getInstance( 'authentication' )
460
					->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
461
						'status' => $status->getWikiText() ] );
462
				$status = $this->handleFormSubmit( $form->mFieldData );
463
			}
464
		}
465
466
		$changeActions = [
467
			AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
468
		];
469
		if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
470
			Hooks::run( 'ChangeAuthenticationDataAudit', [ reset( $this->authRequests ), $status ] );
471
		}
472
473
		return $status;
474
	}
475
476
	/**
477
	 * Submit handler callback for HTMLForm
478
	 * @private
479
	 * @param $data array Submitted data
480
	 * @return Status
481
	 */
482 View Code Duplication
	public function handleFormSubmit( $data ) {
483
		$requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
484
		$response = $this->performAuthenticationStep( $this->authAction, $requests );
485
486
		// we can't handle FAIL or similar as failure here since it might require changing the form
487
		return Status::newGood( $response );
488
	}
489
490
	/**
491
	 * Returns URL query parameters which can be used to reload the page (or leave and return) while
492
	 * preserving all information that is necessary for authentication to continue. These parameters
493
	 * will be preserved in the action URL of the form and in the return URL for redirect flow.
494
	 * @param bool $withToken Include CSRF token
495
	 * @return array
496
	 */
497
	protected function getPreservedParams( $withToken = false ) {
498
		$params = [];
499
		if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
500
			$params['authAction'] = $this->getContinueAction( $this->authAction );
501
		}
502
		if ( $withToken ) {
503
			$params[$this->getTokenName()] = $this->getToken()->toString();
504
		}
505
		return $params;
506
	}
507
508
	/**
509
	 * Generates a HTMLForm descriptor array from a set of authentication requests.
510
	 * @param AuthenticationRequest[] $requests
511
	 * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
512
	 * @return array
513
	 */
514
	protected function getAuthFormDescriptor( $requests, $action ) {
515
		$fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
516
		$formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
517
518
		$this->addTabIndex( $formDescriptor );
519
520
		return $formDescriptor;
521
	}
522
523
	/**
524
	 * @param AuthenticationRequest[] $requests
525
	 * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
526
	 * @return HTMLForm
527
	 */
528
	protected function getAuthForm( array $requests, $action ) {
529
		$formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
530
		$context = $this->getContext();
531 View Code Duplication
		if ( $context->getRequest() !== $this->getRequest() ) {
532
			// We have overridden the request, need to make sure the form uses that too.
533
			$context = new DerivativeContext( $this->getContext() );
534
			$context->setRequest( $this->getRequest() );
535
		}
536
		$form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
537
		$form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
538
		$form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
539
		$form->addHiddenField( 'authAction', $this->authAction );
540
		$form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
541
542
		return $form;
543
	}
544
545
	/**
546
	 * Display the form.
547
	 * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
548
	 */
549
	protected function displayForm( $status ) {
550
		if ( $status instanceof StatusValue ) {
551
			$status = Status::wrap( $status );
552
		}
553
		$form = $this->getAuthForm( $this->authRequests, $this->authAction );
554
		$form->prepareForm()->displayForm( $status );
555
	}
556
557
	/**
558
	 * Returns true if the form built from the given AuthenticationRequests needs a submit button.
559
	 * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using
560
	 * one of those custom buttons is the only way to proceed, there is no point in displaying the
561
	 * default button which won't do anything useful.
562
	 *
563
	 * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
564
	 *  form will be built
565
	 * @return bool
566
	 */
567
	protected function needsSubmitButton( array $requests ) {
568
		$customSubmitButtonPresent = false;
569
570
		// Secondary and preauth providers always need their data; they will not care what button
571
		// is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers;
572
		// that's the point in being optional. Se we need to check whether all primary providers
573
		// have their own buttons and whether there is at least one button present.
574
		foreach ( $requests as $req ) {
575
			if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) {
576
				if ( $this->hasOwnSubmitButton( $req ) ) {
577
					$customSubmitButtonPresent = true;
578
				} else {
579
					return true;
580
				}
581
			}
582
		}
583
		return !$customSubmitButtonPresent;
584
	}
585
586
	/**
587
	 * Checks whether the given AuthenticationRequest has its own submit button.
588
	 * @param AuthenticationRequest $req
589
	 * @return bool
590
	 */
591
	protected function hasOwnSubmitButton( AuthenticationRequest $req ) {
592
		foreach ( $req->getFieldInfo() as $field => $info ) {
593
			if ( $info['type'] === 'button' ) {
594
				return true;
595
			}
596
		}
597
		return false;
598
	}
599
600
	/**
601
	 * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
602
	 * use the tab key to traverse the form without having to step through all links and such.
603
	 * @param $formDescriptor
604
	 */
605
	protected function addTabIndex( &$formDescriptor ) {
606
		$i = 1;
607
		foreach ( $formDescriptor as $field => &$definition ) {
608
			$class = false;
609
			if ( array_key_exists( 'class', $definition ) ) {
610
				$class = $definition['class'];
611
			} elseif ( array_key_exists( 'type', $definition ) ) {
612
				$class = HTMLForm::$typeMappings[$definition['type']];
613
			}
614
			if ( $class !== 'HTMLInfoField' ) {
615
				$definition['tabindex'] = $i;
616
				$i++;
617
			}
618
		}
619
	}
620
621
	/**
622
	 * Returns the CSRF token.
623
	 * @return Token
624
	 */
625
	protected function getToken() {
626
		return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
627
			. $this->getName() );
628
	}
629
630
	/**
631
	 * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
632
	 * @return string
633
	 */
634
	protected function getTokenName() {
635
		return 'wpAuthToken';
636
	}
637
638
	/**
639
	 * Turns a field info array into a form descriptor. Behavior can be modified by the
640
	 * AuthChangeFormFields hook.
641
	 * @param AuthenticationRequest[] $requests
642
	 * @param array $fieldInfo Field information, in the format used by
643
	 *   AuthenticationRequest::getFieldInfo()
644
	 * @param string $action One of the AuthManager::ACTION_* constants
645
	 * @return array A form descriptor that can be passed to HTMLForm
646
	 */
647
	protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
648
		$formDescriptor = [];
649
		foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
650
			$formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
651
		}
652
653
		$requestSnapshot = serialize( $requests );
654
		$this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
655
		\Hooks::run( 'AuthChangeFormFields', [ $requests, $fieldInfo, &$formDescriptor, $action ] );
656
		if ( $requestSnapshot !== serialize( $requests ) ) {
657
			LoggerFactory::getInstance( 'authentication' )->warning(
658
				'AuthChangeFormFields hook changed auth requests' );
659
		}
660
661
		// Process the special 'weight' property, which is a way for AuthChangeFormFields hook
662
		// subscribers (who only see one field at a time) to influence ordering.
663
		self::sortFormDescriptorFields( $formDescriptor );
664
665
		return $formDescriptor;
666
	}
667
668
	/**
669
	 * Maps an authentication field configuration for a single field (as returned by
670
	 * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
671
	 * @param array $singleFieldInfo
672
	 * @param string $fieldName
673
	 * @return array
674
	 */
675
	protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
676
		$type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
677
		$descriptor = [
678
			'type' => $type,
679
			// Do not prefix input name with 'wp'. This is important for the redirect flow.
680
			'name' => $fieldName,
681
		];
682
683
		if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
684
			$descriptor['default'] = wfMessage( $singleFieldInfo['label'] )->plain();
685
		} elseif ( $type !== 'submit' ) {
686
			$descriptor += array_filter( [
687
				// help-message is omitted as it is usually not really useful for a web interface
688
				'label-message' => self::getField( $singleFieldInfo, 'label' ),
689
			] );
690
691
			if ( isset( $singleFieldInfo['options'] ) ) {
692
				$descriptor['options'] = array_flip( array_map( function ( $message ) {
693
					/** @var $message Message */
694
					return $message->parse();
695
				}, $singleFieldInfo['options'] ) );
696
			}
697
698
			if ( isset( $singleFieldInfo['value'] ) ) {
699
				$descriptor['default'] = $singleFieldInfo['value'];
700
			}
701
702
			if ( empty( $singleFieldInfo['optional'] ) ) {
703
				$descriptor['required'] = true;
704
			}
705
		}
706
707
		return $descriptor;
708
	}
709
710
	/**
711
	 * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
712
	 * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
713
	 * Keep order if weights are equal.
714
	 * @param array $formDescriptor
715
	 * @return array
716
	 */
717
	protected static function sortFormDescriptorFields( array &$formDescriptor ) {
718
		$i = 0;
719
		foreach ( $formDescriptor as &$field ) {
720
			$field['__index'] = $i++;
721
		}
722
		uasort( $formDescriptor, function ( $first, $second ) {
723
			return self::getField( $first, 'weight', 0 ) - self::getField( $second, 'weight', 0 )
724
				?: $first['__index'] - $second['__index'];
725
		} );
726
		foreach ( $formDescriptor as &$field ) {
727
			unset( $field['__index'] );
728
		}
729
	}
730
731
	/**
732
	 * Get an array value, or a default if it does not exist.
733
	 * @param array $array
734
	 * @param string $fieldName
735
	 * @param mixed $default
736
	 * @return mixed
737
	 */
738
	protected static function getField( array $array, $fieldName, $default = null ) {
739
		if ( array_key_exists( $fieldName, $array ) ) {
740
			return $array[$fieldName];
741
		} else {
742
			return $default;
743
		}
744
	}
745
746
	/**
747
	 * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
748
	 * @param string $type
749
	 * @return string
750
	 * @throws \LogicException
751
	 */
752
	protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
753
		$map = [
754
			'string' => 'text',
755
			'password' => 'password',
756
			'select' => 'select',
757
			'checkbox' => 'check',
758
			'multiselect' => 'multiselect',
759
			'button' => 'submit',
760
			'hidden' => 'hidden',
761
			'null' => 'info',
762
		];
763
		if ( !array_key_exists( $type, $map ) ) {
764
			throw new \LogicException( 'invalid field type: ' . $type );
765
		}
766
		return $map[$type];
767
	}
768
}
769