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