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     */
339    protected function isActionAllowed( $action ) {
340        $authManager = $this->getAuthManager();
341        if ( !in_array( $action, static::$allowedActions, true ) ) {
342            throw new InvalidArgumentException( 'invalid action: ' . $action );
343        }
344
345        // calling getAuthenticationRequests can be expensive, avoid if possible
346        $requests = ( $action === $this->authAction ) ? $this->authRequests
347            : $authManager->getAuthenticationRequests( $action );
348        if ( !$requests ) {
349            // no provider supports this action in the current state
350            return false;
351        }
352
353        switch ( $action ) {
354            case AuthManager::ACTION_LOGIN:
355            case AuthManager::ACTION_LOGIN_CONTINUE:
356                return $authManager->canAuthenticateNow();
357            case AuthManager::ACTION_CREATE:
358            case AuthManager::ACTION_CREATE_CONTINUE:
359                return $authManager->canCreateAccounts();
360            case AuthManager::ACTION_LINK:
361            case AuthManager::ACTION_LINK_CONTINUE:
362                return $authManager->canLinkAccounts();
363            case AuthManager::ACTION_CHANGE:
364            case AuthManager::ACTION_REMOVE:
365            case AuthManager::ACTION_UNLINK:
366                return true;
367            default:
368                // should never reach here but makes static code analyzers happy
369                throw new InvalidArgumentException( 'invalid action: ' . $action );
370        }
371    }
372
373    /**
374     * @param string $action One of the AuthManager::ACTION_* constants
375     * @param AuthenticationRequest[] $requests
376     * @return AuthenticationResponse
377     * @throws LogicException if $action is invalid
378     */
379    protected function performAuthenticationStep( $action, array $requests ) {
380        if ( !in_array( $action, static::$allowedActions, true ) ) {
381            throw new InvalidArgumentException( 'invalid action: ' . $action );
382        }
383
384        $authManager = $this->getAuthManager();
385        $returnToUrl = $this->getPageTitle( 'return' )
386            ->getFullURL( $this->getPreservedParams( [ 'withToken' => true ] ), false, PROTO_HTTPS );
387
388        switch ( $action ) {
389            case AuthManager::ACTION_LOGIN:
390                return $authManager->beginAuthentication( $requests, $returnToUrl );
391            case AuthManager::ACTION_LOGIN_CONTINUE:
392                return $authManager->continueAuthentication( $requests );
393            case AuthManager::ACTION_CREATE:
394                return $authManager->beginAccountCreation( $this->getAuthority(), $requests,
395                    $returnToUrl );
396            case AuthManager::ACTION_CREATE_CONTINUE:
397                return $authManager->continueAccountCreation( $requests );
398            case AuthManager::ACTION_LINK:
399                return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
400            case AuthManager::ACTION_LINK_CONTINUE:
401                return $authManager->continueAccountLink( $requests );
402            case AuthManager::ACTION_CHANGE:
403            case AuthManager::ACTION_REMOVE:
404            case AuthManager::ACTION_UNLINK:
405                if ( count( $requests ) > 1 ) {
406                    throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
407                }
408
409                if ( !$requests ) {
410                    throw new InvalidArgumentException( 'no auth request' );
411                }
412                $req = reset( $requests );
413                $status = $authManager->allowsAuthenticationDataChange( $req );
414                $this->getHookRunner()->onChangeAuthenticationDataAudit( $req, $status );
415                if ( !$status->isGood() ) {
416                    return AuthenticationResponse::newFail( $status->getMessage() );
417                }
418                $authManager->changeAuthenticationData( $req );
419                return AuthenticationResponse::newPass();
420            default:
421                // should never reach here but makes static code analyzers happy
422                throw new InvalidArgumentException( 'invalid action: ' . $action );
423        }
424    }
425
426    /**
427     * Attempts to do an authentication step with the submitted data.
428     * Subclasses should probably call this from execute().
429     * @return false|Status
430     *    - false if there was no submit at all
431     *    - a good Status wrapping an AuthenticationResponse if the form submit was successful.
432     *      This does not necessarily mean that the authentication itself was successful; see the
433     *      response for that.
434     *    - a bad Status for form errors.
435     */
436    protected function trySubmit() {
437        $status = false;
438
439        $form = $this->getAuthForm( $this->authRequests, $this->authAction );
440        $form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
441
442        if ( $this->getRequest()->wasPosted() ) {
443            // handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
444            $requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
445            $sessionToken = $this->getToken();
446            if ( $sessionToken->wasNew() ) {
447                return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
448            } elseif ( !$requestTokenValue ) {
449                return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
450            } elseif ( !$sessionToken->match( $requestTokenValue ) ) {
451                return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
452            }
453
454            $form->prepareForm();
455            $status = $form->trySubmit();
456
457            // HTMLForm submit return values are a mess; let's ensure it is false or a Status
458            // FIXME this probably should be in HTMLForm
459            if ( $status === true ) {
460                // not supposed to happen since our submit handler should always return a Status
461                throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
462            } elseif ( $status === false ) {
463                // form was not submitted; nothing to do
464            } elseif ( $status instanceof Status ) {
465                // already handled by the form; nothing to do
466            } elseif ( $status instanceof StatusValue ) {
467                // in theory not an allowed return type but nothing stops the submit handler from
468                // accidentally returning it so best check and fix
469                $status = Status::wrap( $status );
470            } elseif ( is_string( $status ) ) {
471                $status = Status::newFatal( new RawMessage( '$1', [ $status ] ) );
472            } elseif ( is_array( $status ) ) {
473                if ( is_string( reset( $status ) ) ) {
474                    // @phan-suppress-next-line PhanParamTooFewUnpack
475                    $status = Status::newFatal( ...$status );
476                } elseif ( is_array( reset( $status ) ) ) {
477                    $ret = Status::newGood();
478                    foreach ( $status as $message ) {
479                        // @phan-suppress-next-line PhanParamTooFewUnpack
480                        $ret->fatal( ...$message );
481                    }
482                    $status = $ret;
483                } else {
484                    throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
485                        . 'first element of array is ' . get_debug_type( reset( $status ) ) );
486                }
487            } else {
488                // not supposed to happen, but HTMLForm does not verify the return type
489                // from the submit callback; better safe then sorry!
490                throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
491                    . get_debug_type( $status ) );
492            }
493
494            if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
495                // This is awkward. There was a form validation error, which means the data was not
496                // passed to AuthManager. Normally we would display the form with an error message,
497                // but for the data we received via the redirect flow that would not be helpful at all.
498                // Let's just submit the data to AuthManager directly instead.
499                LoggerFactory::getInstance( 'authentication' )
500                    ->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
501                        'status' => $status->getWikiText( false, false, 'en' ) ] );
502                $status = $this->handleFormSubmit( $form->mFieldData );
503            }
504        }
505
506        $changeActions = [
507            AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
508        ];
509        if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
510            $this->getHookRunner()->onChangeAuthenticationDataAudit( reset( $this->authRequests ), $status );
511        }
512
513        return $status;
514    }
515
516    /**
517     * Submit handler callback for HTMLForm
518     * @internal
519     * @param array $data Submitted data
520     * @return Status
521     */
522    public function handleFormSubmit( $data ) {
523        $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
524        $response = $this->performAuthenticationStep( $this->authAction, $requests );
525
526        // we can't handle FAIL or similar as failure here since it might require changing the form
527        return Status::newGood( $response );
528    }
529
530    /**
531     * Returns URL query parameters which should be preserved between authentication requests.
532     * These should be used when generating links such as form submit or language switch.
533     *
534     * These parameters will be preserved in:
535     * - successive authentication steps (the form submit URL and the return URL for redirecting
536     *   providers);
537     * - links that reload the same form somehow (e.g. language switcher links);
538     * - links for switching between the login and create account forms.
539     *
540     * @stable to override
541     * @param array $options (since 1.43)
542     *   - reset (bool, default false): Reset the authentication process, i.e. omit parameters
543     *     which are related to continuing in-progress authentication.
544     *   - withToken (bool, default false): Include CSRF token
545     *   Before 1.43, this was a boolean flag identical to the current 'withToken' option.
546     *   That usage is deprecated.
547     * @phan-param array{reset?: bool, withToken?: bool}|bool $options
548     * @return array Array of parameter name => parameter value.
549     */
550    protected function getPreservedParams( $options = [] ) {
551        if ( is_bool( $options ) ) {
552            wfDeprecated( __METHOD__ . ' boolean $options', '1.43' );
553            $options = [ 'withToken' => $options ];
554        }
555        $options += [
556            'reset' => false,
557            'withToken' => false,
558        ];
559        // Help Phan figure out that these fields are now definitely set - https://github.com/phan/phan/issues/4864
560        '@phan-var array{reset: bool, withToken: bool} $options';
561        $params = [];
562        $request = $this->getRequest();
563
564        $params += [
565            'uselang' => $request->getVal( 'uselang' ),
566            'variant' => $request->getVal( 'variant' ),
567            'returnto' => $request->getVal( 'returnto' ),
568            'returntoquery' => $request->getVal( 'returntoquery' ),
569            'returntoanchor' => $request->getVal( 'returntoanchor' ),
570        ];
571
572        if ( !$options['reset'] && $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
573            $params['authAction'] = $this->getContinueAction( $this->authAction );
574        }
575
576        if ( $options['withToken'] ) {
577            $params[$this->getTokenName()] = $this->getToken()->toString();
578        }
579
580        // Allow authentication extensions like CentralAuth to preserve their own
581        // query params during and after the authentication process.
582        $this->getHookRunner()->onAuthPreserveQueryParams(
583            $params, [ 'reset' => $options['reset'] ]
584        );
585
586        return array_filter( $params, fn ( $val ) => $val !== null );
587    }
588
589    /**
590     * Generates a HTMLForm descriptor array from a set of authentication requests.
591     * @stable to override
592     * @param AuthenticationRequest[] $requests
593     * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
594     * @return array[]
595     */
596    protected function getAuthFormDescriptor( $requests, $action ) {
597        $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
598        $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
599
600        $this->addTabIndex( $formDescriptor );
601
602        return $formDescriptor;
603    }
604
605    /**
606     * @stable to override
607     * @param AuthenticationRequest[] $requests
608     * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
609     * @return HTMLForm
610     */
611    protected function getAuthForm( array $requests, $action ) {
612        $formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
613        $context = $this->getContext();
614        if ( $context->getRequest() !== $this->getRequest() ) {
615            // We have overridden the request, need to make sure the form uses that too.
616            $context = new DerivativeContext( $this->getContext() );
617            $context->setRequest( $this->getRequest() );
618        }
619        $form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
620        $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
621        $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
622        $form->addHiddenField( 'authAction', $this->authAction );
623        $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
624
625        return $form;
626    }
627
628    /**
629     * Display the form.
630     * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
631     */
632    protected function displayForm( $status ) {
633        if ( $status instanceof StatusValue ) {
634            $status = Status::wrap( $status );
635        }
636        $form = $this->getAuthForm( $this->authRequests, $this->authAction );
637        $form->prepareForm()->displayForm( $status );
638    }
639
640    /**
641     * Returns true if the form built from the given AuthenticationRequests needs a submit button.
642     * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using
643     * one of those custom buttons is the only way to proceed, there is no point in displaying the
644     * default button which won't do anything useful.
645     * @stable to override
646     *
647     * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
648     *  form will be built
649     * @return bool
650     */
651    protected function needsSubmitButton( array $requests ) {
652        $customSubmitButtonPresent = false;
653
654        // Secondary and preauth providers always need their data; they will not care what button
655        // is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers;
656        // that's the point in being optional. Se we need to check whether all primary providers
657        // have their own buttons and whether there is at least one button present.
658        foreach ( $requests as $req ) {
659            if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) {
660                if ( $this->hasOwnSubmitButton( $req ) ) {
661                    $customSubmitButtonPresent = true;
662                } else {
663                    return true;
664                }
665            }
666        }
667        return !$customSubmitButtonPresent;
668    }
669
670    /**
671     * Checks whether the given AuthenticationRequest has its own submit button.
672     * @param AuthenticationRequest $req
673     * @return bool
674     */
675    protected function hasOwnSubmitButton( AuthenticationRequest $req ) {
676        foreach ( $req->getFieldInfo() as $info ) {
677            if ( $info['type'] === 'button' ) {
678                return true;
679            }
680        }
681        return false;
682    }
683
684    /**
685     * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
686     * use the tab key to traverse the form without having to step through all links and such.
687     * @param array[] &$formDescriptor
688     */
689    protected function addTabIndex( &$formDescriptor ) {
690        $i = 1;
691        foreach ( $formDescriptor as &$definition ) {
692            $class = false;
693            if ( array_key_exists( 'class', $definition ) ) {
694                $class = $definition['class'];
695            } elseif ( array_key_exists( 'type', $definition ) ) {
696                $class = HTMLForm::$typeMappings[$definition['type']];
697            }
698            if ( $class !== HTMLInfoField::class ) {
699                $definition['tabindex'] = $i;
700                $i++;
701            }
702        }
703    }
704
705    /**
706     * Returns the CSRF token.
707     * @stable to override
708     * @return Token
709     */
710    protected function getToken() {
711        return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
712            . $this->getName() );
713    }
714
715    /**
716     * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
717     * @stable to override
718     * @return string
719     */
720    protected function getTokenName() {
721        return 'wpAuthToken';
722    }
723
724    /**
725     * Turns a field info array into a form descriptor. Behavior can be modified by the
726     * AuthChangeFormFields hook.
727     * @param AuthenticationRequest[] $requests
728     * @param array $fieldInfo Field information, in the format used by
729     *   AuthenticationRequest::getFieldInfo()
730     * @param string $action One of the AuthManager::ACTION_* constants
731     * @return array A form descriptor that can be passed to HTMLForm
732     */
733    protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
734        $formDescriptor = [];
735        foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
736            $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
737        }
738
739        $requestSnapshot = serialize( $requests );
740        $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
741        $this->getHookRunner()->onAuthChangeFormFields( $requests, $fieldInfo,
742            $formDescriptor, $action );
743        if ( $requestSnapshot !== serialize( $requests ) ) {
744            LoggerFactory::getInstance( 'authentication' )->warning(
745                'AuthChangeFormFields hook changed auth requests' );
746        }
747
748        // Process the special 'weight' property, which is a way for AuthChangeFormFields hook
749        // subscribers (who only see one field at a time) to influence ordering.
750        self::sortFormDescriptorFields( $formDescriptor );
751
752        return $formDescriptor;
753    }
754
755    /**
756     * Maps an authentication field configuration for a single field (as returned by
757     * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
758     * @param array $singleFieldInfo
759     * @param string $fieldName
760     * @return array
761     */
762    protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
763        $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
764        $descriptor = [
765            'type' => $type,
766            // Do not prefix input name with 'wp'. This is important for the redirect flow.
767            'name' => $fieldName,
768        ];
769
770        if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
771            $descriptor['default'] = $singleFieldInfo['label']->plain();
772        } elseif ( $type !== 'submit' ) {
773            $descriptor += array_filter( [
774                // help-message is omitted as it is usually not really useful for a web interface
775                'label-message' => self::getField( $singleFieldInfo, 'label' ),
776            ] );
777
778            if ( isset( $singleFieldInfo['options'] ) ) {
779                $descriptor['options'] = array_flip( array_map( static function ( $message ) {
780                    /** @var Message $message */
781                    return $message->parse();
782                }, $singleFieldInfo['options'] ) );
783            }
784
785            if ( isset( $singleFieldInfo['value'] ) ) {
786                $descriptor['default'] = $singleFieldInfo['value'];
787            }
788
789            if ( empty( $singleFieldInfo['optional'] ) ) {
790                $descriptor['required'] = true;
791            }
792        }
793
794        return $descriptor;
795    }
796
797    /**
798     * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
799     * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
800     * Keep order if weights are equal.
801     * @param array &$formDescriptor
802     */
803    protected static function sortFormDescriptorFields( array &$formDescriptor ) {
804        $i = 0;
805        foreach ( $formDescriptor as &$field ) {
806            $field['__index'] = $i++;
807        }
808        unset( $field );
809        uasort( $formDescriptor, static function ( $first, $second ) {
810            return self::getField( $first, 'weight', 0 ) <=> self::getField( $second, 'weight', 0 )
811                ?: $first['__index'] <=> $second['__index'];
812        } );
813        foreach ( $formDescriptor as &$field ) {
814            unset( $field['__index'] );
815        }
816    }
817
818    /**
819     * Get an array value, or a default if it does not exist.
820     * @param array $array
821     * @param string $fieldName
822     * @param mixed|null $default
823     * @return mixed
824     */
825    protected static function getField( array $array, $fieldName, $default = null ) {
826        if ( array_key_exists( $fieldName, $array ) ) {
827            return $array[$fieldName];
828        } else {
829            return $default;
830        }
831    }
832
833    /**
834     * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
835     *
836     * @param string $type
837     *
838     * @return string
839     */
840    protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
841        $map = [
842            'string' => 'text',
843            'password' => 'password',
844            'select' => 'select',
845            'checkbox' => 'check',
846            'multiselect' => 'multiselect',
847            'button' => 'submit',
848            'hidden' => 'hidden',
849            'null' => 'info',
850        ];
851        if ( !array_key_exists( $type, $map ) ) {
852            throw new InvalidArgumentException( 'invalid field type: ' . $type );
853        }
854        return $map[$type];
855    }
856
857    /**
858     * Apply defaults to a form descriptor, without creating non-existent fields.
859     *
860     * Overrides $formDescriptor fields with their $defaultFormDescriptor equivalent, but
861     * only if the field is defined in $fieldInfo, uses the special 'basefield' property to
862     * refer to a $fieldInfo field, or it is not a real field (e.g. help text). Applies some
863     * common-sense behaviors to ensure related fields are overridden in a consistent manner.
864     * @param array $fieldInfo
865     * @param array $formDescriptor
866     * @param array $defaultFormDescriptor
867     * @return array
868     */
869    protected static function mergeDefaultFormDescriptor(
870        array $fieldInfo, array $formDescriptor, array $defaultFormDescriptor
871    ) {
872        // keep the ordering from $defaultFormDescriptor where there is no explicit weight
873        foreach ( $defaultFormDescriptor as $fieldName => $defaultField ) {
874            // remove everything that is not in the fieldinfo, is not marked as a supplemental field
875            // to something in the fieldinfo, and is not an info field or a submit button
876            if (
877                !isset( $fieldInfo[$fieldName] )
878                && (
879                    !isset( $defaultField['baseField'] )
880                    || !isset( $fieldInfo[$defaultField['baseField']] )
881                )
882                && (
883                    !isset( $defaultField['type'] )
884                    || !in_array( $defaultField['type'], [ 'submit', 'info' ], true )
885                )
886            ) {
887                $defaultFormDescriptor[$fieldName] = null;
888                continue;
889            }
890
891            // default message labels should always take priority
892            $requestField = $formDescriptor[$fieldName] ?? [];
893            if (
894                isset( $defaultField['label'] )
895                || isset( $defaultField['label-message'] )
896                || isset( $defaultField['label-raw'] )
897            ) {
898                unset( $requestField['label'], $requestField['label-message'], $defaultField['label-raw'] );
899            }
900
901            $defaultFormDescriptor[$fieldName] += $requestField;
902        }
903
904        return array_filter( $defaultFormDescriptor + $formDescriptor );
905    }
906}
907
908/** @deprecated class alias since 1.41 */
909class_alias( AuthManagerSpecialPage::class, 'AuthManagerSpecialPage' );