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