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