Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.77% covered (success)
99.77%
1302 / 1305
95.45% covered (success)
95.45%
42 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthManager
99.77% covered (success)
99.77%
1302 / 1305
95.45% covered (success)
95.45%
42 / 44
339
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forcePrimaryAuthenticationProviders
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 canAuthenticateNow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beginAuthentication
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
14
 continueAuthentication
99.06% covered (success)
99.06%
211 / 213
0.00% covered (danger)
0.00%
0 / 1
52
 securitySensitiveOperationStatus
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
16
 userCanAuthenticate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 normalizeUsername
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 revokeAccessForUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 allowsAuthenticationDataChange
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 changeAuthenticationData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 canCreateAccounts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 canCreateAccount
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
8
 authorizeInternal
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 probablyCanCreateAccount
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 authorizeCreateAccount
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 beginAccountCreation
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
11
 continueAccountCreation
100.00% covered (success)
100.00%
272 / 272
100.00% covered (success)
100.00%
1 / 1
52
 autoCreateUser
100.00% covered (success)
100.00%
160 / 160
100.00% covered (success)
100.00%
1 / 1
36
 authorizeAutoCreateAccount
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 canLinkAccounts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 beginAccountLink
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
1 / 1
15
 continueAccountLink
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
13
 getAuthenticationRequests
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
18
 getAuthenticationRequestsInternal
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
15
 fillRequests
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 userExists
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 allowsPropertyChange
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAuthenticationProvider
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 setAuthenticationSessionData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getAuthenticationSessionData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 removeAuthenticationSessionData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 providerArrayFromSpecs
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 getConfiguration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getPreAuthenticationProviders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getPrimaryAuthenticationProviders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getSecondaryAuthenticationProviders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setSessionDataForUser
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 setDefaultUserOptions
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 callMethodOnProviders
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getHookContainer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHookRunner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Authentication (and possibly Authorization in the future) system entry point
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Auth
22 */
23
24namespace MediaWiki\Auth;
25
26use IDBAccessObject;
27use Language;
28use MediaWiki\Block\BlockManager;
29use MediaWiki\Config\Config;
30use MediaWiki\Context\RequestContext;
31use MediaWiki\Deferred\DeferredUpdates;
32use MediaWiki\Deferred\SiteStatsUpdate;
33use MediaWiki\HookContainer\HookContainer;
34use MediaWiki\HookContainer\HookRunner;
35use MediaWiki\Languages\LanguageConverterFactory;
36use MediaWiki\MainConfigNames;
37use MediaWiki\Page\PageIdentity;
38use MediaWiki\Permissions\Authority;
39use MediaWiki\Permissions\PermissionStatus;
40use MediaWiki\Request\WebRequest;
41use MediaWiki\SpecialPage\SpecialPage;
42use MediaWiki\Status\Status;
43use MediaWiki\User\BotPasswordStore;
44use MediaWiki\User\Options\UserOptionsManager;
45use MediaWiki\User\TempUser\TempUserCreator;
46use MediaWiki\User\User;
47use MediaWiki\User\UserFactory;
48use MediaWiki\User\UserIdentity;
49use MediaWiki\User\UserIdentityLookup;
50use MediaWiki\User\UserNameUtils;
51use MediaWiki\User\UserRigorOptions;
52use MediaWiki\Watchlist\WatchlistManager;
53use Psr\Log\LoggerAwareInterface;
54use Psr\Log\LoggerInterface;
55use Psr\Log\NullLogger;
56use StatusValue;
57use Wikimedia\ObjectFactory\ObjectFactory;
58use Wikimedia\Rdbms\ILoadBalancer;
59use Wikimedia\Rdbms\ReadOnlyMode;
60use Wikimedia\ScopedCallback;
61
62/**
63 * This serves as the entry point to the authentication system.
64 *
65 * In the future, it may also serve as the entry point to the authorization
66 * system.
67 *
68 * If you are looking at this because you are working on an extension that creates its own
69 * login or signup page, then 1) you really shouldn't do that, 2) if you feel you absolutely
70 * have to, subclass AuthManagerSpecialPage or build it on the client side using the clientlogin
71 * or the createaccount API. Trying to call this class directly will very likely end up in
72 * security vulnerabilities or broken UX in edge cases.
73 *
74 * If you are working on an extension that needs to integrate with the authentication system
75 * (e.g. by providing a new login method, or doing extra permission checks), you'll probably
76 * need to write an AuthenticationProvider.
77 *
78 * If you want to create a "reserved" user programmatically, User::newSystemUser() might be what
79 * you are looking for. If you want to change user data, use User::changeAuthenticationData().
80 * Code that is related to some SessionProvider or PrimaryAuthenticationProvider can
81 * create a (non-reserved) user by calling AuthManager::autoCreateUser(); it is then the provider's
82 * responsibility to ensure that the user can authenticate somehow (see especially
83 * PrimaryAuthenticationProvider::autoCreatedAccount()). The same functionality can also be used
84 * from Maintenance scripts such as createAndPromote.php.
85 * If you are writing code that is not associated with such a provider and needs to create accounts
86 * programmatically for real users, you should rethink your architecture. There is no good way to
87 * do that as such code has no knowledge of what authentication methods are enabled on the wiki and
88 * cannot provide any means for users to access the accounts it would create.
89 *
90 * The two main control flows when using this class are as follows:
91 * * Login, user creation or account linking code will call getAuthenticationRequests(), populate
92 *   the requests with data (by using them to build a HTMLForm and have the user fill it, or by
93 *   exposing a form specification via the API, so that the client can build it), and pass them to
94 *   the appropriate begin* method. That will return either a success/failure response, or more
95 *   requests to fill (either by building a form or by redirecting the user to some external
96 *   provider which will send the data back), in which case they need to be submitted to the
97 *   appropriate continue* method and that step has to be repeated until the response is a success
98 *   or failure response. AuthManager will use the session to maintain internal state during the
99 *   process.
100 * * Code doing an authentication data change will call getAuthenticationRequests(), select
101 *   a single request, populate it, and pass it to allowsAuthenticationDataChange() and then
102 *   changeAuthenticationData(). If the data change is user-initiated, the whole process needs
103 *   to be preceded by a call to securitySensitiveOperationStatus() and aborted if that returns
104 *   a non-OK status.
105 *
106 * @ingroup Auth
107 * @since 1.27
108 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
109 */
110class AuthManager implements LoggerAwareInterface {
111    /**
112     * @internal
113     * Key in the user's session data for storing login state.
114     */
115    public const AUTHN_STATE = 'AuthManager::authnState';
116
117    /**
118     * @internal
119     * Key in the user's session data for storing account creation state.
120     */
121    public const ACCOUNT_CREATION_STATE = 'AuthManager::accountCreationState';
122
123    /**
124     * @internal
125     * Key in the user's session data for storing account linking state.
126     */
127    public const ACCOUNT_LINK_STATE = 'AuthManager::accountLinkState';
128
129    /**
130     * @internal
131     * Key in the user's session data for storing autocreation failures,
132     * to avoid re-attempting expensive autocreation checks on every request.
133     */
134    public const AUTOCREATE_BLOCKLIST = 'AuthManager::AutoCreateBlacklist';
135
136    /** Log in with an existing (not necessarily local) user */
137    public const ACTION_LOGIN = 'login';
138    /** Continue a login process that was interrupted by the need for user input or communication
139     * with an external provider
140     */
141    public const ACTION_LOGIN_CONTINUE = 'login-continue';
142    /** Create a new user */
143    public const ACTION_CREATE = 'create';
144    /** Continue a user creation process that was interrupted by the need for user input or
145     * communication with an external provider
146     */
147    public const ACTION_CREATE_CONTINUE = 'create-continue';
148    /** Link an existing user to a third-party account */
149    public const ACTION_LINK = 'link';
150    /** Continue a user linking process that was interrupted by the need for user input or
151     * communication with an external provider
152     */
153    public const ACTION_LINK_CONTINUE = 'link-continue';
154    /** Change a user's credentials */
155    public const ACTION_CHANGE = 'change';
156    /** Remove a user's credentials */
157    public const ACTION_REMOVE = 'remove';
158    /** Like ACTION_REMOVE but for linking providers only */
159    public const ACTION_UNLINK = 'unlink';
160
161    /** Security-sensitive operations are ok. */
162    public const SEC_OK = 'ok';
163    /** Security-sensitive operations should re-authenticate. */
164    public const SEC_REAUTH = 'reauth';
165    /** Security-sensitive should not be performed. */
166    public const SEC_FAIL = 'fail';
167
168    /** Auto-creation is due to SessionManager */
169    public const AUTOCREATE_SOURCE_SESSION = \MediaWiki\Session\SessionManager::class;
170
171    /** Auto-creation is due to a Maintenance script */
172    public const AUTOCREATE_SOURCE_MAINT = '::Maintenance::';
173
174    /** Auto-creation is due to temporary account creation on page save */
175    public const AUTOCREATE_SOURCE_TEMP = TempUserCreator::class;
176
177    /** @var WebRequest */
178    private $request;
179
180    /** @var Config */
181    private $config;
182
183    /** @var ObjectFactory */
184    private $objectFactory;
185
186    /** @var LoggerInterface */
187    private $logger;
188
189    /** @var UserNameUtils */
190    private $userNameUtils;
191
192    /** @var AuthenticationProvider[] */
193    private $allAuthenticationProviders = [];
194
195    /** @var PreAuthenticationProvider[] */
196    private $preAuthenticationProviders = null;
197
198    /** @var PrimaryAuthenticationProvider[] */
199    private $primaryAuthenticationProviders = null;
200
201    /** @var SecondaryAuthenticationProvider[] */
202    private $secondaryAuthenticationProviders = null;
203
204    /** @var CreatedAccountAuthenticationRequest[] */
205    private $createdAccountAuthenticationRequests = [];
206
207    /** @var HookContainer */
208    private $hookContainer;
209
210    /** @var HookRunner */
211    private $hookRunner;
212
213    /** @var ReadOnlyMode */
214    private $readOnlyMode;
215
216    /** @var BlockManager */
217    private $blockManager;
218
219    /** @var WatchlistManager */
220    private $watchlistManager;
221
222    /** @var ILoadBalancer */
223    private $loadBalancer;
224
225    /** @var Language */
226    private $contentLanguage;
227
228    /** @var LanguageConverterFactory */
229    private $languageConverterFactory;
230
231    /** @var BotPasswordStore */
232    private $botPasswordStore;
233
234    /** @var UserFactory */
235    private $userFactory;
236
237    /** @var UserIdentityLookup */
238    private $userIdentityLookup;
239
240    /** @var UserOptionsManager */
241    private $userOptionsManager;
242
243    /**
244     * @param WebRequest $request
245     * @param Config $config
246     * @param ObjectFactory $objectFactory
247     * @param HookContainer $hookContainer
248     * @param ReadOnlyMode $readOnlyMode
249     * @param UserNameUtils $userNameUtils
250     * @param BlockManager $blockManager
251     * @param WatchlistManager $watchlistManager
252     * @param ILoadBalancer $loadBalancer
253     * @param Language $contentLanguage
254     * @param LanguageConverterFactory $languageConverterFactory
255     * @param BotPasswordStore $botPasswordStore
256     * @param UserFactory $userFactory
257     * @param UserIdentityLookup $userIdentityLookup
258     * @param UserOptionsManager $userOptionsManager
259     */
260    public function __construct(
261        WebRequest $request,
262        Config $config,
263        ObjectFactory $objectFactory,
264        HookContainer $hookContainer,
265        ReadOnlyMode $readOnlyMode,
266        UserNameUtils $userNameUtils,
267        BlockManager $blockManager,
268        WatchlistManager $watchlistManager,
269        ILoadBalancer $loadBalancer,
270        Language $contentLanguage,
271        LanguageConverterFactory $languageConverterFactory,
272        BotPasswordStore $botPasswordStore,
273        UserFactory $userFactory,
274        UserIdentityLookup $userIdentityLookup,
275        UserOptionsManager $userOptionsManager
276    ) {
277        $this->request = $request;
278        $this->config = $config;
279        $this->objectFactory = $objectFactory;
280        $this->hookContainer = $hookContainer;
281        $this->hookRunner = new HookRunner( $hookContainer );
282        $this->setLogger( new NullLogger() );
283        $this->readOnlyMode = $readOnlyMode;
284        $this->userNameUtils = $userNameUtils;
285        $this->blockManager = $blockManager;
286        $this->watchlistManager = $watchlistManager;
287        $this->loadBalancer = $loadBalancer;
288        $this->contentLanguage = $contentLanguage;
289        $this->languageConverterFactory = $languageConverterFactory;
290        $this->botPasswordStore = $botPasswordStore;
291        $this->userFactory = $userFactory;
292        $this->userIdentityLookup = $userIdentityLookup;
293        $this->userOptionsManager = $userOptionsManager;
294    }
295
296    /**
297     * @param LoggerInterface $logger
298     */
299    public function setLogger( LoggerInterface $logger ) {
300        $this->logger = $logger;
301    }
302
303    /**
304     * @return WebRequest
305     */
306    public function getRequest() {
307        return $this->request;
308    }
309
310    /**
311     * Force certain PrimaryAuthenticationProviders
312     * @deprecated For backwards compatibility only
313     * @param PrimaryAuthenticationProvider[] $providers
314     * @param string $why
315     */
316    public function forcePrimaryAuthenticationProviders( array $providers, $why ) {
317        $this->logger->warning( "Overriding AuthManager primary authn because $why" );
318
319        if ( $this->primaryAuthenticationProviders !== null ) {
320            $this->logger->warning(
321                'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
322            );
323
324            $this->allAuthenticationProviders = array_diff_key(
325                $this->allAuthenticationProviders,
326                $this->primaryAuthenticationProviders
327            );
328            $session = $this->request->getSession();
329            $session->remove( self::AUTHN_STATE );
330            $session->remove( self::ACCOUNT_CREATION_STATE );
331            $session->remove( self::ACCOUNT_LINK_STATE );
332            $this->createdAccountAuthenticationRequests = [];
333        }
334
335        $this->primaryAuthenticationProviders = [];
336        foreach ( $providers as $provider ) {
337            if ( !$provider instanceof AbstractPrimaryAuthenticationProvider ) {
338                throw new \RuntimeException(
339                    'Expected instance of MediaWiki\\Auth\\AbstractPrimaryAuthenticationProvider, got ' .
340                        get_class( $provider )
341                );
342            }
343            $provider->init( $this->logger, $this, $this->hookContainer, $this->config, $this->userNameUtils );
344            $id = $provider->getUniqueId();
345            if ( isset( $this->allAuthenticationProviders[$id] ) ) {
346                throw new \RuntimeException(
347                    "Duplicate specifications for id $id (classes " .
348                        get_class( $provider ) . ' and ' .
349                        get_class( $this->allAuthenticationProviders[$id] ) . ')'
350                );
351            }
352            $this->allAuthenticationProviders[$id] = $provider;
353            $this->primaryAuthenticationProviders[$id] = $provider;
354        }
355    }
356
357    /***************************************************************************/
358    // region   Authentication
359    /** @name   Authentication */
360
361    /**
362     * Indicate whether user authentication is possible
363     *
364     * It may not be if the session is provided by something like OAuth
365     * for which each individual request includes authentication data.
366     *
367     * @return bool
368     */
369    public function canAuthenticateNow() {
370        return $this->request->getSession()->canSetUser();
371    }
372
373    /**
374     * Start an authentication flow
375     *
376     * In addition to the AuthenticationRequests returned by
377     * $this->getAuthenticationRequests(), a client might include a
378     * CreateFromLoginAuthenticationRequest from a previous login attempt to
379     * preserve state.
380     *
381     * Instead of the AuthenticationRequests returned by
382     * $this->getAuthenticationRequests(), a client might pass a
383     * CreatedAccountAuthenticationRequest from an account creation that just
384     * succeeded to log in to the just-created account.
385     *
386     * @param AuthenticationRequest[] $reqs
387     * @param string $returnToUrl Url that REDIRECT responses should eventually
388     *  return to.
389     * @return AuthenticationResponse See self::continueAuthentication()
390     */
391    public function beginAuthentication( array $reqs, $returnToUrl ) {
392        $session = $this->request->getSession();
393        if ( !$session->canSetUser() ) {
394            // Caller should have called canAuthenticateNow()
395            $session->remove( self::AUTHN_STATE );
396            throw new \LogicException( 'Authentication is not possible now' );
397        }
398
399        $guessUserName = null;
400        foreach ( $reqs as $req ) {
401            $req->returnToUrl = $returnToUrl;
402            // @codeCoverageIgnoreStart
403            if ( $req->username !== null && $req->username !== '' ) {
404                if ( $guessUserName === null ) {
405                    $guessUserName = $req->username;
406                } elseif ( $guessUserName !== $req->username ) {
407                    $guessUserName = null;
408                    break;
409                }
410            }
411            // @codeCoverageIgnoreEnd
412        }
413
414        // Check for special-case login of a just-created account
415        $req = AuthenticationRequest::getRequestByClass(
416            $reqs, CreatedAccountAuthenticationRequest::class
417        );
418        if ( $req ) {
419            if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) {
420                throw new \LogicException(
421                    'CreatedAccountAuthenticationRequests are only valid on ' .
422                        'the same AuthManager that created the account'
423                );
424            }
425
426            $user = $this->userFactory->newFromName( (string)$req->username );
427            // @codeCoverageIgnoreStart
428            if ( !$user ) {
429                throw new \UnexpectedValueException(
430                    "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\""
431                );
432            } elseif ( $user->getId() != $req->id ) {
433                throw new \UnexpectedValueException(
434                    "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}"
435                );
436            }
437            // @codeCoverageIgnoreEnd
438
439            $this->logger->info( 'Logging in {user} after account creation', [
440                'user' => $user->getName(),
441            ] );
442            $ret = AuthenticationResponse::newPass( $user->getName() );
443            $performer = $session->getUser();
444            $this->setSessionDataForUser( $user );
445            $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
446            $session->remove( self::AUTHN_STATE );
447            $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
448                $ret, $user, $user->getName(), [
449                    'performer' => $performer
450                ] );
451            return $ret;
452        }
453
454        $this->removeAuthenticationSessionData( null );
455
456        foreach ( $this->getPreAuthenticationProviders() as $provider ) {
457            $status = $provider->testForAuthentication( $reqs );
458            if ( !$status->isGood() ) {
459                $this->logger->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() );
460                $ret = AuthenticationResponse::newFail(
461                    Status::wrap( $status )->getMessage()
462                );
463                $this->callMethodOnProviders( 7, 'postAuthentication',
464                    [ $this->userFactory->newFromName( (string)$guessUserName ), $ret ]
465                );
466                $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit( $ret, null, $guessUserName, [
467                    'performer' => $session->getUser()
468                ] );
469                return $ret;
470            }
471        }
472
473        $state = [
474            'reqs' => $reqs,
475            'returnToUrl' => $returnToUrl,
476            'guessUserName' => $guessUserName,
477            'primary' => null,
478            'primaryResponse' => null,
479            'secondary' => [],
480            'maybeLink' => [],
481            'continueRequests' => [],
482        ];
483
484        // Preserve state from a previous failed login
485        $req = AuthenticationRequest::getRequestByClass(
486            $reqs, CreateFromLoginAuthenticationRequest::class
487        );
488        if ( $req ) {
489            $state['maybeLink'] = $req->maybeLink;
490        }
491
492        $session = $this->request->getSession();
493        $session->setSecret( self::AUTHN_STATE, $state );
494        $session->persist();
495
496        return $this->continueAuthentication( $reqs );
497    }
498
499    /**
500     * Continue an authentication flow
501     *
502     * Return values are interpreted as follows:
503     * - status FAIL: Authentication failed. If $response->createRequest is
504     *   set, that may be passed to self::beginAuthentication() or to
505     *   self::beginAccountCreation() to preserve state.
506     * - status REDIRECT: The client should be redirected to the contained URL,
507     *   new AuthenticationRequests should be made (if any), then
508     *   AuthManager::continueAuthentication() should be called.
509     * - status UI: The client should be presented with a user interface for
510     *   the fields in the specified AuthenticationRequests, then new
511     *   AuthenticationRequests should be made, then
512     *   AuthManager::continueAuthentication() should be called.
513     * - status RESTART: The user logged in successfully with a third-party
514     *   service, but the third-party credentials aren't attached to any local
515     *   account. This could be treated as a UI or a FAIL.
516     * - status PASS: Authentication was successful.
517     *
518     * @param AuthenticationRequest[] $reqs
519     * @return AuthenticationResponse
520     */
521    public function continueAuthentication( array $reqs ) {
522        $session = $this->request->getSession();
523        try {
524            if ( !$session->canSetUser() ) {
525                // Caller should have called canAuthenticateNow()
526                // @codeCoverageIgnoreStart
527                throw new \LogicException( 'Authentication is not possible now' );
528                // @codeCoverageIgnoreEnd
529            }
530
531            $state = $session->getSecret( self::AUTHN_STATE );
532            if ( !is_array( $state ) ) {
533                return AuthenticationResponse::newFail(
534                    wfMessage( 'authmanager-authn-not-in-progress' )
535                );
536            }
537            $state['continueRequests'] = [];
538
539            $guessUserName = $state['guessUserName'];
540
541            foreach ( $reqs as $req ) {
542                $req->returnToUrl = $state['returnToUrl'];
543            }
544
545            // Step 1: Choose a primary authentication provider, and call it until it succeeds.
546
547            if ( $state['primary'] === null ) {
548                // We haven't picked a PrimaryAuthenticationProvider yet
549                // @codeCoverageIgnoreStart
550                $guessUserName = null;
551                foreach ( $reqs as $req ) {
552                    if ( $req->username !== null && $req->username !== '' ) {
553                        if ( $guessUserName === null ) {
554                            $guessUserName = $req->username;
555                        } elseif ( $guessUserName !== $req->username ) {
556                            $guessUserName = null;
557                            break;
558                        }
559                    }
560                }
561                $state['guessUserName'] = $guessUserName;
562                // @codeCoverageIgnoreEnd
563                $state['reqs'] = $reqs;
564
565                foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
566                    $res = $provider->beginPrimaryAuthentication( $reqs );
567                    switch ( $res->status ) {
568                        case AuthenticationResponse::PASS:
569                            $state['primary'] = $id;
570                            $state['primaryResponse'] = $res;
571                            $this->logger->debug( "Primary login with $id succeeded" );
572                            break 2;
573                        case AuthenticationResponse::FAIL:
574                            $this->logger->debug( "Login failed in primary authentication by $id" );
575                            if ( $res->createRequest || $state['maybeLink'] ) {
576                                $res->createRequest = new CreateFromLoginAuthenticationRequest(
577                                    $res->createRequest, $state['maybeLink']
578                                );
579                            }
580                            $this->callMethodOnProviders(
581                                7,
582                                'postAuthentication',
583                                [
584                                    $this->userFactory->newFromName( (string)$guessUserName ),
585                                    $res
586                                ]
587                            );
588                            $session->remove( self::AUTHN_STATE );
589                            $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
590                                $res, null, $guessUserName, [
591                                    'performer' => $session->getUser()
592                                ] );
593                            return $res;
594                        case AuthenticationResponse::ABSTAIN:
595                            // Continue loop
596                            break;
597                        case AuthenticationResponse::REDIRECT:
598                        case AuthenticationResponse::UI:
599                            $this->logger->debug( "Primary login with $id returned $res->status" );
600                            $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
601                            $state['primary'] = $id;
602                            $state['continueRequests'] = $res->neededRequests;
603                            $session->setSecret( self::AUTHN_STATE, $state );
604                            return $res;
605
606                            // @codeCoverageIgnoreStart
607                        default:
608                            throw new \DomainException(
609                                get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status"
610                            );
611                            // @codeCoverageIgnoreEnd
612                    }
613                }
614                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
615                if ( $state['primary'] === null ) {
616                    $this->logger->debug( 'Login failed in primary authentication because no provider accepted' );
617                    $ret = AuthenticationResponse::newFail(
618                        wfMessage( 'authmanager-authn-no-primary' )
619                    );
620                    $this->callMethodOnProviders( 7, 'postAuthentication',
621                        [ $this->userFactory->newFromName( (string)$guessUserName ), $ret ]
622                    );
623                    $session->remove( self::AUTHN_STATE );
624                    return $ret;
625                }
626            } elseif ( $state['primaryResponse'] === null ) {
627                $provider = $this->getAuthenticationProvider( $state['primary'] );
628                if ( !$provider instanceof PrimaryAuthenticationProvider ) {
629                    // Configuration changed? Force them to start over.
630                    // @codeCoverageIgnoreStart
631                    $ret = AuthenticationResponse::newFail(
632                        wfMessage( 'authmanager-authn-not-in-progress' )
633                    );
634                    $this->callMethodOnProviders( 7, 'postAuthentication',
635                        [ $this->userFactory->newFromName( (string)$guessUserName ), $ret ]
636                    );
637                    $session->remove( self::AUTHN_STATE );
638                    return $ret;
639                    // @codeCoverageIgnoreEnd
640                }
641                $id = $provider->getUniqueId();
642                $res = $provider->continuePrimaryAuthentication( $reqs );
643                switch ( $res->status ) {
644                    case AuthenticationResponse::PASS:
645                        $state['primaryResponse'] = $res;
646                        $this->logger->debug( "Primary login with $id succeeded" );
647                        break;
648                    case AuthenticationResponse::FAIL:
649                        $this->logger->debug( "Login failed in primary authentication by $id" );
650                        if ( $res->createRequest || $state['maybeLink'] ) {
651                            $res->createRequest = new CreateFromLoginAuthenticationRequest(
652                                $res->createRequest, $state['maybeLink']
653                            );
654                        }
655                        $this->callMethodOnProviders( 7, 'postAuthentication',
656                            [ $this->userFactory->newFromName( (string)$guessUserName ), $res ]
657                        );
658                        $session->remove( self::AUTHN_STATE );
659                        $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
660                            $res, null, $guessUserName, [
661                                'performer' => $session->getUser()
662                            ] );
663                        return $res;
664                    case AuthenticationResponse::REDIRECT:
665                    case AuthenticationResponse::UI:
666                        $this->logger->debug( "Primary login with $id returned $res->status" );
667                        $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
668                        $state['continueRequests'] = $res->neededRequests;
669                        $session->setSecret( self::AUTHN_STATE, $state );
670                        return $res;
671                    default:
672                        throw new \DomainException(
673                            get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status"
674                        );
675                }
676            }
677
678            // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
679            $res = $state['primaryResponse'];
680            if ( $res->username === null ) {
681                $provider = $this->getAuthenticationProvider( $state['primary'] );
682                if ( !$provider instanceof PrimaryAuthenticationProvider ) {
683                    // Configuration changed? Force them to start over.
684                    // @codeCoverageIgnoreStart
685                    $ret = AuthenticationResponse::newFail(
686                        wfMessage( 'authmanager-authn-not-in-progress' )
687                    );
688                    $this->callMethodOnProviders( 7, 'postAuthentication',
689                        [ $this->userFactory->newFromName( (string)$guessUserName ), $ret ]
690                    );
691                    $session->remove( self::AUTHN_STATE );
692                    return $ret;
693                    // @codeCoverageIgnoreEnd
694                }
695
696                if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK &&
697                    $res->linkRequest &&
698                    // don't confuse the user with an incorrect message if linking is disabled
699                    $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider::class )
700                ) {
701                    $state['maybeLink'][$res->linkRequest->getUniqueId()] = $res->linkRequest;
702                    $msg = 'authmanager-authn-no-local-user-link';
703                } else {
704                    $msg = 'authmanager-authn-no-local-user';
705                }
706                $this->logger->debug(
707                    "Primary login with {$provider->getUniqueId()} succeeded, but returned no user"
708                );
709                $ret = AuthenticationResponse::newRestart( wfMessage( $msg ) );
710                $ret->neededRequests = $this->getAuthenticationRequestsInternal(
711                    self::ACTION_LOGIN,
712                    [],
713                    $this->getPrimaryAuthenticationProviders() + $this->getSecondaryAuthenticationProviders()
714                );
715                if ( $res->createRequest || $state['maybeLink'] ) {
716                    $ret->createRequest = new CreateFromLoginAuthenticationRequest(
717                        $res->createRequest, $state['maybeLink']
718                    );
719                    $ret->neededRequests[] = $ret->createRequest;
720                }
721                $this->fillRequests( $ret->neededRequests, self::ACTION_LOGIN, null, true );
722                $session->setSecret( self::AUTHN_STATE, [
723                    'reqs' => [], // Will be filled in later
724                    'primary' => null,
725                    'primaryResponse' => null,
726                    'secondary' => [],
727                    'continueRequests' => $ret->neededRequests,
728                ] + $state );
729                return $ret;
730            }
731
732            // Step 2: Primary authentication succeeded, create the User object
733            // (and add the user locally if necessary)
734
735            $user = $this->userFactory->newFromName(
736                (string)$res->username,
737                UserRigorOptions::RIGOR_USABLE
738            );
739            if ( !$user ) {
740                $provider = $this->getAuthenticationProvider( $state['primary'] );
741                throw new \DomainException(
742                    get_class( $provider ) . " returned an invalid username: {$res->username}"
743                );
744            }
745            if ( !$user->isRegistered() ) {
746                // User doesn't exist locally. Create it.
747                $this->logger->info( 'Auto-creating {user} on login', [
748                    'user' => $user->getName(),
749                ] );
750                $status = $this->autoCreateUser( $user, $state['primary'], false );
751                if ( !$status->isGood() ) {
752                    $ret = AuthenticationResponse::newFail(
753                        Status::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' )
754                    );
755                    $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
756                    $session->remove( self::AUTHN_STATE );
757                    $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
758                        $ret, $user, $user->getName(), [
759                            'performer' => $session->getUser()
760                        ] );
761                    return $ret;
762                }
763            }
764
765            // Step 3: Iterate over all the secondary authentication providers.
766
767            $beginReqs = $state['reqs'];
768
769            foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
770                if ( !isset( $state['secondary'][$id] ) ) {
771                    // This provider isn't started yet, so we pass it the set
772                    // of reqs from beginAuthentication instead of whatever
773                    // might have been used by a previous provider in line.
774                    $func = 'beginSecondaryAuthentication';
775                    $res = $provider->beginSecondaryAuthentication( $user, $beginReqs );
776                } elseif ( !$state['secondary'][$id] ) {
777                    $func = 'continueSecondaryAuthentication';
778                    $res = $provider->continueSecondaryAuthentication( $user, $reqs );
779                } else {
780                    continue;
781                }
782                switch ( $res->status ) {
783                    case AuthenticationResponse::PASS:
784                        $this->logger->debug( "Secondary login with $id succeeded" );
785                        // fall through
786                    case AuthenticationResponse::ABSTAIN:
787                        $state['secondary'][$id] = true;
788                        break;
789                    case AuthenticationResponse::FAIL:
790                        $this->logger->debug( "Login failed in secondary authentication by $id" );
791                        $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] );
792                        $session->remove( self::AUTHN_STATE );
793                        $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
794                            $res, $user, $user->getName(), [
795                                'performer' => $session->getUser()
796                            ] );
797                        return $res;
798                    case AuthenticationResponse::REDIRECT:
799                    case AuthenticationResponse::UI:
800                        $this->logger->debug( "Secondary login with $id returned " . $res->status );
801                        $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $user->getName() );
802                        $state['secondary'][$id] = false;
803                        $state['continueRequests'] = $res->neededRequests;
804                        $session->setSecret( self::AUTHN_STATE, $state );
805                        return $res;
806
807                        // @codeCoverageIgnoreStart
808                    default:
809                        throw new \DomainException(
810                            get_class( $provider ) . "::{$func}() returned $res->status"
811                        );
812                        // @codeCoverageIgnoreEnd
813                }
814            }
815
816            // Step 4: Authentication complete! Set the user in the session and
817            // clean up.
818
819            $this->logger->info( 'Login for {user} succeeded from {clientip}', [
820                'user' => $user->getName(),
821                'clientip' => $this->request->getIP(),
822            ] );
823            $rememberMeConfig = $this->config->get( MainConfigNames::RememberMe );
824            if ( $rememberMeConfig === RememberMeAuthenticationRequest::ALWAYS_REMEMBER ) {
825                $rememberMe = true;
826            } elseif ( $rememberMeConfig === RememberMeAuthenticationRequest::NEVER_REMEMBER ) {
827                $rememberMe = false;
828            } else {
829                /** @var RememberMeAuthenticationRequest $req */
830                $req = AuthenticationRequest::getRequestByClass(
831                    $beginReqs, RememberMeAuthenticationRequest::class
832                );
833                $rememberMe = $req && $req->rememberMe;
834            }
835            $this->setSessionDataForUser( $user, $rememberMe );
836            $ret = AuthenticationResponse::newPass( $user->getName() );
837            $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
838            $performer = $session->getUser();
839            $session->remove( self::AUTHN_STATE );
840            $this->removeAuthenticationSessionData( null );
841            $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
842                $ret, $user, $user->getName(), [
843                    'performer' => $performer
844                ] );
845            return $ret;
846        } catch ( \Exception $ex ) {
847            $session->remove( self::AUTHN_STATE );
848            throw $ex;
849        }
850    }
851
852    /**
853     * Whether security-sensitive operations should proceed.
854     *
855     * A "security-sensitive operation" is something like a password or email
856     * change, that would normally have a "reenter your password to confirm"
857     * box if we only supported password-based authentication.
858     *
859     * @param string $operation Operation being checked. This should be a
860     *  message-key-like string such as 'change-password' or 'change-email'.
861     * @return string One of the SEC_* constants.
862     */
863    public function securitySensitiveOperationStatus( $operation ) {
864        $status = self::SEC_OK;
865
866        $this->logger->debug( __METHOD__ . ": Checking $operation" );
867
868        $session = $this->request->getSession();
869        $aId = $session->getUser()->getId();
870        if ( $aId === 0 ) {
871            // User isn't authenticated. DWIM?
872            $status = $this->canAuthenticateNow() ? self::SEC_REAUTH : self::SEC_FAIL;
873            $this->logger->info( __METHOD__ . ": Not logged in! $operation is $status" );
874            return $status;
875        }
876
877        if ( $session->canSetUser() ) {
878            $id = $session->get( 'AuthManager:lastAuthId' );
879            $last = $session->get( 'AuthManager:lastAuthTimestamp' );
880            if ( $id !== $aId || $last === null ) {
881                $timeSinceLogin = PHP_INT_MAX; // Forever ago
882            } else {
883                $timeSinceLogin = max( 0, time() - $last );
884            }
885
886            $thresholds = $this->config->get( MainConfigNames::ReauthenticateTime );
887            if ( isset( $thresholds[$operation] ) ) {
888                $threshold = $thresholds[$operation];
889            } elseif ( isset( $thresholds['default'] ) ) {
890                $threshold = $thresholds['default'];
891            } else {
892                throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
893            }
894
895            if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
896                $status = self::SEC_REAUTH;
897            }
898        } else {
899            $timeSinceLogin = -1;
900
901            $pass = $this->config->get(
902                MainConfigNames::AllowSecuritySensitiveOperationIfCannotReauthenticate );
903            if ( isset( $pass[$operation] ) ) {
904                $status = $pass[$operation] ? self::SEC_OK : self::SEC_FAIL;
905            } elseif ( isset( $pass['default'] ) ) {
906                $status = $pass['default'] ? self::SEC_OK : self::SEC_FAIL;
907            } else {
908                throw new \UnexpectedValueException(
909                    '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default'
910                );
911            }
912        }
913
914        $this->getHookRunner()->onSecuritySensitiveOperationStatus(
915            $status, $operation, $session, $timeSinceLogin );
916
917        // If authentication is not possible, downgrade from "REAUTH" to "FAIL".
918        if ( !$this->canAuthenticateNow() && $status === self::SEC_REAUTH ) {
919            $status = self::SEC_FAIL;
920        }
921
922        $this->logger->info( __METHOD__ . "$operation is $status for '{user}'",
923            [
924                'user' => $session->getUser()->getName(),
925                'clientip' => $this->getRequest()->getIP(),
926            ]
927        );
928
929        return $status;
930    }
931
932    /**
933     * Determine whether a username can authenticate
934     *
935     * This is mainly for internal purposes and only takes authentication data into account,
936     * not things like blocks that can change without the authentication system being aware.
937     *
938     * @param string $username MediaWiki username
939     * @return bool
940     */
941    public function userCanAuthenticate( $username ) {
942        foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
943            if ( $provider->testUserCanAuthenticate( $username ) ) {
944                return true;
945            }
946        }
947        return false;
948    }
949
950    /**
951     * Provide normalized versions of the username for security checks
952     *
953     * Since different providers can normalize the input in different ways,
954     * this returns an array of all the different ways the name might be
955     * normalized for authentication.
956     *
957     * The returned strings should not be revealed to the user, as that might
958     * leak private information (e.g. an email address might be normalized to a
959     * username).
960     *
961     * @param string $username
962     * @return string[]
963     */
964    public function normalizeUsername( $username ) {
965        $ret = [];
966        foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
967            $normalized = $provider->providerNormalizeUsername( $username );
968            if ( $normalized !== null ) {
969                $ret[$normalized] = true;
970            }
971        }
972        return array_keys( $ret );
973    }
974
975    // endregion -- end of Authentication
976
977    /***************************************************************************/
978    // region   Authentication data changing
979    /** @name   Authentication data changing */
980
981    /**
982     * Revoke any authentication credentials for a user
983     *
984     * After this, the user should no longer be able to log in.
985     *
986     * @param string $username
987     */
988    public function revokeAccessForUser( $username ) {
989        $this->logger->info( 'Revoking access for {user}', [
990            'user' => $username,
991        ] );
992        $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] );
993    }
994
995    /**
996     * Validate a change of authentication data (e.g. passwords)
997     * @param AuthenticationRequest $req
998     * @param bool $checkData If false, $req hasn't been loaded from the
999     *  submission so checks on user-submitted fields should be skipped. $req->username is
1000     *  considered user-submitted for this purpose, even if it cannot be changed via
1001     *  $req->loadFromSubmission.
1002     * @return Status
1003     */
1004    public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
1005        $any = false;
1006        $providers = $this->getPrimaryAuthenticationProviders() +
1007            $this->getSecondaryAuthenticationProviders();
1008
1009        foreach ( $providers as $provider ) {
1010            $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData );
1011            if ( !$status->isGood() ) {
1012                // If status is not good because reset email password last attempt was within
1013                // $wgPasswordReminderResendTime then return good status with throttled-mailpassword value;
1014                // otherwise, return the $status wrapped.
1015                return $status->hasMessage( 'throttled-mailpassword' )
1016                    ? Status::newGood( 'throttled-mailpassword' )
1017                    : Status::wrap( $status );
1018            }
1019            $any = $any || $status->value !== 'ignored';
1020        }
1021        if ( !$any ) {
1022            return Status::newGood( 'ignored' )
1023                ->warning( 'authmanager-change-not-supported' );
1024        }
1025        return Status::newGood();
1026    }
1027
1028    /**
1029     * Change authentication data (e.g. passwords)
1030     *
1031     * If $req was returned for AuthManager::ACTION_CHANGE, using $req should
1032     * result in a successful login in the future.
1033     *
1034     * If $req was returned for AuthManager::ACTION_REMOVE, using $req should
1035     * no longer result in a successful login.
1036     *
1037     * This method should only be called if allowsAuthenticationDataChange( $req, true )
1038     * returned success.
1039     *
1040     * @param AuthenticationRequest $req
1041     * @param bool $isAddition Set true if this represents an addition of
1042     *  credentials rather than a change. The main difference is that additions
1043     *  should not invalidate BotPasswords. If you're not sure, leave it false.
1044     */
1045    public function changeAuthenticationData( AuthenticationRequest $req, $isAddition = false ) {
1046        $this->logger->info( 'Changing authentication data for {user} class {what}', [
1047            'user' => is_string( $req->username ) ? $req->username : '<no name>',
1048            'what' => get_class( $req ),
1049        ] );
1050
1051        $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] );
1052
1053        // When the main account's authentication data is changed, invalidate
1054        // all BotPasswords too.
1055        if ( !$isAddition ) {
1056            $this->botPasswordStore->invalidateUserPasswords( (string)$req->username );
1057        }
1058    }
1059
1060    // endregion -- end of Authentication data changing
1061
1062    /***************************************************************************/
1063    // region   Account creation
1064    /** @name   Account creation */
1065
1066    /**
1067     * Determine whether accounts can be created
1068     * @return bool
1069     */
1070    public function canCreateAccounts() {
1071        foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1072            switch ( $provider->accountCreationType() ) {
1073                case PrimaryAuthenticationProvider::TYPE_CREATE:
1074                case PrimaryAuthenticationProvider::TYPE_LINK:
1075                    return true;
1076            }
1077        }
1078        return false;
1079    }
1080
1081    /**
1082     * Determine whether a particular account can be created
1083     * @param string $username MediaWiki username
1084     * @param array $options
1085     *  - flags: (int) Bitfield of IDBAccessObject::READ_* constants, default IDBAccessObject::READ_NORMAL
1086     *  - creating: (bool) For internal use only. Never specify this.
1087     * @return Status
1088     */
1089    public function canCreateAccount( $username, $options = [] ) {
1090        // Back compat
1091        if ( is_int( $options ) ) {
1092            $options = [ 'flags' => $options ];
1093        }
1094        $options += [
1095            'flags' => IDBAccessObject::READ_NORMAL,
1096            'creating' => false,
1097        ];
1098        // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1099        $flags = $options['flags'];
1100
1101        if ( !$this->canCreateAccounts() ) {
1102            return Status::newFatal( 'authmanager-create-disabled' );
1103        }
1104
1105        if ( $this->userExists( $username, $flags ) ) {
1106            return Status::newFatal( 'userexists' );
1107        }
1108
1109        $user = $this->userFactory->newFromName( (string)$username, UserRigorOptions::RIGOR_CREATABLE );
1110        if ( !is_object( $user ) ) {
1111            return Status::newFatal( 'noname' );
1112        } else {
1113            $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL
1114            if ( $user->isRegistered() ) {
1115                return Status::newFatal( 'userexists' );
1116            }
1117        }
1118
1119        // Denied by providers?
1120        $providers = $this->getPreAuthenticationProviders() +
1121            $this->getPrimaryAuthenticationProviders() +
1122            $this->getSecondaryAuthenticationProviders();
1123        foreach ( $providers as $provider ) {
1124            $status = $provider->testUserForCreation( $user, false, $options );
1125            if ( !$status->isGood() ) {
1126                return Status::wrap( $status );
1127            }
1128        }
1129
1130        return Status::newGood();
1131    }
1132
1133    /**
1134     * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
1135     * @param string $action
1136     * @return StatusValue
1137     */
1138    private function authorizeInternal(
1139        callable $authorizer,
1140        string $action
1141    ): StatusValue {
1142        // Wiki is read-only?
1143        if ( $this->readOnlyMode->isReadOnly() ) {
1144            return StatusValue::newFatal( wfMessage( 'readonlytext', $this->readOnlyMode->getReason() ) );
1145        }
1146
1147        $permStatus = new PermissionStatus();
1148        if ( !$authorizer(
1149            $action,
1150            SpecialPage::getTitleFor( 'CreateAccount' ),
1151            $permStatus
1152        ) ) {
1153            return $permStatus;
1154        }
1155
1156        $ip = $this->getRequest()->getIP();
1157        if ( $this->blockManager->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
1158            return StatusValue::newFatal( 'sorbs_create_account_reason' );
1159        }
1160
1161        return StatusValue::newGood();
1162    }
1163
1164    /**
1165     * Check whether $creator can create accounts.
1166     *
1167     * @note this method does not guarantee full permissions check, so it should only
1168     * be used to to decide whether to show a form. To authorize the account creation
1169     * action use {@link self::authorizeCreateAccount} instead.
1170     *
1171     * @since 1.39
1172     * @param Authority $creator
1173     * @return StatusValue
1174     */
1175    public function probablyCanCreateAccount( Authority $creator ): StatusValue {
1176        return $this->authorizeInternal(
1177            static function (
1178                string $action,
1179                PageIdentity $target,
1180                PermissionStatus $status
1181            ) use ( $creator ) {
1182                return $creator->probablyCan( $action, $target, $status );
1183            },
1184            'createaccount'
1185        );
1186    }
1187
1188    /**
1189     * Authorize the account creation by $creator
1190     *
1191     * @note this method should be used right before the account is created.
1192     * To check whether a current performer has the potential to create accounts,
1193     * use {@link self::probablyCanCreateAccount} instead.
1194     *
1195     * @since 1.39
1196     * @param Authority $creator
1197     * @return StatusValue
1198     */
1199    public function authorizeCreateAccount( Authority $creator ): StatusValue {
1200        return $this->authorizeInternal(
1201            static function (
1202                string $action,
1203                PageIdentity $target,
1204                PermissionStatus $status
1205            ) use ( $creator ) {
1206                return $creator->authorizeWrite( $action, $target, $status );
1207            },
1208            'createaccount'
1209        );
1210    }
1211
1212    /**
1213     * Start an account creation flow
1214     *
1215     * In addition to the AuthenticationRequests returned by
1216     * $this->getAuthenticationRequests(), a client might include a
1217     * CreateFromLoginAuthenticationRequest from a previous login attempt. If
1218     * <code>
1219     * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE )
1220     * </code>
1221     * returns true, any AuthenticationRequest::PRIMARY_REQUIRED requests
1222     * should be omitted. If the CreateFromLoginAuthenticationRequest has a
1223     * username set, that username must be used for all other requests.
1224     *
1225     * @param Authority $creator User doing the account creation
1226     * @param AuthenticationRequest[] $reqs
1227     * @param string $returnToUrl Url that REDIRECT responses should eventually
1228     *  return to.
1229     * @return AuthenticationResponse
1230     */
1231    public function beginAccountCreation( Authority $creator, array $reqs, $returnToUrl ) {
1232        $session = $this->request->getSession();
1233        if ( !$this->canCreateAccounts() ) {
1234            // Caller should have called canCreateAccounts()
1235            $session->remove( self::ACCOUNT_CREATION_STATE );
1236            throw new \LogicException( 'Account creation is not possible' );
1237        }
1238
1239        try {
1240            $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
1241        } catch ( \UnexpectedValueException $ex ) {
1242            $username = null;
1243        }
1244        if ( $username === null ) {
1245            $this->logger->debug( __METHOD__ . ': No username provided' );
1246            return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1247        }
1248
1249        // Permissions check
1250        $status = Status::wrap( $this->authorizeCreateAccount( $creator ) );
1251        if ( !$status->isGood() ) {
1252            $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [
1253                'user' => $username,
1254                'creator' => $creator->getUser()->getName(),
1255                'reason' => $status->getWikiText( false, false, 'en' )
1256            ] );
1257            return AuthenticationResponse::newFail( $status->getMessage() );
1258        }
1259
1260        $status = $this->canCreateAccount(
1261            $username, [ 'flags' => IDBAccessObject::READ_LOCKING, 'creating' => true ]
1262        );
1263        if ( !$status->isGood() ) {
1264            $this->logger->debug( __METHOD__ . ': {user} cannot be created: {reason}', [
1265                'user' => $username,
1266                'creator' => $creator->getUser()->getName(),
1267                'reason' => $status->getWikiText( false, false, 'en' )
1268            ] );
1269            return AuthenticationResponse::newFail( $status->getMessage() );
1270        }
1271
1272        $user = $this->userFactory->newFromName( (string)$username, UserRigorOptions::RIGOR_CREATABLE );
1273        foreach ( $reqs as $req ) {
1274            $req->username = $username;
1275            $req->returnToUrl = $returnToUrl;
1276            if ( $req instanceof UserDataAuthenticationRequest ) {
1277                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable user should be checked and valid here
1278                $status = $req->populateUser( $user );
1279                if ( !$status->isGood() ) {
1280                    $status = Status::wrap( $status );
1281                    $session->remove( self::ACCOUNT_CREATION_STATE );
1282                    $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [
1283                        'user' => $user->getName(),
1284                        'creator' => $creator->getUser()->getName(),
1285                        'reason' => $status->getWikiText( false, false, 'en' ),
1286                    ] );
1287                    return AuthenticationResponse::newFail( $status->getMessage() );
1288                }
1289            }
1290        }
1291
1292        $this->removeAuthenticationSessionData( null );
1293
1294        $state = [
1295            'username' => $username,
1296            'userid' => 0,
1297            'creatorid' => $creator->getUser()->getId(),
1298            'creatorname' => $creator->getUser()->getName(),
1299            'reqs' => $reqs,
1300            'returnToUrl' => $returnToUrl,
1301            'primary' => null,
1302            'primaryResponse' => null,
1303            'secondary' => [],
1304            'continueRequests' => [],
1305            'maybeLink' => [],
1306            'ranPreTests' => false,
1307        ];
1308
1309        // Special case: converting a login to an account creation
1310        $req = AuthenticationRequest::getRequestByClass(
1311            $reqs, CreateFromLoginAuthenticationRequest::class
1312        );
1313        if ( $req ) {
1314            $state['maybeLink'] = $req->maybeLink;
1315
1316            if ( $req->createRequest ) {
1317                $reqs[] = $req->createRequest;
1318                $state['reqs'][] = $req->createRequest;
1319            }
1320        }
1321
1322        $session->setSecret( self::ACCOUNT_CREATION_STATE, $state );
1323        $session->persist();
1324
1325        return $this->continueAccountCreation( $reqs );
1326    }
1327
1328    /**
1329     * Continue an account creation flow
1330     * @param AuthenticationRequest[] $reqs
1331     * @return AuthenticationResponse
1332     */
1333    public function continueAccountCreation( array $reqs ) {
1334        $session = $this->request->getSession();
1335        try {
1336            if ( !$this->canCreateAccounts() ) {
1337                // Caller should have called canCreateAccounts()
1338                $session->remove( self::ACCOUNT_CREATION_STATE );
1339                throw new \LogicException( 'Account creation is not possible' );
1340            }
1341
1342            $state = $session->getSecret( self::ACCOUNT_CREATION_STATE );
1343            if ( !is_array( $state ) ) {
1344                return AuthenticationResponse::newFail(
1345                    wfMessage( 'authmanager-create-not-in-progress' )
1346                );
1347            }
1348            $state['continueRequests'] = [];
1349
1350            // Step 0: Prepare and validate the input
1351
1352            $user = $this->userFactory->newFromName(
1353                (string)$state['username'],
1354                UserRigorOptions::RIGOR_CREATABLE
1355            );
1356            if ( !is_object( $user ) ) {
1357                $session->remove( self::ACCOUNT_CREATION_STATE );
1358                $this->logger->debug( __METHOD__ . ': Invalid username', [
1359                    'user' => $state['username'],
1360                ] );
1361                return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1362            }
1363
1364            if ( $state['creatorid'] ) {
1365                $creator = $this->userFactory->newFromId( (int)$state['creatorid'] );
1366            } else {
1367                $creator = $this->userFactory->newAnonymous();
1368                $creator->setName( $state['creatorname'] );
1369            }
1370
1371            // Avoid account creation races on double submissions
1372            $cache = \ObjectCache::getLocalClusterInstance();
1373            $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) );
1374            if ( !$lock ) {
1375                // Don't clear AuthManager::accountCreationState for this code
1376                // path because the process that won the race owns it.
1377                $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
1378                    'user' => $user->getName(),
1379                    'creator' => $creator->getName(),
1380                ] );
1381                return AuthenticationResponse::newFail( wfMessage( 'usernameinprogress' ) );
1382            }
1383
1384            // Permissions check
1385            $status = Status::wrap( $this->authorizeCreateAccount( $creator ) );
1386            if ( !$status->isGood() ) {
1387                $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [
1388                    'user' => $user->getName(),
1389                    'creator' => $creator->getName(),
1390                    'reason' => $status->getWikiText( false, false, 'en' )
1391                ] );
1392                $ret = AuthenticationResponse::newFail( $status->getMessage() );
1393                $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1394                $session->remove( self::ACCOUNT_CREATION_STATE );
1395                return $ret;
1396            }
1397
1398            // Load from primary DB for existence check
1399            $user->load( IDBAccessObject::READ_LOCKING );
1400
1401            if ( $state['userid'] === 0 ) {
1402                if ( $user->isRegistered() ) {
1403                    $this->logger->debug( __METHOD__ . ': User exists locally', [
1404                        'user' => $user->getName(),
1405                        'creator' => $creator->getName(),
1406                    ] );
1407                    $ret = AuthenticationResponse::newFail( wfMessage( 'userexists' ) );
1408                    $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1409                    $session->remove( self::ACCOUNT_CREATION_STATE );
1410                    return $ret;
1411                }
1412            } else {
1413                if ( !$user->isRegistered() ) {
1414                    $this->logger->debug( __METHOD__ . ': User does not exist locally when it should', [
1415                        'user' => $user->getName(),
1416                        'creator' => $creator->getName(),
1417                        'expected_id' => $state['userid'],
1418                    ] );
1419                    throw new \UnexpectedValueException(
1420                        "User \"{$state['username']}\" should exist now, but doesn't!"
1421                    );
1422                }
1423                if ( $user->getId() !== $state['userid'] ) {
1424                    $this->logger->debug( __METHOD__ . ': User ID/name mismatch', [
1425                        'user' => $user->getName(),
1426                        'creator' => $creator->getName(),
1427                        'expected_id' => $state['userid'],
1428                        'actual_id' => $user->getId(),
1429                    ] );
1430                    throw new \UnexpectedValueException(
1431                        "User \"{$state['username']}\" exists, but " .
1432                            "ID {$user->getId()} !== {$state['userid']}!"
1433                    );
1434                }
1435            }
1436            foreach ( $state['reqs'] as $req ) {
1437                if ( $req instanceof UserDataAuthenticationRequest ) {
1438                    $status = $req->populateUser( $user );
1439                    if ( !$status->isGood() ) {
1440                        // This should never happen...
1441                        $status = Status::wrap( $status );
1442                        $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [
1443                            'user' => $user->getName(),
1444                            'creator' => $creator->getName(),
1445                            'reason' => $status->getWikiText( false, false, 'en' ),
1446                        ] );
1447                        $ret = AuthenticationResponse::newFail( $status->getMessage() );
1448                        $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1449                        $session->remove( self::ACCOUNT_CREATION_STATE );
1450                        return $ret;
1451                    }
1452                }
1453            }
1454
1455            foreach ( $reqs as $req ) {
1456                $req->returnToUrl = $state['returnToUrl'];
1457                $req->username = $state['username'];
1458            }
1459
1460            // Run pre-creation tests, if we haven't already
1461            if ( !$state['ranPreTests'] ) {
1462                $providers = $this->getPreAuthenticationProviders() +
1463                    $this->getPrimaryAuthenticationProviders() +
1464                    $this->getSecondaryAuthenticationProviders();
1465                foreach ( $providers as $id => $provider ) {
1466                    $status = $provider->testForAccountCreation( $user, $creator, $reqs );
1467                    if ( !$status->isGood() ) {
1468                        $this->logger->debug( __METHOD__ . ": Fail in pre-authentication by $id", [
1469                            'user' => $user->getName(),
1470                            'creator' => $creator->getName(),
1471                        ] );
1472                        $ret = AuthenticationResponse::newFail(
1473                            Status::wrap( $status )->getMessage()
1474                        );
1475                        $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1476                        $session->remove( self::ACCOUNT_CREATION_STATE );
1477                        return $ret;
1478                    }
1479                }
1480
1481                $state['ranPreTests'] = true;
1482            }
1483
1484            // Step 1: Choose a primary authentication provider and call it until it succeeds.
1485
1486            if ( $state['primary'] === null ) {
1487                // We haven't picked a PrimaryAuthenticationProvider yet
1488                foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
1489                    if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_NONE ) {
1490                        continue;
1491                    }
1492                    $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs );
1493                    switch ( $res->status ) {
1494                        case AuthenticationResponse::PASS:
1495                            $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [
1496                                'user' => $user->getName(),
1497                                'creator' => $creator->getName(),
1498                            ] );
1499                            $state['primary'] = $id;
1500                            $state['primaryResponse'] = $res;
1501                            break 2;
1502                        case AuthenticationResponse::FAIL:
1503                            $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
1504                                'user' => $user->getName(),
1505                                'creator' => $creator->getName(),
1506                            ] );
1507                            $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1508                            $session->remove( self::ACCOUNT_CREATION_STATE );
1509                            return $res;
1510                        case AuthenticationResponse::ABSTAIN:
1511                            // Continue loop
1512                            break;
1513                        case AuthenticationResponse::REDIRECT:
1514                        case AuthenticationResponse::UI:
1515                            $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [
1516                                'user' => $user->getName(),
1517                                'creator' => $creator->getName(),
1518                            ] );
1519                            $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1520                            $state['primary'] = $id;
1521                            $state['continueRequests'] = $res->neededRequests;
1522                            $session->setSecret( self::ACCOUNT_CREATION_STATE, $state );
1523                            return $res;
1524
1525                            // @codeCoverageIgnoreStart
1526                        default:
1527                            throw new \DomainException(
1528                                get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status"
1529                            );
1530                            // @codeCoverageIgnoreEnd
1531                    }
1532                }
1533                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
1534                if ( $state['primary'] === null ) {
1535                    $this->logger->debug( __METHOD__ . ': Primary creation failed because no provider accepted', [
1536                        'user' => $user->getName(),
1537                        'creator' => $creator->getName(),
1538                    ] );
1539                    $ret = AuthenticationResponse::newFail(
1540                        wfMessage( 'authmanager-create-no-primary' )
1541                    );
1542                    $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1543                    $session->remove( self::ACCOUNT_CREATION_STATE );
1544                    return $ret;
1545                }
1546            } elseif ( $state['primaryResponse'] === null ) {
1547                $provider = $this->getAuthenticationProvider( $state['primary'] );
1548                if ( !$provider instanceof PrimaryAuthenticationProvider ) {
1549                    // Configuration changed? Force them to start over.
1550                    // @codeCoverageIgnoreStart
1551                    $ret = AuthenticationResponse::newFail(
1552                        wfMessage( 'authmanager-create-not-in-progress' )
1553                    );
1554                    $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1555                    $session->remove( self::ACCOUNT_CREATION_STATE );
1556                    return $ret;
1557                    // @codeCoverageIgnoreEnd
1558                }
1559                $id = $provider->getUniqueId();
1560                $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs );
1561                switch ( $res->status ) {
1562                    case AuthenticationResponse::PASS:
1563                        $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [
1564                            'user' => $user->getName(),
1565                            'creator' => $creator->getName(),
1566                        ] );
1567                        $state['primaryResponse'] = $res;
1568                        break;
1569                    case AuthenticationResponse::FAIL:
1570                        $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
1571                            'user' => $user->getName(),
1572                            'creator' => $creator->getName(),
1573                        ] );
1574                        $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1575                        $session->remove( self::ACCOUNT_CREATION_STATE );
1576                        return $res;
1577                    case AuthenticationResponse::REDIRECT:
1578                    case AuthenticationResponse::UI:
1579                        $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [
1580                            'user' => $user->getName(),
1581                            'creator' => $creator->getName(),
1582                        ] );
1583                        $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1584                        $state['continueRequests'] = $res->neededRequests;
1585                        $session->setSecret( self::ACCOUNT_CREATION_STATE, $state );
1586                        return $res;
1587                    default:
1588                        throw new \DomainException(
1589                            get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status"
1590                        );
1591                }
1592            }
1593
1594            // Step 2: Primary authentication succeeded, create the User object
1595            // and add the user locally.
1596
1597            if ( $state['userid'] === 0 ) {
1598                $this->logger->info( 'Creating user {user} during account creation', [
1599                    'user' => $user->getName(),
1600                    'creator' => $creator->getName(),
1601                ] );
1602                $status = $user->addToDatabase();
1603                if ( !$status->isOK() ) {
1604                    // @codeCoverageIgnoreStart
1605                    $ret = AuthenticationResponse::newFail( $status->getMessage() );
1606                    $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1607                    $session->remove( self::ACCOUNT_CREATION_STATE );
1608                    return $ret;
1609                    // @codeCoverageIgnoreEnd
1610                }
1611                $this->setDefaultUserOptions( $user, $creator->isAnon() );
1612                $this->getHookRunner()->onLocalUserCreated( $user, false );
1613                $user->saveSettings();
1614                $state['userid'] = $user->getId();
1615
1616                // Update user count
1617                DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'users' => 1 ] ) );
1618
1619                // Watch user's userpage and talk page
1620                $this->watchlistManager->addWatchIgnoringRights( $user, $user->getUserPage() );
1621
1622                // Inform the provider
1623                // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
1624                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
1625                $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] );
1626
1627                // Log the creation
1628                if ( $this->config->get( MainConfigNames::NewUserLog ) ) {
1629                    $isAnon = $creator->isAnon();
1630                    $logEntry = new \ManualLogEntry(
1631                        'newusers',
1632                        $logSubtype ?: ( $isAnon ? 'create' : 'create2' )
1633                    );
1634                    $logEntry->setPerformer( $isAnon ? $user : $creator );
1635                    $logEntry->setTarget( $user->getUserPage() );
1636                    /** @var CreationReasonAuthenticationRequest $req */
1637                    $req = AuthenticationRequest::getRequestByClass(
1638                        $state['reqs'], CreationReasonAuthenticationRequest::class
1639                    );
1640                    $logEntry->setComment( $req ? $req->reason : '' );
1641                    $logEntry->setParameters( [
1642                        '4::userid' => $user->getId(),
1643                    ] );
1644                    $logid = $logEntry->insert();
1645                    $logEntry->publish( $logid );
1646                }
1647            }
1648
1649            // Step 3: Iterate over all the secondary authentication providers.
1650
1651            $beginReqs = $state['reqs'];
1652
1653            foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
1654                if ( !isset( $state['secondary'][$id] ) ) {
1655                    // This provider isn't started yet, so we pass it the set
1656                    // of reqs from beginAuthentication instead of whatever
1657                    // might have been used by a previous provider in line.
1658                    $func = 'beginSecondaryAccountCreation';
1659                    $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs );
1660                } elseif ( !$state['secondary'][$id] ) {
1661                    $func = 'continueSecondaryAccountCreation';
1662                    $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs );
1663                } else {
1664                    continue;
1665                }
1666                switch ( $res->status ) {
1667                    case AuthenticationResponse::PASS:
1668                        $this->logger->debug( __METHOD__ . ": Secondary creation passed by $id", [
1669                            'user' => $user->getName(),
1670                            'creator' => $creator->getName(),
1671                        ] );
1672                        // fall through
1673                    case AuthenticationResponse::ABSTAIN:
1674                        $state['secondary'][$id] = true;
1675                        break;
1676                    case AuthenticationResponse::REDIRECT:
1677                    case AuthenticationResponse::UI:
1678                        $this->logger->debug( __METHOD__ . ": Secondary creation $res->status by $id", [
1679                            'user' => $user->getName(),
1680                            'creator' => $creator->getName(),
1681                        ] );
1682                        $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1683                        $state['secondary'][$id] = false;
1684                        $state['continueRequests'] = $res->neededRequests;
1685                        $session->setSecret( self::ACCOUNT_CREATION_STATE, $state );
1686                        return $res;
1687                    case AuthenticationResponse::FAIL:
1688                        throw new \DomainException(
1689                            get_class( $provider ) . "::{$func}() returned $res->status." .
1690                            ' Secondary providers are not allowed to fail account creation, that' .
1691                            ' should have been done via testForAccountCreation().'
1692                        );
1693                            // @codeCoverageIgnoreStart
1694                    default:
1695                        throw new \DomainException(
1696                            get_class( $provider ) . "::{$func}() returned $res->status"
1697                        );
1698                            // @codeCoverageIgnoreEnd
1699                }
1700            }
1701
1702            $id = $user->getId();
1703            $name = $user->getName();
1704            $req = new CreatedAccountAuthenticationRequest( $id, $name );
1705            $ret = AuthenticationResponse::newPass( $name );
1706            $ret->loginRequest = $req;
1707            $this->createdAccountAuthenticationRequests[] = $req;
1708
1709            $this->logger->info( __METHOD__ . ': Account creation succeeded for {user}', [
1710                'user' => $user->getName(),
1711                'creator' => $creator->getName(),
1712            ] );
1713
1714            $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1715            $session->remove( self::ACCOUNT_CREATION_STATE );
1716            $this->removeAuthenticationSessionData( null );
1717            return $ret;
1718        } catch ( \Exception $ex ) {
1719            $session->remove( self::ACCOUNT_CREATION_STATE );
1720            throw $ex;
1721        }
1722    }
1723
1724    /**
1725     * Auto-create an account, and optionally log into that account
1726     *
1727     * PrimaryAuthenticationProviders can invoke this method by returning a PASS from
1728     * beginPrimaryAuthentication/continuePrimaryAuthentication with the username of a
1729     * non-existing user. SessionProviders can invoke it by returning a SessionInfo with
1730     * the username of a non-existing user from provideSessionInfo(). Calling this method
1731     * explicitly (e.g. from a maintenance script) is also fine.
1732     *
1733     * @param User $user User to auto-create
1734     * @param string $source What caused the auto-creation? This must be one of:
1735     *  - the ID of a PrimaryAuthenticationProvider,
1736     *  - one of the self::AUTOCREATE_SOURCE_* constants
1737     * @param bool $login Whether to also log the user in
1738     * @param bool $log Whether to generate a user creation log entry (since 1.36)
1739     * @param Authority|null $performer The performer of the action to use for user rights
1740     *   checking. Normally null to indicate an anonymous performer. Added in 1.42 for
1741     *   Special:CreateLocalAccount (T234371).
1742     *
1743     * @return Status Good if user was created, Ok if user already existed, otherwise Fatal
1744     */
1745    public function autoCreateUser(
1746        User $user,
1747        $source,
1748        $login = true,
1749        $log = true,
1750        ?Authority $performer = null
1751    ) {
1752        $validSources = [
1753            self::AUTOCREATE_SOURCE_SESSION,
1754            self::AUTOCREATE_SOURCE_MAINT,
1755            self::AUTOCREATE_SOURCE_TEMP
1756        ];
1757        if ( !in_array( $source, $validSources, true )
1758            && !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider
1759        ) {
1760            throw new \InvalidArgumentException( "Unknown auto-creation source: $source" );
1761        }
1762
1763        $username = $user->getName();
1764
1765        // Try the local user from the replica DB
1766        $localUserIdentity = $this->userIdentityLookup->getUserIdentityByName( $username );
1767        $localId = ( $localUserIdentity && $localUserIdentity->getId() )
1768            ? $localUserIdentity->getId()
1769            : null;
1770        $flags = IDBAccessObject::READ_NORMAL;
1771
1772        // Fetch the user ID from the primary, so that we don't try to create the user
1773        // when they already exist, due to replication lag
1774        // @codeCoverageIgnoreStart
1775        if (
1776            !$localId &&
1777            $this->loadBalancer->getReaderIndex() !== 0
1778        ) {
1779            $localUserIdentity = $this->userIdentityLookup->getUserIdentityByName(
1780                $username,
1781                IDBAccessObject::READ_LATEST
1782            );
1783            $localId = ( $localUserIdentity && $localUserIdentity->getId() )
1784                ? $localUserIdentity->getId()
1785                : null;
1786            $flags = IDBAccessObject::READ_LATEST;
1787        }
1788        // @codeCoverageIgnoreEnd
1789
1790        if ( $localId ) {
1791            $this->logger->debug( __METHOD__ . ': {username} already exists locally', [
1792                'username' => $username,
1793            ] );
1794            $user->setId( $localId );
1795            $user->loadFromId( $flags );
1796            if ( $login ) {
1797                $remember = $source === self::AUTOCREATE_SOURCE_TEMP;
1798                $this->setSessionDataForUser( $user, $remember );
1799            }
1800            return Status::newGood()->warning( 'userexists' );
1801        }
1802
1803        // Wiki is read-only?
1804        if ( $this->readOnlyMode->isReadOnly() ) {
1805            $reason = $this->readOnlyMode->getReason();
1806            $this->logger->debug( __METHOD__ . ': denied because of read only mode: {reason}', [
1807                'username' => $username,
1808                'reason' => $reason,
1809            ] );
1810            $user->setId( 0 );
1811            $user->loadFromId();
1812            return Status::newFatal( wfMessage( 'readonlytext', $reason ) );
1813        }
1814
1815        // If there is a non-anonymous performer, don't use their session
1816        $session = $performer ? null : $this->request->getSession();
1817
1818        // Check the session, if we tried to create this user already there's
1819        // no point in retrying.
1820        if ( $session && $session->get( self::AUTOCREATE_BLOCKLIST ) ) {
1821            $this->logger->debug( __METHOD__ . ': blacklisted in session {sessionid}', [
1822                'username' => $username,
1823                'sessionid' => $session->getId(),
1824            ] );
1825            $user->setId( 0 );
1826            $user->loadFromId();
1827            $reason = $session->get( self::AUTOCREATE_BLOCKLIST );
1828            if ( $reason instanceof StatusValue ) {
1829                return Status::wrap( $reason );
1830            } else {
1831                return Status::newFatal( $reason );
1832            }
1833        }
1834
1835        // Is the username usable? (Previously isCreatable() was checked here but
1836        // that doesn't work with auto-creation of TempUser accounts by CentralAuth)
1837        if ( !$this->userNameUtils->isUsable( $username ) ) {
1838            $this->logger->debug( __METHOD__ . ': name "{username}" is not usable', [
1839                'username' => $username,
1840            ] );
1841            if ( $session ) {
1842                $session->set( self::AUTOCREATE_BLOCKLIST, 'noname' );
1843            }
1844            $user->setId( 0 );
1845            $user->loadFromId();
1846            return Status::newFatal( 'noname' );
1847        }
1848
1849        // Is the IP user able to create accounts?
1850        $performer ??= $this->userFactory->newAnonymous();
1851        $bypassAuthorization = $session ? $session->getProvider()->canAlwaysAutocreate() : false;
1852        if ( $source !== self::AUTOCREATE_SOURCE_MAINT && !$bypassAuthorization ) {
1853            $status = $this->authorizeAutoCreateAccount( $performer );
1854            if ( !$status->isOK() ) {
1855                $this->logger->debug( __METHOD__ . ': cannot create or autocreate accounts', [
1856                    'username' => $username,
1857                    'creator' => $performer->getUser()->getName(),
1858                ] );
1859                if ( $session ) {
1860                    $session->set( self::AUTOCREATE_BLOCKLIST, $status );
1861                    $session->persist();
1862                }
1863                $user->setId( 0 );
1864                $user->loadFromId();
1865                return Status::wrap( $status );
1866            }
1867        }
1868
1869        // Avoid account creation races on double submissions
1870        $cache = \ObjectCache::getLocalClusterInstance();
1871        $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
1872        if ( !$lock ) {
1873            $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
1874                'user' => $username,
1875            ] );
1876            $user->setId( 0 );
1877            $user->loadFromId();
1878            return Status::newFatal( 'usernameinprogress' );
1879        }
1880
1881        // Denied by providers?
1882        $options = [
1883            'flags' => IDBAccessObject::READ_LATEST,
1884            'creating' => true,
1885        ];
1886        $providers = $this->getPreAuthenticationProviders() +
1887            $this->getPrimaryAuthenticationProviders() +
1888            $this->getSecondaryAuthenticationProviders();
1889        foreach ( $providers as $provider ) {
1890            $status = $provider->testUserForCreation( $user, $source, $options );
1891            if ( !$status->isGood() ) {
1892                $ret = Status::wrap( $status );
1893                $this->logger->debug( __METHOD__ . ': Provider denied creation of {username}: {reason}', [
1894                    'username' => $username,
1895                    'reason' => $ret->getWikiText( false, false, 'en' ),
1896                ] );
1897                if ( $session ) {
1898                    $session->set( self::AUTOCREATE_BLOCKLIST, $status );
1899                }
1900                $user->setId( 0 );
1901                $user->loadFromId();
1902                return $ret;
1903            }
1904        }
1905
1906        $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
1907        if ( $cache->get( $backoffKey ) ) {
1908            $this->logger->debug( __METHOD__ . ': {username} denied by prior creation attempt failures', [
1909                'username' => $username,
1910            ] );
1911            $user->setId( 0 );
1912            $user->loadFromId();
1913            return Status::newFatal( 'authmanager-autocreate-exception' );
1914        }
1915
1916        // Checks passed, create the user...
1917        $from = $_SERVER['REQUEST_URI'] ?? 'CLI';
1918        $this->logger->info( __METHOD__ . ': creating new user ({username}) - from: {from}', [
1919            'username' => $username,
1920            'from' => $from,
1921        ] );
1922
1923        // Ignore warnings about primary connections/writes...hard to avoid here
1924        $trxProfiler = \Profiler::instance()->getTransactionProfiler();
1925        $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY );
1926        try {
1927            $status = $user->addToDatabase();
1928            if ( !$status->isOK() ) {
1929                // Double-check for a race condition (T70012). We make use of the fact that when
1930                // addToDatabase fails due to the user already existing, the user object gets loaded.
1931                if ( $user->getId() ) {
1932                    $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [
1933                        'username' => $username,
1934                    ] );
1935                    if ( $login ) {
1936                        $remember = $source === self::AUTOCREATE_SOURCE_TEMP;
1937                        $this->setSessionDataForUser( $user, $remember );
1938                    }
1939                    $status = Status::newGood()->warning( 'userexists' );
1940                } else {
1941                    $this->logger->error( __METHOD__ . ': {username} failed with message {msg}', [
1942                        'username' => $username,
1943                        'msg' => $status->getWikiText( false, false, 'en' )
1944                    ] );
1945                    $user->setId( 0 );
1946                    $user->loadFromId();
1947                }
1948                return $status;
1949            }
1950        } catch ( \Exception $ex ) {
1951            $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [
1952                'username' => $username,
1953                'exception' => $ex,
1954            ] );
1955            // Do not keep throwing errors for a while
1956            $cache->set( $backoffKey, 1, 600 );
1957            // Bubble up error; which should normally trigger DB rollbacks
1958            throw $ex;
1959        }
1960
1961        $this->setDefaultUserOptions( $user, false );
1962
1963        // Inform the providers
1964        $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] );
1965
1966        $this->getHookRunner()->onLocalUserCreated( $user, true );
1967        $user->saveSettings();
1968
1969        // Update user count
1970        DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'users' => 1 ] ) );
1971        // Watch user's userpage and talk page (except temp users)
1972        if ( $source !== self::AUTOCREATE_SOURCE_TEMP ) {
1973            DeferredUpdates::addCallableUpdate( function () use ( $user ) {
1974                $this->watchlistManager->addWatchIgnoringRights( $user, $user->getUserPage() );
1975            } );
1976        }
1977
1978        // Log the creation
1979        if ( $this->config->get( MainConfigNames::NewUserLog ) && $log ) {
1980            $logEntry = new \ManualLogEntry( 'newusers', 'autocreate' );
1981            $logEntry->setPerformer( $user );
1982            $logEntry->setTarget( $user->getUserPage() );
1983            $logEntry->setComment( '' );
1984            $logEntry->setParameters( [
1985                '4::userid' => $user->getId(),
1986            ] );
1987            $logEntry->insert();
1988        }
1989
1990        ScopedCallback::consume( $scope );
1991
1992        if ( $login ) {
1993            $remember = $source === self::AUTOCREATE_SOURCE_TEMP;
1994            $this->setSessionDataForUser( $user, $remember );
1995        }
1996
1997        return Status::newGood();
1998    }
1999
2000    /**
2001     * Authorize automatic account creation. This is like account creation but
2002     * checks the autocreateaccount right instead of the createaccount right.
2003     *
2004     * @param Authority $creator
2005     * @return StatusValue
2006     */
2007    private function authorizeAutoCreateAccount( Authority $creator ) {
2008        return $this->authorizeInternal(
2009            static function (
2010                string $action,
2011                PageIdentity $target,
2012                PermissionStatus $status
2013            ) use ( $creator ) {
2014                return $creator->authorizeWrite( $action, $target, $status );
2015            },
2016            'autocreateaccount'
2017        );
2018    }
2019
2020    // endregion -- end of Account creation
2021
2022    /***************************************************************************/
2023    // region   Account linking
2024    /** @name   Account linking */
2025
2026    /**
2027     * Determine whether accounts can be linked
2028     * @return bool
2029     */
2030    public function canLinkAccounts() {
2031        foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2032            if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
2033                return true;
2034            }
2035        }
2036        return false;
2037    }
2038
2039    /**
2040     * Start an account linking flow
2041     *
2042     * @param User $user User being linked
2043     * @param AuthenticationRequest[] $reqs
2044     * @param string $returnToUrl Url that REDIRECT responses should eventually
2045     *  return to.
2046     * @return AuthenticationResponse
2047     */
2048    public function beginAccountLink( User $user, array $reqs, $returnToUrl ) {
2049        $session = $this->request->getSession();
2050        $session->remove( self::ACCOUNT_LINK_STATE );
2051
2052        if ( !$this->canLinkAccounts() ) {
2053            // Caller should have called canLinkAccounts()
2054            throw new \LogicException( 'Account linking is not possible' );
2055        }
2056
2057        if ( !$user->isRegistered() ) {
2058            if ( !$this->userNameUtils->isUsable( $user->getName() ) ) {
2059                $msg = wfMessage( 'noname' );
2060            } else {
2061                $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() );
2062            }
2063            return AuthenticationResponse::newFail( $msg );
2064        }
2065        foreach ( $reqs as $req ) {
2066            $req->username = $user->getName();
2067            $req->returnToUrl = $returnToUrl;
2068        }
2069
2070        $this->removeAuthenticationSessionData( null );
2071
2072        $providers = $this->getPreAuthenticationProviders();
2073        foreach ( $providers as $id => $provider ) {
2074            $status = $provider->testForAccountLink( $user );
2075            if ( !$status->isGood() ) {
2076                $this->logger->debug( __METHOD__ . ": Account linking pre-check failed by $id", [
2077                    'user' => $user->getName(),
2078                ] );
2079                $ret = AuthenticationResponse::newFail(
2080                    Status::wrap( $status )->getMessage()
2081                );
2082                $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2083                return $ret;
2084            }
2085        }
2086
2087        $state = [
2088            'username' => $user->getName(),
2089            'userid' => $user->getId(),
2090            'returnToUrl' => $returnToUrl,
2091            'primary' => null,
2092            'continueRequests' => [],
2093        ];
2094
2095        $providers = $this->getPrimaryAuthenticationProviders();
2096        foreach ( $providers as $id => $provider ) {
2097            if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) {
2098                continue;
2099            }
2100
2101            $res = $provider->beginPrimaryAccountLink( $user, $reqs );
2102            switch ( $res->status ) {
2103                case AuthenticationResponse::PASS:
2104                    $this->logger->info( "Account linked to {user} by $id", [
2105                        'user' => $user->getName(),
2106                    ] );
2107                    $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2108                    return $res;
2109
2110                case AuthenticationResponse::FAIL:
2111                    $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
2112                        'user' => $user->getName(),
2113                    ] );
2114                    $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2115                    return $res;
2116
2117                case AuthenticationResponse::ABSTAIN:
2118                    // Continue loop
2119                    break;
2120
2121                case AuthenticationResponse::REDIRECT:
2122                case AuthenticationResponse::UI:
2123                    $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
2124                        'user' => $user->getName(),
2125                    ] );
2126                    $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
2127                    $state['primary'] = $id;
2128                    $state['continueRequests'] = $res->neededRequests;
2129                    $session->setSecret( self::ACCOUNT_LINK_STATE, $state );
2130                    $session->persist();
2131                    return $res;
2132
2133                    // @codeCoverageIgnoreStart
2134                default:
2135                    throw new \DomainException(
2136                        get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status"
2137                    );
2138                    // @codeCoverageIgnoreEnd
2139            }
2140        }
2141
2142        $this->logger->debug( __METHOD__ . ': Account linking failed because no provider accepted', [
2143            'user' => $user->getName(),
2144        ] );
2145        $ret = AuthenticationResponse::newFail(
2146            wfMessage( 'authmanager-link-no-primary' )
2147        );
2148        $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2149        return $ret;
2150    }
2151
2152    /**
2153     * Continue an account linking flow
2154     * @param AuthenticationRequest[] $reqs
2155     * @return AuthenticationResponse
2156     */
2157    public function continueAccountLink( array $reqs ) {
2158        $session = $this->request->getSession();
2159        try {
2160            if ( !$this->canLinkAccounts() ) {
2161                // Caller should have called canLinkAccounts()
2162                $session->remove( self::ACCOUNT_LINK_STATE );
2163                throw new \LogicException( 'Account linking is not possible' );
2164            }
2165
2166            $state = $session->getSecret( self::ACCOUNT_LINK_STATE );
2167            if ( !is_array( $state ) ) {
2168                return AuthenticationResponse::newFail(
2169                    wfMessage( 'authmanager-link-not-in-progress' )
2170                );
2171            }
2172            $state['continueRequests'] = [];
2173
2174            // Step 0: Prepare and validate the input
2175
2176            $user = $this->userFactory->newFromName(
2177                (string)$state['username'],
2178                UserRigorOptions::RIGOR_USABLE
2179            );
2180            if ( !is_object( $user ) ) {
2181                $session->remove( self::ACCOUNT_LINK_STATE );
2182                return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
2183            }
2184            if ( $user->getId() !== $state['userid'] ) {
2185                throw new \UnexpectedValueException(
2186                    "User \"{$state['username']}\" is valid, but " .
2187                        "ID {$user->getId()} !== {$state['userid']}!"
2188                );
2189            }
2190
2191            foreach ( $reqs as $req ) {
2192                $req->username = $state['username'];
2193                $req->returnToUrl = $state['returnToUrl'];
2194            }
2195
2196            // Step 1: Call the primary again until it succeeds
2197
2198            $provider = $this->getAuthenticationProvider( $state['primary'] );
2199            if ( !$provider instanceof PrimaryAuthenticationProvider ) {
2200                // Configuration changed? Force them to start over.
2201                // @codeCoverageIgnoreStart
2202                $ret = AuthenticationResponse::newFail(
2203                    wfMessage( 'authmanager-link-not-in-progress' )
2204                );
2205                $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2206                $session->remove( self::ACCOUNT_LINK_STATE );
2207                return $ret;
2208                // @codeCoverageIgnoreEnd
2209            }
2210            $id = $provider->getUniqueId();
2211            $res = $provider->continuePrimaryAccountLink( $user, $reqs );
2212            switch ( $res->status ) {
2213                case AuthenticationResponse::PASS:
2214                    $this->logger->info( "Account linked to {user} by $id", [
2215                        'user' => $user->getName(),
2216                    ] );
2217                    $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2218                    $session->remove( self::ACCOUNT_LINK_STATE );
2219                    return $res;
2220                case AuthenticationResponse::FAIL:
2221                    $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
2222                        'user' => $user->getName(),
2223                    ] );
2224                    $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2225                    $session->remove( self::ACCOUNT_LINK_STATE );
2226                    return $res;
2227                case AuthenticationResponse::REDIRECT:
2228                case AuthenticationResponse::UI:
2229                    $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
2230                        'user' => $user->getName(),
2231                    ] );
2232                    $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
2233                    $state['continueRequests'] = $res->neededRequests;
2234                    $session->setSecret( self::ACCOUNT_LINK_STATE, $state );
2235                    return $res;
2236                default:
2237                    throw new \DomainException(
2238                        get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
2239                    );
2240            }
2241        } catch ( \Exception $ex ) {
2242            $session->remove( self::ACCOUNT_LINK_STATE );
2243            throw $ex;
2244        }
2245    }
2246
2247    // endregion -- end of Account linking
2248
2249    /***************************************************************************/
2250    // region   Information methods
2251    /** @name   Information methods */
2252
2253    /**
2254     * Return the applicable list of AuthenticationRequests
2255     *
2256     * Possible values for $action:
2257     *  - ACTION_LOGIN: Valid for passing to beginAuthentication
2258     *  - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state
2259     *  - ACTION_CREATE: Valid for passing to beginAccountCreation
2260     *  - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state
2261     *  - ACTION_LINK: Valid for passing to beginAccountLink
2262     *  - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state
2263     *  - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials
2264     *  - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials.
2265     *  - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts.
2266     *
2267     * @param string $action One of the AuthManager::ACTION_* constants
2268     * @param UserIdentity|null $user User being acted on, instead of the current user.
2269     * @return AuthenticationRequest[]
2270     */
2271    public function getAuthenticationRequests( $action, UserIdentity $user = null ) {
2272        $options = [];
2273        $providerAction = $action;
2274
2275        // Figure out which providers to query
2276        switch ( $action ) {
2277            case self::ACTION_LOGIN:
2278            case self::ACTION_CREATE:
2279                $providers = $this->getPreAuthenticationProviders() +
2280                    $this->getPrimaryAuthenticationProviders() +
2281                    $this->getSecondaryAuthenticationProviders();
2282                break;
2283
2284            case self::ACTION_LOGIN_CONTINUE:
2285                $state = $this->request->getSession()->getSecret( self::AUTHN_STATE );
2286                return is_array( $state ) ? $state['continueRequests'] : [];
2287
2288            case self::ACTION_CREATE_CONTINUE:
2289                $state = $this->request->getSession()->getSecret( self::ACCOUNT_CREATION_STATE );
2290                return is_array( $state ) ? $state['continueRequests'] : [];
2291
2292            case self::ACTION_LINK:
2293                $providers = [];
2294                foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2295                    if ( $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
2296                        $providers[] = $p;
2297                    }
2298                }
2299                break;
2300
2301            case self::ACTION_UNLINK:
2302                $providers = [];
2303                foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2304                    if ( $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
2305                        $providers[] = $p;
2306                    }
2307                }
2308
2309                // To providers, unlink and remove are identical.
2310                $providerAction = self::ACTION_REMOVE;
2311                break;
2312
2313            case self::ACTION_LINK_CONTINUE:
2314                $state = $this->request->getSession()->getSecret( self::ACCOUNT_LINK_STATE );
2315                return is_array( $state ) ? $state['continueRequests'] : [];
2316
2317            case self::ACTION_CHANGE:
2318            case self::ACTION_REMOVE:
2319                $providers = $this->getPrimaryAuthenticationProviders() +
2320                    $this->getSecondaryAuthenticationProviders();
2321                break;
2322
2323            // @codeCoverageIgnoreStart
2324            default:
2325                throw new \DomainException( __METHOD__ . ": Invalid action \"$action\"" );
2326        }
2327        // @codeCoverageIgnoreEnd
2328
2329        return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user );
2330    }
2331
2332    /**
2333     * Internal request lookup for self::getAuthenticationRequests
2334     *
2335     * @param string $providerAction Action to pass to providers
2336     * @param array $options Options to pass to providers
2337     * @param AuthenticationProvider[] $providers
2338     * @param UserIdentity|null $user being acted on
2339     * @return AuthenticationRequest[]
2340     */
2341    private function getAuthenticationRequestsInternal(
2342        $providerAction, array $options, array $providers, UserIdentity $user = null
2343    ) {
2344        $user = $user ?: RequestContext::getMain()->getUser();
2345        $options['username'] = $user->isRegistered() ? $user->getName() : null;
2346
2347        // Query them and merge results
2348        $reqs = [];
2349        foreach ( $providers as $provider ) {
2350            $isPrimary = $provider instanceof PrimaryAuthenticationProvider;
2351            foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) {
2352                $id = $req->getUniqueId();
2353
2354                // If a required request if from a Primary, mark it as "primary-required" instead
2355                if ( $isPrimary && $req->required ) {
2356                    $req->required = AuthenticationRequest::PRIMARY_REQUIRED;
2357                }
2358
2359                if (
2360                    !isset( $reqs[$id] )
2361                    || $req->required === AuthenticationRequest::REQUIRED
2362                    || $reqs[$id] === AuthenticationRequest::OPTIONAL
2363                ) {
2364                    $reqs[$id] = $req;
2365                }
2366            }
2367        }
2368
2369        // AuthManager has its own req for some actions
2370        switch ( $providerAction ) {
2371            case self::ACTION_LOGIN:
2372                $reqs[] = new RememberMeAuthenticationRequest(
2373                    $this->config->get( MainConfigNames::RememberMe ) );
2374                $options['username'] = null; // Don't fill in the username below
2375                break;
2376
2377            case self::ACTION_CREATE:
2378                $reqs[] = new UsernameAuthenticationRequest;
2379                $reqs[] = new UserDataAuthenticationRequest;
2380                if ( $options['username'] !== null ) {
2381                    $reqs[] = new CreationReasonAuthenticationRequest;
2382                    $options['username'] = null; // Don't fill in the username below
2383                }
2384                break;
2385        }
2386
2387        // Fill in reqs data
2388        $this->fillRequests( $reqs, $providerAction, $options['username'], true );
2389
2390        // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing
2391        if ( $providerAction === self::ACTION_CHANGE || $providerAction === self::ACTION_REMOVE ) {
2392            $reqs = array_filter( $reqs, function ( $req ) {
2393                return $this->allowsAuthenticationDataChange( $req, false )->isGood();
2394            } );
2395        }
2396
2397        return array_values( $reqs );
2398    }
2399
2400    /**
2401     * Set values in an array of requests
2402     * @param AuthenticationRequest[] &$reqs
2403     * @param string $action
2404     * @param string|null $username
2405     * @param bool $forceAction
2406     */
2407    private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) {
2408        foreach ( $reqs as $req ) {
2409            if ( !$req->action || $forceAction ) {
2410                $req->action = $action;
2411            }
2412            $req->username ??= $username;
2413        }
2414    }
2415
2416    /**
2417     * Determine whether a username exists
2418     * @param string $username
2419     * @param int $flags Bitfield of IDBAccessObject::READ_* constants
2420     * @return bool
2421     */
2422    public function userExists( $username, $flags = IDBAccessObject::READ_NORMAL ) {
2423        foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2424            if ( $provider->testUserExists( $username, $flags ) ) {
2425                return true;
2426            }
2427        }
2428
2429        return false;
2430    }
2431
2432    /**
2433     * Determine whether a user property should be allowed to be changed.
2434     *
2435     * Supported properties are:
2436     *  - emailaddress
2437     *  - realname
2438     *  - nickname
2439     *
2440     * @param string $property
2441     * @return bool
2442     */
2443    public function allowsPropertyChange( $property ) {
2444        $providers = $this->getPrimaryAuthenticationProviders() +
2445            $this->getSecondaryAuthenticationProviders();
2446        foreach ( $providers as $provider ) {
2447            if ( !$provider->providerAllowsPropertyChange( $property ) ) {
2448                return false;
2449            }
2450        }
2451        return true;
2452    }
2453
2454    /**
2455     * Get a provider by ID
2456     * @note This is public so extensions can check whether their own provider
2457     *  is installed and so they can read its configuration if necessary.
2458     *  Other uses are not recommended.
2459     * @param string $id
2460     * @return AuthenticationProvider|null
2461     */
2462    public function getAuthenticationProvider( $id ) {
2463        // Fast version
2464        if ( isset( $this->allAuthenticationProviders[$id] ) ) {
2465            return $this->allAuthenticationProviders[$id];
2466        }
2467
2468        // Slow version: instantiate each kind and check
2469        $providers = $this->getPrimaryAuthenticationProviders();
2470        if ( isset( $providers[$id] ) ) {
2471            return $providers[$id];
2472        }
2473        $providers = $this->getSecondaryAuthenticationProviders();
2474        if ( isset( $providers[$id] ) ) {
2475            return $providers[$id];
2476        }
2477        $providers = $this->getPreAuthenticationProviders();
2478        if ( isset( $providers[$id] ) ) {
2479            return $providers[$id];
2480        }
2481
2482        return null;
2483    }
2484
2485    // endregion -- end of Information methods
2486
2487    /***************************************************************************/
2488    // region   Internal methods
2489    /** @name   Internal methods */
2490
2491    /**
2492     * Store authentication in the current session
2493     * @note For use by AuthenticationProviders only
2494     * @param string $key
2495     * @param mixed $data Must be serializable
2496     */
2497    public function setAuthenticationSessionData( $key, $data ) {
2498        $session = $this->request->getSession();
2499        $arr = $session->getSecret( 'authData' );
2500        if ( !is_array( $arr ) ) {
2501            $arr = [];
2502        }
2503        $arr[$key] = $data;
2504        $session->setSecret( 'authData', $arr );
2505    }
2506
2507    /**
2508     * Fetch authentication data from the current session
2509     * @note For use by AuthenticationProviders only
2510     * @param string $key
2511     * @param mixed|null $default
2512     * @return mixed
2513     */
2514    public function getAuthenticationSessionData( $key, $default = null ) {
2515        $arr = $this->request->getSession()->getSecret( 'authData' );
2516        if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2517            return $arr[$key];
2518        } else {
2519            return $default;
2520        }
2521    }
2522
2523    /**
2524     * Remove authentication data
2525     * @note For use by AuthenticationProviders
2526     * @param string|null $key If null, all data is removed
2527     */
2528    public function removeAuthenticationSessionData( $key ) {
2529        $session = $this->request->getSession();
2530        if ( $key === null ) {
2531            $session->remove( 'authData' );
2532        } else {
2533            $arr = $session->getSecret( 'authData' );
2534            if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2535                unset( $arr[$key] );
2536                $session->setSecret( 'authData', $arr );
2537            }
2538        }
2539    }
2540
2541    /**
2542     * Create an array of AuthenticationProviders from an array of ObjectFactory specs
2543     * @param string $class
2544     * @param array[] $specs
2545     * @return AuthenticationProvider[]
2546     */
2547    protected function providerArrayFromSpecs( $class, array $specs ) {
2548        $i = 0;
2549        foreach ( $specs as &$spec ) {
2550            $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ];
2551        }
2552        unset( $spec );
2553        // Sort according to the 'sort' field, and if they are equal, according to 'sort2'
2554        usort( $specs, static function ( $a, $b ) {
2555            return $a['sort'] <=> $b['sort']
2556                ?: $a['sort2'] <=> $b['sort2'];
2557        } );
2558
2559        $ret = [];
2560        foreach ( $specs as $spec ) {
2561            /** @var AbstractAuthenticationProvider $provider */
2562            $provider = $this->objectFactory->createObject( $spec, [ 'assertClass' => $class ] );
2563            $provider->init( $this->logger, $this, $this->getHookContainer(), $this->config, $this->userNameUtils );
2564            $id = $provider->getUniqueId();
2565            if ( isset( $this->allAuthenticationProviders[$id] ) ) {
2566                throw new \RuntimeException(
2567                    "Duplicate specifications for id $id (classes " .
2568                    get_class( $provider ) . ' and ' .
2569                    get_class( $this->allAuthenticationProviders[$id] ) . ')'
2570                );
2571            }
2572            $this->allAuthenticationProviders[$id] = $provider;
2573            $ret[$id] = $provider;
2574        }
2575        return $ret;
2576    }
2577
2578    /**
2579     * @return array
2580     */
2581    private function getConfiguration() {
2582        return $this->config->get( MainConfigNames::AuthManagerConfig )
2583            ?: $this->config->get( MainConfigNames::AuthManagerAutoConfig );
2584    }
2585
2586    /**
2587     * Get the list of PreAuthenticationProviders
2588     * @return PreAuthenticationProvider[]
2589     */
2590    protected function getPreAuthenticationProviders() {
2591        if ( $this->preAuthenticationProviders === null ) {
2592            $conf = $this->getConfiguration();
2593            $this->preAuthenticationProviders = $this->providerArrayFromSpecs(
2594                PreAuthenticationProvider::class, $conf['preauth']
2595            );
2596        }
2597        return $this->preAuthenticationProviders;
2598    }
2599
2600    /**
2601     * Get the list of PrimaryAuthenticationProviders
2602     * @return PrimaryAuthenticationProvider[]
2603     */
2604    protected function getPrimaryAuthenticationProviders() {
2605        if ( $this->primaryAuthenticationProviders === null ) {
2606            $conf = $this->getConfiguration();
2607            $this->primaryAuthenticationProviders = $this->providerArrayFromSpecs(
2608                PrimaryAuthenticationProvider::class, $conf['primaryauth']
2609            );
2610        }
2611        return $this->primaryAuthenticationProviders;
2612    }
2613
2614    /**
2615     * Get the list of SecondaryAuthenticationProviders
2616     * @return SecondaryAuthenticationProvider[]
2617     */
2618    protected function getSecondaryAuthenticationProviders() {
2619        if ( $this->secondaryAuthenticationProviders === null ) {
2620            $conf = $this->getConfiguration();
2621            $this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs(
2622                SecondaryAuthenticationProvider::class, $conf['secondaryauth']
2623            );
2624        }
2625        return $this->secondaryAuthenticationProviders;
2626    }
2627
2628    /**
2629     * Log the user in
2630     * @param User $user
2631     * @param bool|null $remember
2632     */
2633    private function setSessionDataForUser( $user, $remember = null ) {
2634        $session = $this->request->getSession();
2635        $delay = $session->delaySave();
2636
2637        $session->resetId();
2638        $session->resetAllTokens();
2639        if ( $session->canSetUser() ) {
2640            $session->setUser( $user );
2641        }
2642        if ( $remember !== null ) {
2643            $session->setRememberUser( $remember );
2644        }
2645        $session->set( 'AuthManager:lastAuthId', $user->getId() );
2646        $session->set( 'AuthManager:lastAuthTimestamp', time() );
2647        $session->persist();
2648
2649        \Wikimedia\ScopedCallback::consume( $delay );
2650
2651        $this->getHookRunner()->onUserLoggedIn( $user );
2652    }
2653
2654    /**
2655     * @param User $user
2656     * @param bool $useContextLang Use 'uselang' to set the user's language
2657     */
2658    private function setDefaultUserOptions( User $user, $useContextLang ) {
2659        $user->setToken();
2660
2661        $lang = $useContextLang ? RequestContext::getMain()->getLanguage() : $this->contentLanguage;
2662        $this->userOptionsManager->setOption(
2663            $user,
2664            'language',
2665            $this->languageConverterFactory->getLanguageConverter( $lang )->getPreferredVariant()
2666        );
2667
2668        $contLangConverter = $this->languageConverterFactory->getLanguageConverter( $this->contentLanguage );
2669        if ( $contLangConverter->hasVariants() ) {
2670            $this->userOptionsManager->setOption(
2671                $user,
2672                'variant',
2673                $contLangConverter->getPreferredVariant()
2674            );
2675        }
2676    }
2677
2678    /**
2679     * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary
2680     * @param string $method
2681     * @param array $args
2682     */
2683    private function callMethodOnProviders( $which, $method, array $args ) {
2684        $providers = [];
2685        if ( $which & 1 ) {
2686            $providers += $this->getPreAuthenticationProviders();
2687        }
2688        if ( $which & 2 ) {
2689            $providers += $this->getPrimaryAuthenticationProviders();
2690        }
2691        if ( $which & 4 ) {
2692            $providers += $this->getSecondaryAuthenticationProviders();
2693        }
2694        foreach ( $providers as $provider ) {
2695            $provider->$method( ...$args );
2696        }
2697    }
2698
2699    /**
2700     * @return HookContainer
2701     */
2702    private function getHookContainer() {
2703        return $this->hookContainer;
2704    }
2705
2706    /**
2707     * @return HookRunner
2708     */
2709    private function getHookRunner() {
2710        return $this->hookRunner;
2711    }
2712
2713    // endregion -- end of Internal methods
2714
2715}
2716
2717/*
2718 * This file uses VisualStudio style region/endregion fold markers which are
2719 * recognised by PHPStorm. If modelines are enabled, the following editor
2720 * configuration will also enable folding in vim, if it is in the last 5 lines
2721 * of the file. We also use "@name" which creates sections in Doxygen.
2722 *
2723 * vim: foldmarker=//\ region,//\ endregion foldmethod=marker
2724 */