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