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