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