Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 301
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 / 300
0.00% covered (danger)
0.00%
0 / 31
18360
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 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 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 HTMLInfoField;
7use InvalidArgumentException;
8use LogicException;
9use MediaWiki\Auth\AuthenticationRequest;
10use MediaWiki\Auth\AuthenticationResponse;
11use MediaWiki\Auth\AuthManager;
12use MediaWiki\Context\DerivativeContext;
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 */
34abstract class AuthManagerSpecialPage extends SpecialPage {
35    /** @var string[] The list of actions this special page deals with. Subclasses should override
36     * this.
37     */
38    protected static $allowedActions = [
39        AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE,
40        AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE,
41        AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
42        AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK,
43    ];
44
45    /** @var array Customized messages */
46    protected static $messages = [];
47
48    /** @var string one of the AuthManager::ACTION_* constants. */
49    protected $authAction;
50
51    /** @var AuthenticationRequest[] */
52    protected $authRequests;
53
54    /** @var string Subpage of the special page. */
55    protected $subPage;
56
57    /** @var bool True if the current request is a result of returning from a redirect flow. */
58    protected $isReturn;
59
60    /** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */
61    protected $savedRequest;
62
63    /**
64     * Change the form descriptor that determines how a field will look in the authentication form.
65     * Called from fieldInfoToFormDescriptor().
66     * @stable to override
67     *
68     * @param AuthenticationRequest[] $requests
69     * @param array $fieldInfo Field information array (union of all
70     *    AuthenticationRequest::getFieldInfo() responses).
71     * @param array &$formDescriptor HTMLForm descriptor. The special key 'weight' can be set to
72     *    change the order of the fields.
73     * @param string $action Authentication type (one of the AuthManager::ACTION_* constants)
74     */
75    public function onAuthChangeFormFields(
76        array $requests, array $fieldInfo, array &$formDescriptor, $action
77    ) {
78    }
79
80    /**
81     * @stable to override
82     * @return bool|string
83     */
84    protected function getLoginSecurityLevel() {
85        return $this->getName();
86    }
87
88    public function getRequest() {
89        return $this->savedRequest ?: $this->getContext()->getRequest();
90    }
91
92    /**
93     * Override the POST data, GET data from the real request is preserved.
94     *
95     * Used to preserve POST data over a HTTP redirect.
96     *
97     * @stable to override
98     *
99     * @param array $data
100     * @param bool|null $wasPosted
101     */
102    protected function setRequest( array $data, $wasPosted = null ) {
103        $request = $this->getContext()->getRequest();
104        $this->savedRequest = new DerivativeRequest(
105            $request,
106            $data + $request->getQueryValues(),
107            $wasPosted ?? $request->wasPosted()
108        );
109    }
110
111    /**
112     * @stable to override
113     * @param string|null $subPage
114     *
115     * @return bool|void
116     */
117    protected function beforeExecute( $subPage ) {
118        $this->getOutput()->disallowUserJs();
119
120        return $this->handleReturnBeforeExecute( $subPage )
121            && $this->handleReauthBeforeExecute( $subPage );
122    }
123
124    /**
125     * Handle redirection from the /return subpage.
126     *
127     * This is used in the redirect flow where we need
128     * to be able to process data that was sent via a GET request. We set the /return subpage as
129     * the reentry point, so we know we need to treat GET as POST, but we don't want to handle all
130     * future GETs requests as POSTs, so we need to normalize the URL. (Also, we don't want to show any
131     * received parameters around in the URL; they are ugly and might be sensitive.)
132     *
133     * Thus, when on the /return subpage, we stash the request data in the session, redirect, then
134     * use the session to detect that we have been redirected, recover the data and replace the
135     * real WebRequest with a fake one that contains the saved data.
136     *
137     * @param string $subPage
138     * @return bool False if execution should be stopped.
139     */
140    protected function handleReturnBeforeExecute( $subPage ) {
141        $authManager = $this->getAuthManager();
142        $key = 'AuthManagerSpecialPage:return:' . $this->getName();
143
144        if ( $subPage === 'return' ) {
145            $this->loadAuth( $subPage );
146            $preservedParams = $this->getPreservedParams( false );
147
148            // FIXME save POST values only from request
149            $authData = array_diff_key( $this->getRequest()->getValues(),
150                $preservedParams, [ 'title' => 1 ] );
151            $authManager->setAuthenticationSessionData( $key, $authData );
152
153            $url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS );
154            $this->getOutput()->redirect( $url );
155            return false;
156        }
157
158        $authData = $authManager->getAuthenticationSessionData( $key );
159        if ( $authData ) {
160            $authManager->removeAuthenticationSessionData( $key );
161            $this->isReturn = true;
162            $this->setRequest( $authData, true );
163        }
164
165        return true;
166    }
167
168    /**
169     * Handle redirection when the user needs to (re)authenticate.
170     *
171     * Send the user to the login form if needed; in case the request was a POST, stash in the
172     * session and simulate it once the user gets back.
173     *
174     * @param string $subPage
175     * @return bool False if execution should be stopped.
176     * @throws ErrorPageError When the user is not allowed to use this page.
177     */
178    protected function handleReauthBeforeExecute( $subPage ) {
179        $authManager = $this->getAuthManager();
180        $request = $this->getRequest();
181        $key = 'AuthManagerSpecialPage:reauth:' . $this->getName();
182
183        $securityLevel = $this->getLoginSecurityLevel();
184        if ( $securityLevel ) {
185            $securityStatus = $authManager->securitySensitiveOperationStatus( $securityLevel );
186            if ( $securityStatus === AuthManager::SEC_REAUTH ) {
187                $queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] );
188
189                if ( $request->wasPosted() ) {
190                    // unique ID in case the same special page is open in multiple browser tabs
191                    $uniqueId = MWCryptRand::generateHex( 6 );
192                    $key .= ':' . $uniqueId;
193
194                    $queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams;
195                    $authData = array_diff_key( $request->getValues(),
196                            $this->getPreservedParams( false ), [ 'title' => 1 ] );
197                    $authManager->setAuthenticationSessionData( $key, $authData );
198                }
199
200                $title = SpecialPage::getTitleFor( 'Userlogin' );
201                $url = $title->getFullURL( [
202                    'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
203                    'returntoquery' => wfArrayToCgi( $queryParams ),
204                    'force' => $securityLevel,
205                ], false, PROTO_HTTPS );
206
207                $this->getOutput()->redirect( $url );
208                return false;
209            }
210
211            if ( $securityStatus !== AuthManager::SEC_OK ) {
212                throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' );
213            }
214        }
215
216        $uniqueId = $request->getVal( 'authUniqueId' );
217        if ( $uniqueId ) {
218            $key .= ':' . $uniqueId;
219            $authData = $authManager->getAuthenticationSessionData( $key );
220            if ( $authData ) {
221                $authManager->removeAuthenticationSessionData( $key );
222                $this->setRequest( $authData, true );
223            }
224        }
225
226        return true;
227    }
228
229    /**
230     * Get the default action for this special page if none is given via URL/POST data.
231     * Subclasses should override this (or override loadAuth() so this is never called).
232     * @stable to override
233     * @param string $subPage Subpage of the special page.
234     * @return string an AuthManager::ACTION_* constant.
235     */
236    abstract protected function getDefaultAction( $subPage );
237
238    /**
239     * Return custom message key.
240     * Allows subclasses to customize messages.
241     * @param string $defaultKey
242     * @return string
243     */
244    protected function messageKey( $defaultKey ) {
245        return array_key_exists( $defaultKey, static::$messages )
246            ? static::$messages[$defaultKey] : $defaultKey;
247    }
248
249    /**
250     * Allows blacklisting certain request types.
251     * @stable to override
252     * @return array A list of AuthenticationRequest subclass names
253     */
254    protected function getRequestBlacklist() {
255        return [];
256    }
257
258    /**
259     * Load or initialize $authAction, $authRequests and $subPage.
260     * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
261     * @stable to override
262     * @param string $subPage Subpage of the special page.
263     * @param string|null $authAction Override auth action specified in request (this is useful
264     *    when the form needs to be changed from <action> to <action>_CONTINUE after a successful
265     *    authentication step)
266     * @param bool $reset Regenerate the requests even if a cached version is available
267     */
268    protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
269        // Do not load if already loaded, to cut down on the number of getAuthenticationRequests
270        // calls. This is important for requests which have hidden information, so any
271        // getAuthenticationRequests call would mean putting data into some cache.
272        if (
273            !$reset && $this->subPage === $subPage && $this->authAction
274            && ( !$authAction || $authAction === $this->authAction )
275        ) {
276            return;
277        }
278
279        $request = $this->getRequest();
280        $this->subPage = $subPage;
281        $this->authAction = $authAction ?: $request->getText( 'authAction' );
282        if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
283            $this->authAction = $this->getDefaultAction( $subPage );
284            if ( $request->wasPosted() ) {
285                $continueAction = $this->getContinueAction( $this->authAction );
286                if ( in_array( $continueAction, static::$allowedActions, true ) ) {
287                    $this->authAction = $continueAction;
288                }
289            }
290        }
291
292        $allReqs = $this->getAuthManager()->getAuthenticationRequests(
293            $this->authAction, $this->getUser() );
294        $this->authRequests = array_filter( $allReqs, function ( $req ) {
295            return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
296        } );
297    }
298
299    /**
300     * Returns true if this is not the first step of the authentication.
301     * @return bool
302     */
303    protected function isContinued() {
304        return in_array( $this->authAction, [
305            AuthManager::ACTION_LOGIN_CONTINUE,
306            AuthManager::ACTION_CREATE_CONTINUE,
307            AuthManager::ACTION_LINK_CONTINUE,
308        ], true );
309    }
310
311    /**
312     * Gets the _CONTINUE version of an action.
313     * @param string $action An AuthManager::ACTION_* constant.
314     * @return string An AuthManager::ACTION_*_CONTINUE constant.
315     */
316    protected function getContinueAction( $action ) {
317        switch ( $action ) {
318            case AuthManager::ACTION_LOGIN:
319                $action = AuthManager::ACTION_LOGIN_CONTINUE;
320                break;
321            case AuthManager::ACTION_CREATE:
322                $action = AuthManager::ACTION_CREATE_CONTINUE;
323                break;
324            case AuthManager::ACTION_LINK:
325                $action = AuthManager::ACTION_LINK_CONTINUE;
326                break;
327        }
328        return $action;
329    }
330
331    /**
332     * Checks whether AuthManager is ready to perform the action.
333     * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
334     * the caller's responsibility.
335     * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
336     * @return bool
337     * @throws LogicException if $action is invalid
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( 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 ' . gettype( 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                    . gettype( $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 can be used to reload the page (or leave and return) while
532     * preserving all information that is necessary for authentication to continue. These parameters
533     * will be preserved in the action URL of the form and in the return URL for redirect flow.
534     * @stable to override
535     * @param bool $withToken Include CSRF token
536     * @return array
537     */
538    protected function getPreservedParams( $withToken = false ) {
539        $params = [];
540        if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
541            $params['authAction'] = $this->getContinueAction( $this->authAction );
542        }
543        if ( $withToken ) {
544            $params[$this->getTokenName()] = $this->getToken()->toString();
545        }
546        return $params;
547    }
548
549    /**
550     * Generates a HTMLForm descriptor array from a set of authentication requests.
551     * @stable to override
552     * @param AuthenticationRequest[] $requests
553     * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
554     * @return array[]
555     */
556    protected function getAuthFormDescriptor( $requests, $action ) {
557        $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
558        $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
559
560        $this->addTabIndex( $formDescriptor );
561
562        return $formDescriptor;
563    }
564
565    /**
566     * @stable to override
567     * @param AuthenticationRequest[] $requests
568     * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
569     * @return HTMLForm
570     */
571    protected function getAuthForm( array $requests, $action ) {
572        $formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
573        $context = $this->getContext();
574        if ( $context->getRequest() !== $this->getRequest() ) {
575            // We have overridden the request, need to make sure the form uses that too.
576            $context = new DerivativeContext( $this->getContext() );
577            $context->setRequest( $this->getRequest() );
578        }
579        $form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
580        $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
581        $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
582        $form->addHiddenField( 'authAction', $this->authAction );
583        $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
584
585        return $form;
586    }
587
588    /**
589     * Display the form.
590     * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
591     */
592    protected function displayForm( $status ) {
593        if ( $status instanceof StatusValue ) {
594            $status = Status::wrap( $status );
595        }
596        $form = $this->getAuthForm( $this->authRequests, $this->authAction );
597        $form->prepareForm()->displayForm( $status );
598    }
599
600    /**
601     * Returns true if the form built from the given AuthenticationRequests needs a submit button.
602     * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using
603     * one of those custom buttons is the only way to proceed, there is no point in displaying the
604     * default button which won't do anything useful.
605     * @stable to override
606     *
607     * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
608     *  form will be built
609     * @return bool
610     */
611    protected function needsSubmitButton( array $requests ) {
612        $customSubmitButtonPresent = false;
613
614        // Secondary and preauth providers always need their data; they will not care what button
615        // is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers;
616        // that's the point in being optional. Se we need to check whether all primary providers
617        // have their own buttons and whether there is at least one button present.
618        foreach ( $requests as $req ) {
619            if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) {
620                if ( $this->hasOwnSubmitButton( $req ) ) {
621                    $customSubmitButtonPresent = true;
622                } else {
623                    return true;
624                }
625            }
626        }
627        return !$customSubmitButtonPresent;
628    }
629
630    /**
631     * Checks whether the given AuthenticationRequest has its own submit button.
632     * @param AuthenticationRequest $req
633     * @return bool
634     */
635    protected function hasOwnSubmitButton( AuthenticationRequest $req ) {
636        foreach ( $req->getFieldInfo() as $info ) {
637            if ( $info['type'] === 'button' ) {
638                return true;
639            }
640        }
641        return false;
642    }
643
644    /**
645     * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
646     * use the tab key to traverse the form without having to step through all links and such.
647     * @param array[] &$formDescriptor
648     */
649    protected function addTabIndex( &$formDescriptor ) {
650        $i = 1;
651        foreach ( $formDescriptor as &$definition ) {
652            $class = false;
653            if ( array_key_exists( 'class', $definition ) ) {
654                $class = $definition['class'];
655            } elseif ( array_key_exists( 'type', $definition ) ) {
656                $class = HTMLForm::$typeMappings[$definition['type']];
657            }
658            if ( $class !== HTMLInfoField::class ) {
659                $definition['tabindex'] = $i;
660                $i++;
661            }
662        }
663    }
664
665    /**
666     * Returns the CSRF token.
667     * @stable to override
668     * @return Token
669     */
670    protected function getToken() {
671        return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
672            . $this->getName() );
673    }
674
675    /**
676     * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
677     * @stable to override
678     * @return string
679     */
680    protected function getTokenName() {
681        return 'wpAuthToken';
682    }
683
684    /**
685     * Turns a field info array into a form descriptor. Behavior can be modified by the
686     * AuthChangeFormFields hook.
687     * @param AuthenticationRequest[] $requests
688     * @param array $fieldInfo Field information, in the format used by
689     *   AuthenticationRequest::getFieldInfo()
690     * @param string $action One of the AuthManager::ACTION_* constants
691     * @return array A form descriptor that can be passed to HTMLForm
692     */
693    protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
694        $formDescriptor = [];
695        foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
696            $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
697        }
698
699        $requestSnapshot = serialize( $requests );
700        $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
701        $this->getHookRunner()->onAuthChangeFormFields( $requests, $fieldInfo,
702            $formDescriptor, $action );
703        if ( $requestSnapshot !== serialize( $requests ) ) {
704            LoggerFactory::getInstance( 'authentication' )->warning(
705                'AuthChangeFormFields hook changed auth requests' );
706        }
707
708        // Process the special 'weight' property, which is a way for AuthChangeFormFields hook
709        // subscribers (who only see one field at a time) to influence ordering.
710        self::sortFormDescriptorFields( $formDescriptor );
711
712        return $formDescriptor;
713    }
714
715    /**
716     * Maps an authentication field configuration for a single field (as returned by
717     * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
718     * @param array $singleFieldInfo
719     * @param string $fieldName
720     * @return array
721     */
722    protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
723        $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
724        $descriptor = [
725            'type' => $type,
726            // Do not prefix input name with 'wp'. This is important for the redirect flow.
727            'name' => $fieldName,
728        ];
729
730        if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
731            $descriptor['default'] = $singleFieldInfo['label']->plain();
732        } elseif ( $type !== 'submit' ) {
733            $descriptor += array_filter( [
734                // help-message is omitted as it is usually not really useful for a web interface
735                'label-message' => self::getField( $singleFieldInfo, 'label' ),
736            ] );
737
738            if ( isset( $singleFieldInfo['options'] ) ) {
739                $descriptor['options'] = array_flip( array_map( static function ( $message ) {
740                    /** @var Message $message */
741                    return $message->parse();
742                }, $singleFieldInfo['options'] ) );
743            }
744
745            if ( isset( $singleFieldInfo['value'] ) ) {
746                $descriptor['default'] = $singleFieldInfo['value'];
747            }
748
749            if ( empty( $singleFieldInfo['optional'] ) ) {
750                $descriptor['required'] = true;
751            }
752        }
753
754        return $descriptor;
755    }
756
757    /**
758     * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
759     * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
760     * Keep order if weights are equal.
761     * @param array &$formDescriptor
762     */
763    protected static function sortFormDescriptorFields( array &$formDescriptor ) {
764        $i = 0;
765        foreach ( $formDescriptor as &$field ) {
766            $field['__index'] = $i++;
767        }
768        unset( $field );
769        uasort( $formDescriptor, static function ( $first, $second ) {
770            return self::getField( $first, 'weight', 0 ) <=> self::getField( $second, 'weight', 0 )
771                ?: $first['__index'] <=> $second['__index'];
772        } );
773        foreach ( $formDescriptor as &$field ) {
774            unset( $field['__index'] );
775        }
776    }
777
778    /**
779     * Get an array value, or a default if it does not exist.
780     * @param array $array
781     * @param string $fieldName
782     * @param mixed|null $default
783     * @return mixed
784     */
785    protected static function getField( array $array, $fieldName, $default = null ) {
786        if ( array_key_exists( $fieldName, $array ) ) {
787            return $array[$fieldName];
788        } else {
789            return $default;
790        }
791    }
792
793    /**
794     * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
795     *
796     * @param string $type
797     *
798     * @return string
799     */
800    protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
801        $map = [
802            'string' => 'text',
803            'password' => 'password',
804            'select' => 'select',
805            'checkbox' => 'check',
806            'multiselect' => 'multiselect',
807            'button' => 'submit',
808            'hidden' => 'hidden',
809            'null' => 'info',
810        ];
811        if ( !array_key_exists( $type, $map ) ) {
812            throw new InvalidArgumentException( 'invalid field type: ' . $type );
813        }
814        return $map[$type];
815    }
816
817    /**
818     * Apply defaults to a form descriptor, without creating non-existent fields.
819     *
820     * Overrides $formDescriptor fields with their $defaultFormDescriptor equivalent, but
821     * only if the field is defined in $fieldInfo, uses the special 'basefield' property to
822     * refer to a $fieldInfo field, or it is not a real field (e.g. help text). Applies some
823     * common-sense behaviors to ensure related fields are overridden in a consistent manner.
824     * @param array $fieldInfo
825     * @param array $formDescriptor
826     * @param array $defaultFormDescriptor
827     * @return array
828     */
829    protected static function mergeDefaultFormDescriptor(
830        array $fieldInfo, array $formDescriptor, array $defaultFormDescriptor
831    ) {
832        // keep the ordering from $defaultFormDescriptor where there is no explicit weight
833        foreach ( $defaultFormDescriptor as $fieldName => $defaultField ) {
834            // remove everything that is not in the fieldinfo, is not marked as a supplemental field
835            // to something in the fieldinfo, and is not an info field or a submit button
836            if (
837                !isset( $fieldInfo[$fieldName] )
838                && (
839                    !isset( $defaultField['baseField'] )
840                    || !isset( $fieldInfo[$defaultField['baseField']] )
841                )
842                && (
843                    !isset( $defaultField['type'] )
844                    || !in_array( $defaultField['type'], [ 'submit', 'info' ], true )
845                )
846            ) {
847                $defaultFormDescriptor[$fieldName] = null;
848                continue;
849            }
850
851            // default message labels should always take priority
852            $requestField = $formDescriptor[$fieldName] ?? [];
853            if (
854                isset( $defaultField['label'] )
855                || isset( $defaultField['label-message'] )
856                || isset( $defaultField['label-raw'] )
857            ) {
858                unset( $requestField['label'], $requestField['label-message'], $defaultField['label-raw'] );
859            }
860
861            $defaultFormDescriptor[$fieldName] += $requestField;
862        }
863
864        return array_filter( $defaultFormDescriptor + $formDescriptor );
865    }
866}
867
868/** @deprecated class alias since 1.41 */
869class_alias( AuthManagerSpecialPage::class, 'AuthManagerSpecialPage' );