3 * Authentication (and possibly Authorization in the future) system entry point
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
24 namespace MediaWiki\Auth
;
26 use InvalidArgumentException
;
28 use MediaWiki\Auth\Hook\AuthManagerVerifyAuthenticationHook
;
29 use MediaWiki\Block\BlockManager
;
30 use MediaWiki\Config\Config
;
31 use MediaWiki\Context\RequestContext
;
32 use MediaWiki\Deferred\DeferredUpdates
;
33 use MediaWiki\Deferred\SiteStatsUpdate
;
34 use MediaWiki\HookContainer\HookContainer
;
35 use MediaWiki\HookContainer\HookRunner
;
36 use MediaWiki\Language\Language
;
37 use MediaWiki\Languages\LanguageConverterFactory
;
38 use MediaWiki\MainConfigNames
;
39 use MediaWiki\MediaWikiServices
;
40 use MediaWiki\Page\PageIdentity
;
41 use MediaWiki\Permissions\Authority
;
42 use MediaWiki\Permissions\PermissionStatus
;
43 use MediaWiki\Request\WebRequest
;
44 use MediaWiki\SpecialPage\SpecialPage
;
45 use MediaWiki\Status\Status
;
46 use MediaWiki\StubObject\StubGlobalUser
;
47 use MediaWiki\User\BotPasswordStore
;
48 use MediaWiki\User\Options\UserOptionsManager
;
49 use MediaWiki\User\TempUser\TempUserCreator
;
50 use MediaWiki\User\User
;
51 use MediaWiki\User\UserFactory
;
52 use MediaWiki\User\UserIdentity
;
53 use MediaWiki\User\UserIdentityLookup
;
54 use MediaWiki\User\UserNameUtils
;
55 use MediaWiki\User\UserRigorOptions
;
56 use MediaWiki\Watchlist\WatchlistManager
;
57 use MWExceptionHandler
;
58 use Psr\Log\LoggerAwareInterface
;
59 use Psr\Log\LoggerInterface
;
60 use Psr\Log\NullLogger
;
62 use Wikimedia\NormalizedException\NormalizedException
;
63 use Wikimedia\ObjectFactory\ObjectFactory
;
64 use Wikimedia\Rdbms\IDBAccessObject
;
65 use Wikimedia\Rdbms\ILoadBalancer
;
66 use Wikimedia\Rdbms\ReadOnlyMode
;
67 use Wikimedia\ScopedCallback
;
70 * This serves as the entry point to the authentication system.
72 * In the future, it may also serve as the entry point to the authorization
75 * If you are looking at this because you are working on an extension that creates its own
76 * login or signup page, then 1) you really shouldn't do that, 2) if you feel you absolutely
77 * have to, subclass AuthManagerSpecialPage or build it on the client side using the clientlogin
78 * or the createaccount API. Trying to call this class directly will very likely end up in
79 * security vulnerabilities or broken UX in edge cases.
81 * If you are working on an extension that needs to integrate with the authentication system
82 * (e.g. by providing a new login method, or doing extra permission checks), you'll probably
83 * need to write an AuthenticationProvider.
85 * If you want to create a "reserved" user programmatically, User::newSystemUser() might be what
86 * you are looking for. If you want to change user data, use User::changeAuthenticationData().
87 * Code that is related to some SessionProvider or PrimaryAuthenticationProvider can
88 * create a (non-reserved) user by calling AuthManager::autoCreateUser(); it is then the provider's
89 * responsibility to ensure that the user can authenticate somehow (see especially
90 * PrimaryAuthenticationProvider::autoCreatedAccount()). The same functionality can also be used
91 * from Maintenance scripts such as createAndPromote.php.
92 * If you are writing code that is not associated with such a provider and needs to create accounts
93 * programmatically for real users, you should rethink your architecture. There is no good way to
94 * do that as such code has no knowledge of what authentication methods are enabled on the wiki and
95 * cannot provide any means for users to access the accounts it would create.
97 * The two main control flows when using this class are as follows:
98 * * Login, user creation or account linking code will call getAuthenticationRequests(), populate
99 * the requests with data (by using them to build a HTMLForm and have the user fill it, or by
100 * exposing a form specification via the API, so that the client can build it), and pass them to
101 * the appropriate begin* method. That will return either a success/failure response, or more
102 * requests to fill (either by building a form or by redirecting the user to some external
103 * provider which will send the data back), in which case they need to be submitted to the
104 * appropriate continue* method and that step has to be repeated until the response is a success
105 * or failure response. AuthManager will use the session to maintain internal state during the
107 * * Code doing an authentication data change will call getAuthenticationRequests(), select
108 * a single request, populate it, and pass it to allowsAuthenticationDataChange() and then
109 * changeAuthenticationData(). If the data change is user-initiated, the whole process needs
110 * to be preceded by a call to securitySensitiveOperationStatus() and aborted if that returns
115 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
117 class AuthManager
implements LoggerAwareInterface
{
120 * Key in the user's session data for storing login state.
122 public const AUTHN_STATE
= 'AuthManager::authnState';
126 * Key in the user's session data for storing account creation state.
128 public const ACCOUNT_CREATION_STATE
= 'AuthManager::accountCreationState';
132 * Key in the user's session data for storing account linking state.
134 public const ACCOUNT_LINK_STATE
= 'AuthManager::accountLinkState';
138 * Key in the user's session data for storing autocreation failures,
139 * to avoid re-attempting expensive autocreation checks on every request.
141 public const AUTOCREATE_BLOCKLIST
= 'AuthManager::AutoCreateBlacklist';
143 /** Log in with an existing (not necessarily local) user */
144 public const ACTION_LOGIN
= 'login';
145 /** Continue a login process that was interrupted by the need for user input or communication
146 * with an external provider
148 public const ACTION_LOGIN_CONTINUE
= 'login-continue';
149 /** Create a new user */
150 public const ACTION_CREATE
= 'create';
151 /** Continue a user creation process that was interrupted by the need for user input or
152 * communication with an external provider
154 public const ACTION_CREATE_CONTINUE
= 'create-continue';
155 /** Link an existing user to a third-party account */
156 public const ACTION_LINK
= 'link';
157 /** Continue a user linking process that was interrupted by the need for user input or
158 * communication with an external provider
160 public const ACTION_LINK_CONTINUE
= 'link-continue';
161 /** Change a user's credentials */
162 public const ACTION_CHANGE
= 'change';
163 /** Remove a user's credentials */
164 public const ACTION_REMOVE
= 'remove';
165 /** Like ACTION_REMOVE but for linking providers only */
166 public const ACTION_UNLINK
= 'unlink';
168 /** Security-sensitive operations are ok. */
169 public const SEC_OK
= 'ok';
170 /** Security-sensitive operations should re-authenticate. */
171 public const SEC_REAUTH
= 'reauth';
172 /** Security-sensitive should not be performed. */
173 public const SEC_FAIL
= 'fail';
175 /** Auto-creation is due to SessionManager */
176 public const AUTOCREATE_SOURCE_SESSION
= \MediaWiki\Session\SessionManager
::class;
178 /** Auto-creation is due to a Maintenance script */
179 public const AUTOCREATE_SOURCE_MAINT
= '::Maintenance::';
181 /** Auto-creation is due to temporary account creation on page save */
182 public const AUTOCREATE_SOURCE_TEMP
= TempUserCreator
::class;
185 * @internal To be used by primary authentication providers only.
186 * @var string "Remember me" status flag shared between auth providers
188 public const REMEMBER_ME
= 'rememberMe';
190 /** Call pre-authentication providers */
191 private const CALL_PRE
= 1;
193 /** Call primary authentication providers */
194 private const CALL_PRIMARY
= 2;
196 /** Call secondary authentication providers */
197 private const CALL_SECONDARY
= 4;
199 /** Call all authentication providers */
200 private const CALL_ALL
= self
::CALL_PRE | self
::CALL_PRIMARY | self
::CALL_SECONDARY
;
202 /** @var AuthenticationProvider[] */
203 private $allAuthenticationProviders = [];
205 /** @var PreAuthenticationProvider[] */
206 private $preAuthenticationProviders = null;
208 /** @var PrimaryAuthenticationProvider[] */
209 private $primaryAuthenticationProviders = null;
211 /** @var SecondaryAuthenticationProvider[] */
212 private $secondaryAuthenticationProviders = null;
214 /** @var CreatedAccountAuthenticationRequest[] */
215 private $createdAccountAuthenticationRequests = [];
217 private WebRequest
$request;
218 private Config
$config;
219 private ObjectFactory
$objectFactory;
220 private LoggerInterface
$logger;
221 private UserNameUtils
$userNameUtils;
222 private HookContainer
$hookContainer;
223 private HookRunner
$hookRunner;
224 private ReadOnlyMode
$readOnlyMode;
225 private BlockManager
$blockManager;
226 private WatchlistManager
$watchlistManager;
227 private ILoadBalancer
$loadBalancer;
228 private Language
$contentLanguage;
229 private LanguageConverterFactory
$languageConverterFactory;
230 private BotPasswordStore
$botPasswordStore;
231 private UserFactory
$userFactory;
232 private UserIdentityLookup
$userIdentityLookup;
233 private UserOptionsManager
$userOptionsManager;
235 public function __construct(
238 ObjectFactory
$objectFactory,
239 HookContainer
$hookContainer,
240 ReadOnlyMode
$readOnlyMode,
241 UserNameUtils
$userNameUtils,
242 BlockManager
$blockManager,
243 WatchlistManager
$watchlistManager,
244 ILoadBalancer
$loadBalancer,
245 Language
$contentLanguage,
246 LanguageConverterFactory
$languageConverterFactory,
247 BotPasswordStore
$botPasswordStore,
248 UserFactory
$userFactory,
249 UserIdentityLookup
$userIdentityLookup,
250 UserOptionsManager
$userOptionsManager
252 $this->request
= $request;
253 $this->config
= $config;
254 $this->objectFactory
= $objectFactory;
255 $this->hookContainer
= $hookContainer;
256 $this->hookRunner
= new HookRunner( $hookContainer );
257 $this->setLogger( new NullLogger() );
258 $this->readOnlyMode
= $readOnlyMode;
259 $this->userNameUtils
= $userNameUtils;
260 $this->blockManager
= $blockManager;
261 $this->watchlistManager
= $watchlistManager;
262 $this->loadBalancer
= $loadBalancer;
263 $this->contentLanguage
= $contentLanguage;
264 $this->languageConverterFactory
= $languageConverterFactory;
265 $this->botPasswordStore
= $botPasswordStore;
266 $this->userFactory
= $userFactory;
267 $this->userIdentityLookup
= $userIdentityLookup;
268 $this->userOptionsManager
= $userOptionsManager;
271 public function setLogger( LoggerInterface
$logger ) {
272 $this->logger
= $logger;
278 public function getRequest() {
279 return $this->request
;
283 * Force certain PrimaryAuthenticationProviders
285 * @deprecated since 1.43; for backwards compatibility only
286 * @param PrimaryAuthenticationProvider[] $providers
289 public function forcePrimaryAuthenticationProviders( array $providers, $why ) {
290 wfDeprecated( __METHOD__
, '1.43' );
292 $this->logger
->warning( "Overriding AuthManager primary authn because $why" );
294 if ( $this->primaryAuthenticationProviders
!== null ) {
295 $this->logger
->warning(
296 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
299 $this->allAuthenticationProviders
= array_diff_key(
300 $this->allAuthenticationProviders
,
301 $this->primaryAuthenticationProviders
303 $session = $this->request
->getSession();
304 $session->remove( self
::AUTHN_STATE
);
305 $session->remove( self
::ACCOUNT_CREATION_STATE
);
306 $session->remove( self
::ACCOUNT_LINK_STATE
);
307 $this->createdAccountAuthenticationRequests
= [];
310 $this->primaryAuthenticationProviders
= [];
311 foreach ( $providers as $provider ) {
312 if ( !$provider instanceof AbstractPrimaryAuthenticationProvider
) {
313 throw new \
RuntimeException(
314 'Expected instance of MediaWiki\\Auth\\AbstractPrimaryAuthenticationProvider, got ' .
315 get_class( $provider )
318 $provider->init( $this->logger
, $this, $this->hookContainer
, $this->config
, $this->userNameUtils
);
319 $id = $provider->getUniqueId();
320 if ( isset( $this->allAuthenticationProviders
[$id] ) ) {
321 throw new \
RuntimeException(
322 "Duplicate specifications for id $id (classes " .
323 get_class( $provider ) . ' and ' .
324 get_class( $this->allAuthenticationProviders
[$id] ) . ')'
327 $this->allAuthenticationProviders
[$id] = $provider;
328 $this->primaryAuthenticationProviders
[$id] = $provider;
332 /***************************************************************************/
333 // region Authentication
334 /** @name Authentication */
337 * Indicate whether user authentication is possible
339 * It may not be if the session is provided by something like OAuth
340 * for which each individual request includes authentication data.
344 public function canAuthenticateNow() {
345 return $this->request
->getSession()->canSetUser();
349 * Start an authentication flow
351 * In addition to the AuthenticationRequests returned by
352 * $this->getAuthenticationRequests(), a client might include a
353 * CreateFromLoginAuthenticationRequest from a previous login attempt to
356 * Instead of the AuthenticationRequests returned by
357 * $this->getAuthenticationRequests(), a client might pass a
358 * CreatedAccountAuthenticationRequest from an account creation that just
359 * succeeded to log in to the just-created account.
361 * @param AuthenticationRequest[] $reqs
362 * @param string $returnToUrl Url that REDIRECT responses should eventually
364 * @return AuthenticationResponse See self::continueAuthentication()
366 public function beginAuthentication( array $reqs, $returnToUrl ) {
367 $session = $this->request
->getSession();
368 if ( !$session->canSetUser() ) {
369 // Caller should have called canAuthenticateNow()
370 $session->remove( self
::AUTHN_STATE
);
371 throw new LogicException( 'Authentication is not possible now' );
374 $guessUserName = null;
375 foreach ( $reqs as $req ) {
376 $req->returnToUrl
= $returnToUrl;
377 // @codeCoverageIgnoreStart
378 if ( $req->username
!== null && $req->username
!== '' ) {
379 if ( $guessUserName === null ) {
380 $guessUserName = $req->username
;
381 } elseif ( $guessUserName !== $req->username
) {
382 $guessUserName = null;
386 // @codeCoverageIgnoreEnd
389 // Check for special-case login of a just-created account
390 $req = AuthenticationRequest
::getRequestByClass(
391 $reqs, CreatedAccountAuthenticationRequest
::class
394 if ( !in_array( $req, $this->createdAccountAuthenticationRequests
, true ) ) {
395 throw new LogicException(
396 'CreatedAccountAuthenticationRequests are only valid on ' .
397 'the same AuthManager that created the account'
401 $user = $this->userFactory
->newFromName( (string)$req->username
);
402 // @codeCoverageIgnoreStart
404 throw new \
UnexpectedValueException(
405 "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\""
407 } elseif ( $user->getId() != $req->id
) {
408 throw new \
UnexpectedValueException(
409 "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}"
412 // @codeCoverageIgnoreEnd
414 $this->logger
->info( 'Logging in {user} after account creation', [
415 'user' => $user->getName(),
417 $ret = AuthenticationResponse
::newPass( $user->getName() );
418 $performer = $session->getUser();
419 $this->setSessionDataForUser( $user );
420 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication', [ $user, $ret ] );
421 $session->remove( self
::AUTHN_STATE
);
422 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
423 $ret, $user, $user->getName(), [
424 'performer' => $performer
429 $this->removeAuthenticationSessionData( null );
431 foreach ( $this->getPreAuthenticationProviders() as $provider ) {
432 $status = $provider->testForAuthentication( $reqs );
433 if ( !$status->isGood() ) {
434 $this->logger
->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() );
435 $ret = AuthenticationResponse
::newFail(
436 Status
::wrap( $status )->getMessage()
438 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication',
439 [ $this->userFactory
->newFromName( (string)$guessUserName ), $ret ]
441 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit( $ret, null, $guessUserName, [
442 'performer' => $session->getUser()
450 'returnToUrl' => $returnToUrl,
451 'guessUserName' => $guessUserName,
452 'providerIds' => $this->getProviderIds(),
454 'primaryResponse' => null,
457 'continueRequests' => [],
460 // Preserve state from a previous failed login
461 $req = AuthenticationRequest
::getRequestByClass(
462 $reqs, CreateFromLoginAuthenticationRequest
::class
465 $state['maybeLink'] = $req->maybeLink
;
468 $session = $this->request
->getSession();
469 $session->setSecret( self
::AUTHN_STATE
, $state );
472 return $this->continueAuthentication( $reqs );
476 * Continue an authentication flow
478 * Return values are interpreted as follows:
479 * - status FAIL: Authentication failed. If $response->createRequest is
480 * set, that may be passed to self::beginAuthentication() or to
481 * self::beginAccountCreation() to preserve state.
482 * - status REDIRECT: The client should be redirected to the contained URL,
483 * new AuthenticationRequests should be made (if any), then
484 * AuthManager::continueAuthentication() should be called.
485 * - status UI: The client should be presented with a user interface for
486 * the fields in the specified AuthenticationRequests, then new
487 * AuthenticationRequests should be made, then
488 * AuthManager::continueAuthentication() should be called.
489 * - status RESTART: The user logged in successfully with a third-party
490 * service, but the third-party credentials aren't attached to any local
491 * account. This could be treated as a UI or a FAIL.
492 * - status PASS: Authentication was successful.
494 * @param AuthenticationRequest[] $reqs
495 * @return AuthenticationResponse
497 public function continueAuthentication( array $reqs ) {
498 $session = $this->request
->getSession();
500 if ( !$session->canSetUser() ) {
501 // Caller should have called canAuthenticateNow()
502 // @codeCoverageIgnoreStart
503 throw new LogicException( 'Authentication is not possible now' );
504 // @codeCoverageIgnoreEnd
507 $state = $session->getSecret( self
::AUTHN_STATE
);
508 if ( !is_array( $state ) ) {
509 return AuthenticationResponse
::newFail(
510 wfMessage( 'authmanager-authn-not-in-progress' )
513 if ( $state['providerIds'] !== $this->getProviderIds() ) {
514 // An inconsistent AuthManagerFilterProviders hook, or site configuration changed
515 // while the user was in the middle of authentication. The first is a bug, the
516 // second is rare but expected when deploying a config change. Try handle in a way
517 // that's useful for both cases.
518 // @codeCoverageIgnoreStart
519 MWExceptionHandler
::logException( new NormalizedException(
520 'Authentication failed because of inconsistent provider array',
521 [ 'old' => json_encode( $state['providerIds'] ), 'new' => json_encode( $this->getProviderIds() ) ]
523 $response = AuthenticationResponse
::newFail(
524 wfMessage( 'authmanager-authn-not-in-progress' )
526 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication',
527 [ $this->userFactory
->newFromName( (string)$state['guessUserName'] ), $response ]
529 $session->remove( self
::AUTHN_STATE
);
531 // @codeCoverageIgnoreEnd
533 $state['continueRequests'] = [];
535 $guessUserName = $state['guessUserName'];
537 foreach ( $reqs as $req ) {
538 $req->returnToUrl
= $state['returnToUrl'];
541 // Step 1: Choose a primary authentication provider, and call it until it succeeds.
543 if ( $state['primary'] === null ) {
544 // We haven't picked a PrimaryAuthenticationProvider yet
545 // @codeCoverageIgnoreStart
546 $guessUserName = null;
547 foreach ( $reqs as $req ) {
548 if ( $req->username
!== null && $req->username
!== '' ) {
549 if ( $guessUserName === null ) {
550 $guessUserName = $req->username
;
551 } elseif ( $guessUserName !== $req->username
) {
552 $guessUserName = null;
557 $state['guessUserName'] = $guessUserName;
558 // @codeCoverageIgnoreEnd
559 $state['reqs'] = $reqs;
561 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
562 $res = $provider->beginPrimaryAuthentication( $reqs );
563 switch ( $res->status
) {
564 case AuthenticationResponse
::PASS
:
565 $state['primary'] = $id;
566 $state['primaryResponse'] = $res;
567 $this->logger
->debug( "Primary login with $id succeeded" );
569 case AuthenticationResponse
::FAIL
:
570 $this->logger
->debug( "Login failed in primary authentication by $id" );
571 if ( $res->createRequest ||
$state['maybeLink'] ) {
572 $res->createRequest
= new CreateFromLoginAuthenticationRequest(
573 $res->createRequest
, $state['maybeLink']
576 $this->callMethodOnProviders(
578 'postAuthentication',
580 $this->userFactory
->newFromName( (string)$guessUserName ),
584 $session->remove( self
::AUTHN_STATE
);
585 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
586 $res, null, $guessUserName, [
587 'performer' => $session->getUser()
590 case AuthenticationResponse
::ABSTAIN
:
593 case AuthenticationResponse
::REDIRECT
:
594 case AuthenticationResponse
::UI
:
595 $this->logger
->debug( "Primary login with $id returned $res->status" );
596 $this->fillRequests( $res->neededRequests
, self
::ACTION_LOGIN
, $guessUserName );
597 $state['primary'] = $id;
598 $state['continueRequests'] = $res->neededRequests
;
599 $session->setSecret( self
::AUTHN_STATE
, $state );
602 // @codeCoverageIgnoreStart
604 throw new \
DomainException(
605 get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status"
607 // @codeCoverageIgnoreEnd
610 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
611 if ( $state['primary'] === null ) {
612 $this->logger
->debug( 'Login failed in primary authentication because no provider accepted' );
613 $response = AuthenticationResponse
::newFail(
614 wfMessage( 'authmanager-authn-no-primary' )
616 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication',
617 [ $this->userFactory
->newFromName( (string)$guessUserName ), $response ]
619 $session->remove( self
::AUTHN_STATE
);
622 } elseif ( $state['primaryResponse'] === null ) {
623 $provider = $this->getAuthenticationProvider( $state['primary'] );
624 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
625 // Configuration changed? Force them to start over.
626 // @codeCoverageIgnoreStart
627 $response = AuthenticationResponse
::newFail(
628 wfMessage( 'authmanager-authn-not-in-progress' )
630 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication',
631 [ $this->userFactory
->newFromName( (string)$guessUserName ), $response ]
633 $session->remove( self
::AUTHN_STATE
);
635 // @codeCoverageIgnoreEnd
637 $id = $provider->getUniqueId();
638 $res = $provider->continuePrimaryAuthentication( $reqs );
639 switch ( $res->status
) {
640 case AuthenticationResponse
::PASS
:
641 $state['primaryResponse'] = $res;
642 $this->logger
->debug( "Primary login with $id succeeded" );
644 case AuthenticationResponse
::FAIL
:
645 $this->logger
->debug( "Login failed in primary authentication by $id" );
646 if ( $res->createRequest ||
$state['maybeLink'] ) {
647 $res->createRequest
= new CreateFromLoginAuthenticationRequest(
648 $res->createRequest
, $state['maybeLink']
651 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication',
652 [ $this->userFactory
->newFromName( (string)$guessUserName ), $res ]
654 $session->remove( self
::AUTHN_STATE
);
655 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
656 $res, null, $guessUserName, [
657 'performer' => $session->getUser()
660 case AuthenticationResponse
::REDIRECT
:
661 case AuthenticationResponse
::UI
:
662 $this->logger
->debug( "Primary login with $id returned $res->status" );
663 $this->fillRequests( $res->neededRequests
, self
::ACTION_LOGIN
, $guessUserName );
664 $state['continueRequests'] = $res->neededRequests
;
665 $session->setSecret( self
::AUTHN_STATE
, $state );
668 throw new \
DomainException(
669 get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status"
674 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
675 $res = $state['primaryResponse'];
676 if ( $res->username
=== null ) {
677 $provider = $this->getAuthenticationProvider( $state['primary'] );
678 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
679 // Configuration changed? Force them to start over.
680 // @codeCoverageIgnoreStart
681 $response = AuthenticationResponse
::newFail(
682 wfMessage( 'authmanager-authn-not-in-progress' )
684 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication',
685 [ $this->userFactory
->newFromName( (string)$guessUserName ), $response ]
687 $session->remove( self
::AUTHN_STATE
);
689 // @codeCoverageIgnoreEnd
692 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
&&
694 // don't confuse the user with an incorrect message if linking is disabled
695 $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider
::class )
697 $state['maybeLink'][$res->linkRequest
->getUniqueId()] = $res->linkRequest
;
698 $msg = 'authmanager-authn-no-local-user-link';
700 $msg = 'authmanager-authn-no-local-user';
702 $this->logger
->debug(
703 "Primary login with {$provider->getUniqueId()} succeeded, but returned no user"
705 $response = AuthenticationResponse
::newRestart( wfMessage( $msg ) );
706 $response->neededRequests
= $this->getAuthenticationRequestsInternal(
709 $this->getPrimaryAuthenticationProviders() +
$this->getSecondaryAuthenticationProviders()
711 if ( $res->createRequest ||
$state['maybeLink'] ) {
712 $response->createRequest
= new CreateFromLoginAuthenticationRequest(
713 $res->createRequest
, $state['maybeLink']
715 $response->neededRequests
[] = $response->createRequest
;
717 $this->fillRequests( $response->neededRequests
, self
::ACTION_LOGIN
, null, true );
718 $session->setSecret( self
::AUTHN_STATE
, [
719 'reqs' => [], // Will be filled in later
721 'primaryResponse' => null,
723 'continueRequests' => $response->neededRequests
,
726 // Give the AuthManagerVerifyAuthentication hook a chance to interrupt - even though
727 // RESTART does not immediately result in a successful login, the response and session
728 // state can hold information identifying a (remote) user, and that could be turned
729 // into access to that user's account in a follow-up request.
730 if ( !$this->runVerifyHook( self
::ACTION_LOGIN
, null, $response, $state['primary'] ) ) {
731 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication', [ null, $response ] );
732 $session->remove( self
::AUTHN_STATE
);
733 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
734 $response, null, null, [ 'performer' => $session->getUser() ]
742 // Step 2: Primary authentication succeeded, create the User object
743 // (and add the user locally if necessary)
745 $user = $this->userFactory
->newFromName(
746 (string)$res->username
,
747 UserRigorOptions
::RIGOR_USABLE
750 $provider = $this->getAuthenticationProvider( $state['primary'] );
751 throw new \
DomainException(
752 get_class( $provider ) . " returned an invalid username: {$res->username}"
755 if ( !$user->isRegistered() ) {
756 // User doesn't exist locally. Create it.
757 $this->logger
->info( 'Auto-creating {user} on login', [
758 'user' => $user->getName(),
760 // Also use $user as performer, because the performer will be used for permission
761 // checks and global rights extensions might add rights based on the username,
762 // even if the user doesn't exist at this point.
763 $status = $this->autoCreateUser( $user, $state['primary'], false, true, $user );
764 if ( !$status->isGood() ) {
765 $response = AuthenticationResponse
::newFail(
766 Status
::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' )
768 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication', [ $user, $response ] );
769 $session->remove( self
::AUTHN_STATE
);
770 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
771 $response, $user, $user->getName(), [
772 'performer' => $session->getUser()
778 // Step 3: Iterate over all the secondary authentication providers.
780 $beginReqs = $state['reqs'];
782 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
783 if ( !isset( $state['secondary'][$id] ) ) {
784 // This provider isn't started yet, so we pass it the set
785 // of reqs from beginAuthentication instead of whatever
786 // might have been used by a previous provider in line.
787 $func = 'beginSecondaryAuthentication';
788 $res = $provider->beginSecondaryAuthentication( $user, $beginReqs );
789 } elseif ( !$state['secondary'][$id] ) {
790 $func = 'continueSecondaryAuthentication';
791 $res = $provider->continueSecondaryAuthentication( $user, $reqs );
795 switch ( $res->status
) {
796 case AuthenticationResponse
::PASS
:
797 $this->logger
->debug( "Secondary login with $id succeeded" );
799 case AuthenticationResponse
::ABSTAIN
:
800 $state['secondary'][$id] = true;
802 case AuthenticationResponse
::FAIL
:
803 $this->logger
->debug( "Login failed in secondary authentication by $id" );
804 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication', [ $user, $res ] );
805 $session->remove( self
::AUTHN_STATE
);
806 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
807 $res, $user, $user->getName(), [
808 'performer' => $session->getUser()
811 case AuthenticationResponse
::REDIRECT
:
812 case AuthenticationResponse
::UI
:
813 $this->logger
->debug( "Secondary login with $id returned " . $res->status
);
814 $this->fillRequests( $res->neededRequests
, self
::ACTION_LOGIN
, $user->getName() );
815 $state['secondary'][$id] = false;
816 $state['continueRequests'] = $res->neededRequests
;
817 $session->setSecret( self
::AUTHN_STATE
, $state );
820 // @codeCoverageIgnoreStart
822 throw new \
DomainException(
823 get_class( $provider ) . "::{$func}() returned $res->status"
825 // @codeCoverageIgnoreEnd
829 // Step 4: Authentication complete! Give hook handlers a chance to interrupt, then
830 // set the user in the session and clean up.
832 $response = AuthenticationResponse
::newPass( $user->getName() );
833 if ( !$this->runVerifyHook( self
::ACTION_LOGIN
, $user, $response, $state['primary'] ) ) {
834 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication', [ $user, $response ] );
835 $session->remove( self
::AUTHN_STATE
);
836 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
837 $response, $user, $user->getName(), [
838 'performer' => $session->getUser(),
842 $this->logger
->info( 'Login for {user} succeeded from {clientip}', [
843 'user' => $user->getName(),
844 'clientip' => $this->request
->getIP(),
846 $rememberMeConfig = $this->config
->get( MainConfigNames
::RememberMe
);
847 if ( $rememberMeConfig === RememberMeAuthenticationRequest
::ALWAYS_REMEMBER
) {
849 } elseif ( $rememberMeConfig === RememberMeAuthenticationRequest
::NEVER_REMEMBER
) {
852 /** @var RememberMeAuthenticationRequest $req */
853 $req = AuthenticationRequest
::getRequestByClass(
854 $beginReqs, RememberMeAuthenticationRequest
::class
857 // T369668: Before we conclude, let's make sure the user hasn't specified
858 // that they want their login remembered elsewhere like in the central domain.
859 // If the user clicked "remember me" in the central domain, then we should
860 // prioritise that when we call continuePrimaryAuthentication() in the provider
861 // that makes calls continuePrimaryAuthentication(). NOTE: It is the responsibility
862 // of the provider to refresh the "remember me" state that will be applied to
864 $rememberMe = ( $req && $req->rememberMe
) ||
865 $this->getAuthenticationSessionData( self
::REMEMBER_ME
);
867 $this->setSessionDataForUser( $user, $rememberMe );
868 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAuthentication', [ $user, $response ] );
869 $performer = $session->getUser();
870 $session->remove( self
::AUTHN_STATE
);
871 $this->removeAuthenticationSessionData( null );
872 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
873 $response, $user, $user->getName(), [
874 'performer' => $performer
877 } catch ( \Exception
$ex ) {
878 $session->remove( self
::AUTHN_STATE
);
884 * Whether security-sensitive operations should proceed.
886 * A "security-sensitive operation" is something like a password or email
887 * change, that would normally have a "reenter your password to confirm"
888 * box if we only supported password-based authentication.
890 * @param string $operation Operation being checked. This should be a
891 * message-key-like string such as 'change-password' or 'change-email'.
892 * @return string One of the SEC_* constants.
894 public function securitySensitiveOperationStatus( $operation ) {
895 $status = self
::SEC_OK
;
897 $this->logger
->debug( __METHOD__
. ": Checking $operation" );
899 $session = $this->request
->getSession();
900 $aId = $session->getUser()->getId();
902 // User isn't authenticated. DWIM?
903 $status = $this->canAuthenticateNow() ? self
::SEC_REAUTH
: self
::SEC_FAIL
;
904 $this->logger
->info( __METHOD__
. ": Not logged in! $operation is $status" );
908 if ( $session->canSetUser() ) {
909 $id = $session->get( 'AuthManager:lastAuthId' );
910 $last = $session->get( 'AuthManager:lastAuthTimestamp' );
911 if ( $id !== $aId ||
$last === null ) {
912 $timeSinceLogin = PHP_INT_MAX
; // Forever ago
914 $timeSinceLogin = max( 0, time() - $last );
917 $thresholds = $this->config
->get( MainConfigNames
::ReauthenticateTime
);
918 if ( isset( $thresholds[$operation] ) ) {
919 $threshold = $thresholds[$operation];
920 } elseif ( isset( $thresholds['default'] ) ) {
921 $threshold = $thresholds['default'];
923 throw new \
UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
926 if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
927 $status = self
::SEC_REAUTH
;
930 $timeSinceLogin = -1;
932 $pass = $this->config
->get(
933 MainConfigNames
::AllowSecuritySensitiveOperationIfCannotReauthenticate
);
934 if ( isset( $pass[$operation] ) ) {
935 $status = $pass[$operation] ? self
::SEC_OK
: self
::SEC_FAIL
;
936 } elseif ( isset( $pass['default'] ) ) {
937 $status = $pass['default'] ? self
::SEC_OK
: self
::SEC_FAIL
;
939 throw new \
UnexpectedValueException(
940 '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default'
945 $this->getHookRunner()->onSecuritySensitiveOperationStatus(
946 $status, $operation, $session, $timeSinceLogin );
948 // If authentication is not possible, downgrade from "REAUTH" to "FAIL".
949 if ( !$this->canAuthenticateNow() && $status === self
::SEC_REAUTH
) {
950 $status = self
::SEC_FAIL
;
953 $this->logger
->info( __METHOD__
. ": $operation is $status for '{user}'",
955 'user' => $session->getUser()->getName(),
956 'clientip' => $this->getRequest()->getIP(),
964 * Determine whether a username can authenticate
966 * This is mainly for internal purposes and only takes authentication data into account,
967 * not things like blocks that can change without the authentication system being aware.
969 * @param string $username MediaWiki username
972 public function userCanAuthenticate( $username ) {
973 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
974 if ( $provider->testUserCanAuthenticate( $username ) ) {
982 * Provide normalized versions of the username for security checks
984 * Since different providers can normalize the input in different ways,
985 * this returns an array of all the different ways the name might be
986 * normalized for authentication.
988 * The returned strings should not be revealed to the user, as that might
989 * leak private information (e.g. an email address might be normalized to a
992 * @param string $username
995 public function normalizeUsername( $username ) {
997 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
998 $normalized = $provider->providerNormalizeUsername( $username );
999 if ( $normalized !== null ) {
1000 $ret[$normalized] = true;
1003 return array_keys( $ret );
1007 * Call this method to set the request context user for the current request
1008 * from the context session user.
1010 * Useful in cases where we need to make sure that a MediaWiki request outputs
1011 * correct context data for a user who has just been logged-in.
1013 * The method will also update the global language variable based on the
1014 * session's user's context language.
1016 * This won't affect objects which already made a copy of the user or the
1017 * context, so it shouldn't be relied on too heavily, but can help to make the
1018 * UI more consistent after changing the user. Typically used after a successful
1019 * AuthManager action that changed the session user (e.g.
1020 * AuthManager::autoCreateUser() with the login flag set).
1022 public function setRequestContextUserFromSessionUser(): void
{
1023 $context = RequestContext
::getMain();
1024 $user = $context->getRequest()->getSession()->getUser();
1026 StubGlobalUser
::setUser( $user );
1027 $context->setUser( $user );
1029 // phpcs:ignore MediaWiki.Usage.ExtendClassUsage.FunctionVarUsage
1031 // phpcs:ignore MediaWiki.Usage.ExtendClassUsage.FunctionVarUsage
1032 $wgLang = $context->getLanguage();
1035 // endregion -- end of Authentication
1037 /***************************************************************************/
1038 // region Authentication data changing
1039 /** @name Authentication data changing */
1042 * Revoke any authentication credentials for a user
1044 * After this, the user should no longer be able to log in.
1046 * @param string $username
1048 public function revokeAccessForUser( $username ) {
1049 $this->logger
->info( 'Revoking access for {user}', [
1050 'user' => $username,
1052 $this->callMethodOnProviders( self
::CALL_PRIMARY | self
::CALL_SECONDARY
, 'providerRevokeAccessForUser',
1058 * Validate a change of authentication data (e.g. passwords)
1059 * @param AuthenticationRequest $req
1060 * @param bool $checkData If false, $req hasn't been loaded from the
1061 * submission so checks on user-submitted fields should be skipped. $req->username is
1062 * considered user-submitted for this purpose, even if it cannot be changed via
1063 * $req->loadFromSubmission.
1066 public function allowsAuthenticationDataChange( AuthenticationRequest
$req, $checkData = true ) {
1068 $providers = $this->getPrimaryAuthenticationProviders() +
1069 $this->getSecondaryAuthenticationProviders();
1071 foreach ( $providers as $provider ) {
1072 $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData );
1073 if ( !$status->isGood() ) {
1074 // If status is not good because reset email password last attempt was within
1075 // $wgPasswordReminderResendTime then return good status with throttled-mailpassword value;
1076 // otherwise, return the $status wrapped.
1077 return $status->hasMessage( 'throttled-mailpassword' )
1078 ? Status
::newGood( 'throttled-mailpassword' )
1079 : Status
::wrap( $status );
1081 $any = $any ||
$status->value
!== 'ignored';
1084 return Status
::newGood( 'ignored' )
1085 ->warning( 'authmanager-change-not-supported' );
1087 return Status
::newGood();
1091 * Change authentication data (e.g. passwords)
1093 * If $req was returned for AuthManager::ACTION_CHANGE, using $req should
1094 * result in a successful login in the future.
1096 * If $req was returned for AuthManager::ACTION_REMOVE, using $req should
1097 * no longer result in a successful login.
1099 * This method should only be called if allowsAuthenticationDataChange( $req, true )
1102 * @param AuthenticationRequest $req
1103 * @param bool $isAddition Set true if this represents an addition of
1104 * credentials rather than a change. The main difference is that additions
1105 * should not invalidate BotPasswords. If you're not sure, leave it false.
1107 public function changeAuthenticationData( AuthenticationRequest
$req, $isAddition = false ) {
1108 $this->logger
->info( 'Changing authentication data for {user} class {what}', [
1109 'user' => is_string( $req->username
) ?
$req->username
: '<no name>',
1110 'what' => get_class( $req ),
1113 $this->callMethodOnProviders( self
::CALL_PRIMARY | self
::CALL_SECONDARY
, 'providerChangeAuthenticationData',
1117 // When the main account's authentication data is changed, invalidate
1118 // all BotPasswords too.
1119 if ( !$isAddition ) {
1120 $this->botPasswordStore
->invalidateUserPasswords( (string)$req->username
);
1124 // endregion -- end of Authentication data changing
1126 /***************************************************************************/
1127 // region Account creation
1128 /** @name Account creation */
1131 * Determine whether accounts can be created
1134 public function canCreateAccounts() {
1135 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1136 switch ( $provider->accountCreationType() ) {
1137 case PrimaryAuthenticationProvider
::TYPE_CREATE
:
1138 case PrimaryAuthenticationProvider
::TYPE_LINK
:
1146 * Determine whether a particular account can be created
1147 * @param string $username MediaWiki username
1148 * @param array $options
1149 * - flags: (int) Bitfield of IDBAccessObject::READ_* constants, default IDBAccessObject::READ_NORMAL
1150 * - creating: (bool) For internal use only. Never specify this.
1153 public function canCreateAccount( $username, $options = [] ) {
1155 if ( is_int( $options ) ) {
1156 $options = [ 'flags' => $options ];
1159 'flags' => IDBAccessObject
::READ_NORMAL
,
1160 'creating' => false,
1162 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1163 $flags = $options['flags'];
1165 if ( !$this->canCreateAccounts() ) {
1166 return Status
::newFatal( 'authmanager-create-disabled' );
1169 if ( $this->userExists( $username, $flags ) ) {
1170 return Status
::newFatal( 'userexists' );
1173 $user = $this->userFactory
->newFromName( (string)$username, UserRigorOptions
::RIGOR_CREATABLE
);
1174 if ( !is_object( $user ) ) {
1175 return Status
::newFatal( 'noname' );
1177 $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL
1178 if ( $user->isRegistered() ) {
1179 return Status
::newFatal( 'userexists' );
1183 // Denied by providers?
1184 $providers = $this->getPreAuthenticationProviders() +
1185 $this->getPrimaryAuthenticationProviders() +
1186 $this->getSecondaryAuthenticationProviders();
1187 foreach ( $providers as $provider ) {
1188 $status = $provider->testUserForCreation( $user, false, $options );
1189 if ( !$status->isGood() ) {
1190 return Status
::wrap( $status );
1194 return Status
::newGood();
1198 * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
1199 * @param string $action
1200 * @return StatusValue
1202 private function authorizeInternal(
1203 callable
$authorizer,
1206 // Wiki is read-only?
1207 if ( $this->readOnlyMode
->isReadOnly() ) {
1208 return StatusValue
::newFatal( wfMessage( 'readonlytext', $this->readOnlyMode
->getReason() ) );
1211 $permStatus = new PermissionStatus();
1214 SpecialPage
::getTitleFor( 'CreateAccount' ),
1220 $ip = $this->getRequest()->getIP();
1221 if ( $this->blockManager
->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
1222 return StatusValue
::newFatal( 'sorbs_create_account_reason' );
1225 return StatusValue
::newGood();
1229 * Check whether $creator can create accounts.
1231 * @note this method does not guarantee full permissions check, so it should only
1232 * be used to to decide whether to show a form. To authorize the account creation
1233 * action use {@link self::authorizeCreateAccount} instead.
1236 * @param Authority $creator
1237 * @return StatusValue
1239 public function probablyCanCreateAccount( Authority
$creator ): StatusValue
{
1240 return $this->authorizeInternal(
1243 PageIdentity
$target,
1244 PermissionStatus
$status
1245 ) use ( $creator ) {
1246 return $creator->probablyCan( $action, $target, $status );
1253 * Authorize the account creation by $creator
1255 * @note this method should be used right before the account is created.
1256 * To check whether a current performer has the potential to create accounts,
1257 * use {@link self::probablyCanCreateAccount} instead.
1260 * @param Authority $creator
1261 * @return StatusValue
1263 public function authorizeCreateAccount( Authority
$creator ): StatusValue
{
1264 return $this->authorizeInternal(
1267 PageIdentity
$target,
1268 PermissionStatus
$status
1269 ) use ( $creator ) {
1270 return $creator->authorizeWrite( $action, $target, $status );
1277 * Start an account creation flow
1279 * In addition to the AuthenticationRequests returned by
1280 * $this->getAuthenticationRequests(), a client might include a
1281 * CreateFromLoginAuthenticationRequest from a previous login attempt. If
1283 * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE )
1285 * returns true, any AuthenticationRequest::PRIMARY_REQUIRED requests
1286 * should be omitted. If the CreateFromLoginAuthenticationRequest has a
1287 * username set, that username must be used for all other requests.
1289 * @param Authority $creator User doing the account creation
1290 * @param AuthenticationRequest[] $reqs
1291 * @param string $returnToUrl Url that REDIRECT responses should eventually
1293 * @return AuthenticationResponse
1295 public function beginAccountCreation( Authority
$creator, array $reqs, $returnToUrl ) {
1296 $session = $this->request
->getSession();
1297 if ( !$this->canCreateAccounts() ) {
1298 // Caller should have called canCreateAccounts()
1299 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1300 throw new LogicException( 'Account creation is not possible' );
1304 $username = AuthenticationRequest
::getUsernameFromRequests( $reqs );
1305 } catch ( \UnexpectedValueException
$ex ) {
1308 if ( $username === null ) {
1309 $this->logger
->debug( __METHOD__
. ': No username provided' );
1310 return AuthenticationResponse
::newFail( wfMessage( 'noname' ) );
1313 // Permissions check
1314 $status = Status
::wrap( $this->authorizeCreateAccount( $creator ) );
1315 if ( !$status->isGood() ) {
1316 $this->logger
->debug( __METHOD__
. ': {creator} cannot create users: {reason}', [
1317 'user' => $username,
1318 'creator' => $creator->getUser()->getName(),
1319 'reason' => $status->getWikiText( false, false, 'en' )
1321 return AuthenticationResponse
::newFail( $status->getMessage() );
1324 // Avoid deadlocks by placing no shared or exclusive gap locks (T199393)
1325 // As defense in-depth, PrimaryAuthenticationProvider::testUserExists only
1326 // supports READ_NORMAL/READ_LATEST (no support for recency query flags).
1327 $status = $this->canCreateAccount(
1328 $username, [ 'flags' => IDBAccessObject
::READ_LATEST
, 'creating' => true ]
1330 if ( !$status->isGood() ) {
1331 $this->logger
->debug( __METHOD__
. ': {user} cannot be created: {reason}', [
1332 'user' => $username,
1333 'creator' => $creator->getUser()->getName(),
1334 'reason' => $status->getWikiText( false, false, 'en' )
1336 return AuthenticationResponse
::newFail( $status->getMessage() );
1339 $user = $this->userFactory
->newFromName( (string)$username, UserRigorOptions
::RIGOR_CREATABLE
);
1340 foreach ( $reqs as $req ) {
1341 $req->username
= $username;
1342 $req->returnToUrl
= $returnToUrl;
1343 if ( $req instanceof UserDataAuthenticationRequest
) {
1344 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable user should be checked and valid here
1345 $status = $req->populateUser( $user );
1346 if ( !$status->isGood() ) {
1347 $status = Status
::wrap( $status );
1348 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1349 $this->logger
->debug( __METHOD__
. ': UserData is invalid: {reason}', [
1350 'user' => $user->getName(),
1351 'creator' => $creator->getUser()->getName(),
1352 'reason' => $status->getWikiText( false, false, 'en' ),
1354 return AuthenticationResponse
::newFail( $status->getMessage() );
1359 $this->removeAuthenticationSessionData( null );
1362 'username' => $username,
1364 'creatorid' => $creator->getUser()->getId(),
1365 'creatorname' => $creator->getUser()->getName(),
1367 'returnToUrl' => $returnToUrl,
1368 'providerIds' => $this->getProviderIds(),
1370 'primaryResponse' => null,
1372 'continueRequests' => [],
1374 'ranPreTests' => false,
1377 // Special case: converting a login to an account creation
1378 $req = AuthenticationRequest
::getRequestByClass(
1379 $reqs, CreateFromLoginAuthenticationRequest
::class
1382 $state['maybeLink'] = $req->maybeLink
;
1384 if ( $req->createRequest
) {
1385 $reqs[] = $req->createRequest
;
1386 $state['reqs'][] = $req->createRequest
;
1390 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1391 $session->persist();
1393 return $this->continueAccountCreation( $reqs );
1397 * Continue an account creation flow
1398 * @param AuthenticationRequest[] $reqs
1399 * @return AuthenticationResponse
1401 public function continueAccountCreation( array $reqs ) {
1402 $session = $this->request
->getSession();
1404 if ( !$this->canCreateAccounts() ) {
1405 // Caller should have called canCreateAccounts()
1406 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1407 throw new LogicException( 'Account creation is not possible' );
1410 $state = $session->getSecret( self
::ACCOUNT_CREATION_STATE
);
1411 if ( !is_array( $state ) ) {
1412 return AuthenticationResponse
::newFail(
1413 wfMessage( 'authmanager-create-not-in-progress' )
1416 $state['continueRequests'] = [];
1418 // Step 0: Prepare and validate the input
1420 $user = $this->userFactory
->newFromName(
1421 (string)$state['username'],
1422 UserRigorOptions
::RIGOR_CREATABLE
1424 if ( !is_object( $user ) ) {
1425 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1426 $this->logger
->debug( __METHOD__
. ': Invalid username', [
1427 'user' => $state['username'],
1429 return AuthenticationResponse
::newFail( wfMessage( 'noname' ) );
1432 if ( $state['creatorid'] ) {
1433 $creator = $this->userFactory
->newFromId( (int)$state['creatorid'] );
1435 $creator = $this->userFactory
->newAnonymous();
1436 $creator->setName( $state['creatorname'] );
1439 if ( $state['providerIds'] !== $this->getProviderIds() ) {
1440 // An inconsistent AuthManagerFilterProviders hook, or site configuration changed
1441 // while the user was in the middle of authentication. The first is a bug, the
1442 // second is rare but expected when deploying a config change. Try handle in a way
1443 // that's useful for both cases.
1444 // @codeCoverageIgnoreStart
1445 MWExceptionHandler
::logException( new NormalizedException(
1446 'Authentication failed because of inconsistent provider array',
1447 [ 'old' => json_encode( $state['providerIds'] ), 'new' => json_encode( $this->getProviderIds() ) ]
1449 $ret = AuthenticationResponse
::newFail(
1450 wfMessage( 'authmanager-create-not-in-progress' )
1452 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1453 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1455 // @codeCoverageIgnoreEnd
1458 // Avoid account creation races on double submissions
1459 $cache = MediaWikiServices
::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
1460 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) );
1462 // Don't clear AuthManager::accountCreationState for this code
1463 // path because the process that won the race owns it.
1464 $this->logger
->debug( __METHOD__
. ': Could not acquire account creation lock', [
1465 'user' => $user->getName(),
1466 'creator' => $creator->getName(),
1468 return AuthenticationResponse
::newFail( wfMessage( 'usernameinprogress' ) );
1471 // Permissions check
1472 $status = Status
::wrap( $this->authorizeCreateAccount( $creator ) );
1473 if ( !$status->isGood() ) {
1474 $this->logger
->debug( __METHOD__
. ': {creator} cannot create users: {reason}', [
1475 'user' => $user->getName(),
1476 'creator' => $creator->getName(),
1477 'reason' => $status->getWikiText( false, false, 'en' )
1479 $ret = AuthenticationResponse
::newFail( $status->getMessage() );
1480 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1481 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1485 // Load from primary DB for existence check
1486 $user->load( IDBAccessObject
::READ_LATEST
);
1488 if ( $state['userid'] === 0 ) {
1489 if ( $user->isRegistered() ) {
1490 $this->logger
->debug( __METHOD__
. ': User exists locally', [
1491 'user' => $user->getName(),
1492 'creator' => $creator->getName(),
1494 $ret = AuthenticationResponse
::newFail( wfMessage( 'userexists' ) );
1495 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1496 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1500 if ( !$user->isRegistered() ) {
1501 $this->logger
->debug( __METHOD__
. ': User does not exist locally when it should', [
1502 'user' => $user->getName(),
1503 'creator' => $creator->getName(),
1504 'expected_id' => $state['userid'],
1506 throw new \
UnexpectedValueException(
1507 "User \"{$state['username']}\" should exist now, but doesn't!"
1510 if ( $user->getId() !== $state['userid'] ) {
1511 $this->logger
->debug( __METHOD__
. ': User ID/name mismatch', [
1512 'user' => $user->getName(),
1513 'creator' => $creator->getName(),
1514 'expected_id' => $state['userid'],
1515 'actual_id' => $user->getId(),
1517 throw new \
UnexpectedValueException(
1518 "User \"{$state['username']}\" exists, but " .
1519 "ID {$user->getId()} !== {$state['userid']}!"
1523 foreach ( $state['reqs'] as $req ) {
1524 if ( $req instanceof UserDataAuthenticationRequest
) {
1525 $status = $req->populateUser( $user );
1526 if ( !$status->isGood() ) {
1527 // This should never happen...
1528 $status = Status
::wrap( $status );
1529 $this->logger
->debug( __METHOD__
. ': UserData is invalid: {reason}', [
1530 'user' => $user->getName(),
1531 'creator' => $creator->getName(),
1532 'reason' => $status->getWikiText( false, false, 'en' ),
1534 $ret = AuthenticationResponse
::newFail( $status->getMessage() );
1535 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation',
1536 [ $user, $creator, $ret ]
1538 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1544 foreach ( $reqs as $req ) {
1545 $req->returnToUrl
= $state['returnToUrl'];
1546 $req->username
= $state['username'];
1549 // Run pre-creation tests, if we haven't already
1550 if ( !$state['ranPreTests'] ) {
1551 $providers = $this->getPreAuthenticationProviders() +
1552 $this->getPrimaryAuthenticationProviders() +
1553 $this->getSecondaryAuthenticationProviders();
1554 foreach ( $providers as $id => $provider ) {
1555 $status = $provider->testForAccountCreation( $user, $creator, $reqs );
1556 if ( !$status->isGood() ) {
1557 $this->logger
->debug( __METHOD__
. ": Fail in pre-authentication by $id", [
1558 'user' => $user->getName(),
1559 'creator' => $creator->getName(),
1561 $ret = AuthenticationResponse
::newFail(
1562 Status
::wrap( $status )->getMessage()
1564 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation',
1565 [ $user, $creator, $ret ]
1567 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1572 $state['ranPreTests'] = true;
1575 // Step 1: Choose a primary authentication provider and call it until it succeeds.
1577 if ( $state['primary'] === null ) {
1578 // We haven't picked a PrimaryAuthenticationProvider yet
1579 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
1580 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_NONE
) {
1583 $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs );
1584 switch ( $res->status
) {
1585 case AuthenticationResponse
::PASS
:
1586 $this->logger
->debug( __METHOD__
. ": Primary creation passed by $id", [
1587 'user' => $user->getName(),
1588 'creator' => $creator->getName(),
1590 $state['primary'] = $id;
1591 $state['primaryResponse'] = $res;
1593 case AuthenticationResponse
::FAIL
:
1594 $this->logger
->debug( __METHOD__
. ": Primary creation failed by $id", [
1595 'user' => $user->getName(),
1596 'creator' => $creator->getName(),
1598 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation',
1599 [ $user, $creator, $res ]
1601 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1603 case AuthenticationResponse
::ABSTAIN
:
1606 case AuthenticationResponse
::REDIRECT
:
1607 case AuthenticationResponse
::UI
:
1608 $this->logger
->debug( __METHOD__
. ": Primary creation $res->status by $id", [
1609 'user' => $user->getName(),
1610 'creator' => $creator->getName(),
1612 $this->fillRequests( $res->neededRequests
, self
::ACTION_CREATE
, null );
1613 $state['primary'] = $id;
1614 $state['continueRequests'] = $res->neededRequests
;
1615 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1618 // @codeCoverageIgnoreStart
1620 throw new \
DomainException(
1621 get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status"
1623 // @codeCoverageIgnoreEnd
1626 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
1627 if ( $state['primary'] === null ) {
1628 $this->logger
->debug( __METHOD__
. ': Primary creation failed because no provider accepted', [
1629 'user' => $user->getName(),
1630 'creator' => $creator->getName(),
1632 $ret = AuthenticationResponse
::newFail(
1633 wfMessage( 'authmanager-create-no-primary' )
1635 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1636 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1639 } elseif ( $state['primaryResponse'] === null ) {
1640 $provider = $this->getAuthenticationProvider( $state['primary'] );
1641 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
1642 // Configuration changed? Force them to start over.
1643 // @codeCoverageIgnoreStart
1644 $ret = AuthenticationResponse
::newFail(
1645 wfMessage( 'authmanager-create-not-in-progress' )
1647 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1648 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1650 // @codeCoverageIgnoreEnd
1652 $id = $provider->getUniqueId();
1653 $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs );
1654 switch ( $res->status
) {
1655 case AuthenticationResponse
::PASS
:
1656 $this->logger
->debug( __METHOD__
. ": Primary creation passed by $id", [
1657 'user' => $user->getName(),
1658 'creator' => $creator->getName(),
1660 $state['primaryResponse'] = $res;
1662 case AuthenticationResponse
::FAIL
:
1663 $this->logger
->debug( __METHOD__
. ": Primary creation failed by $id", [
1664 'user' => $user->getName(),
1665 'creator' => $creator->getName(),
1667 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation',
1668 [ $user, $creator, $res ]
1670 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1672 case AuthenticationResponse
::REDIRECT
:
1673 case AuthenticationResponse
::UI
:
1674 $this->logger
->debug( __METHOD__
. ": Primary creation $res->status by $id", [
1675 'user' => $user->getName(),
1676 'creator' => $creator->getName(),
1678 $this->fillRequests( $res->neededRequests
, self
::ACTION_CREATE
, null );
1679 $state['continueRequests'] = $res->neededRequests
;
1680 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1683 throw new \
DomainException(
1684 get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status"
1689 // Step 2: Primary authentication succeeded. Give hook handlers a chance to interrupt,
1690 // then create the User object and add the user locally.
1692 if ( $state['userid'] === 0 ) {
1693 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set if we passed step 1
1694 $response = $state['primaryResponse'];
1695 if ( !$this->runVerifyHook( self
::ACTION_CREATE
, $user, $response, $state['primary'] ) ) {
1696 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation',
1697 [ $user, $creator, $response ]
1699 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1702 $this->logger
->info( 'Creating user {user} during account creation', [
1703 'user' => $user->getName(),
1704 'creator' => $creator->getName(),
1706 $status = $user->addToDatabase();
1707 if ( !$status->isOK() ) {
1708 // @codeCoverageIgnoreStart
1709 $ret = AuthenticationResponse
::newFail( $status->getMessage() );
1710 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1711 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1713 // @codeCoverageIgnoreEnd
1715 $this->setDefaultUserOptions( $user, $creator->isAnon() );
1716 $this->getHookRunner()->onLocalUserCreated( $user, false );
1717 $user->saveSettings();
1718 $state['userid'] = $user->getId();
1720 // Update user count
1721 DeferredUpdates
::addUpdate( SiteStatsUpdate
::factory( [ 'users' => 1 ] ) );
1723 // Watch user's userpage and talk page
1724 $this->watchlistManager
->addWatchIgnoringRights( $user, $user->getUserPage() );
1726 // Inform the provider
1727 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
1728 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
1729 $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] );
1732 if ( $this->config
->get( MainConfigNames
::NewUserLog
) ) {
1733 $isNamed = $creator->isNamed();
1734 $logEntry = new \
ManualLogEntry(
1736 $logSubtype ?
: ( $isNamed ?
'create2' : 'create' )
1738 $logEntry->setPerformer( $isNamed ?
$creator : $user );
1739 $logEntry->setTarget( $user->getUserPage() );
1740 /** @var CreationReasonAuthenticationRequest $req */
1741 $req = AuthenticationRequest
::getRequestByClass(
1742 $state['reqs'], CreationReasonAuthenticationRequest
::class
1744 $logEntry->setComment( $req ?
$req->reason
: '' );
1745 $logEntry->setParameters( [
1746 '4::userid' => $user->getId(),
1748 $logid = $logEntry->insert();
1749 $logEntry->publish( $logid );
1753 // Step 3: Iterate over all the secondary authentication providers.
1755 $beginReqs = $state['reqs'];
1757 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
1758 if ( !isset( $state['secondary'][$id] ) ) {
1759 // This provider isn't started yet, so we pass it the set
1760 // of reqs from beginAuthentication instead of whatever
1761 // might have been used by a previous provider in line.
1762 $func = 'beginSecondaryAccountCreation';
1763 $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs );
1764 } elseif ( !$state['secondary'][$id] ) {
1765 $func = 'continueSecondaryAccountCreation';
1766 $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs );
1770 switch ( $res->status
) {
1771 case AuthenticationResponse
::PASS
:
1772 $this->logger
->debug( __METHOD__
. ": Secondary creation passed by $id", [
1773 'user' => $user->getName(),
1774 'creator' => $creator->getName(),
1777 case AuthenticationResponse
::ABSTAIN
:
1778 $state['secondary'][$id] = true;
1780 case AuthenticationResponse
::REDIRECT
:
1781 case AuthenticationResponse
::UI
:
1782 $this->logger
->debug( __METHOD__
. ": Secondary creation $res->status by $id", [
1783 'user' => $user->getName(),
1784 'creator' => $creator->getName(),
1786 $this->fillRequests( $res->neededRequests
, self
::ACTION_CREATE
, null );
1787 $state['secondary'][$id] = false;
1788 $state['continueRequests'] = $res->neededRequests
;
1789 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1791 case AuthenticationResponse
::FAIL
:
1792 throw new \
DomainException(
1793 get_class( $provider ) . "::{$func}() returned $res->status." .
1794 ' Secondary providers are not allowed to fail account creation, that' .
1795 ' should have been done via testForAccountCreation().'
1797 // @codeCoverageIgnoreStart
1799 throw new \
DomainException(
1800 get_class( $provider ) . "::{$func}() returned $res->status"
1802 // @codeCoverageIgnoreEnd
1806 $id = $user->getId();
1807 $name = $user->getName();
1808 $req = new CreatedAccountAuthenticationRequest( $id, $name );
1809 $ret = AuthenticationResponse
::newPass( $name );
1810 $ret->loginRequest
= $req;
1811 $this->createdAccountAuthenticationRequests
[] = $req;
1813 $this->logger
->info( __METHOD__
. ': Account creation succeeded for {user}', [
1814 'user' => $user->getName(),
1815 'creator' => $creator->getName(),
1818 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $creator, $ret ] );
1819 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1820 $this->removeAuthenticationSessionData( null );
1822 } catch ( \Exception
$ex ) {
1823 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1829 * @param Status $status
1830 * @param User $targetUser
1831 * @param string $source What caused the auto-creation @see ::autoCreateUser
1832 * @param bool $login Whether to also log the user in
1834 * @todo Inject both identityUtils and logger
1836 private function logAutocreationAttempt( Status
$status, User
$targetUser, $source, $login ) {
1837 if ( $status->isOK() && !$status->isGood() ) {
1838 return; // user already existed, no need to log
1841 $firstMessage = $status->getMessages( 'error' )[0] ??
$status->getMessages( 'warning' )[0] ??
null;
1842 $identityUtils = MediaWikiServices
::getInstance()->getUserIdentityUtils();
1844 \MediaWiki\Logger\LoggerFactory
::getInstance( 'authevents' )->info( 'Autocreation attempt', [
1845 'event' => 'autocreate',
1846 'successful' => $status->isGood(),
1847 'status' => $firstMessage ?
$firstMessage->getKey() : '-',
1848 'accountType' => $identityUtils->getShortUserTypeInternal( $targetUser ),
1849 'source' => $source,
1855 * Auto-create an account, and optionally log into that account
1857 * PrimaryAuthenticationProviders can invoke this method by returning a PASS from
1858 * beginPrimaryAuthentication/continuePrimaryAuthentication with the username of a
1859 * non-existing user. SessionProviders can invoke it by returning a SessionInfo with
1860 * the username of a non-existing user from provideSessionInfo(). Calling this method
1861 * explicitly (e.g. from a maintenance script) is also fine.
1863 * @param User $user User to auto-create
1864 * @param string $source What caused the auto-creation? This must be one of:
1865 * - the ID of a PrimaryAuthenticationProvider,
1866 * - one of the self::AUTOCREATE_SOURCE_* constants
1867 * @param bool $login Whether to also log the user in
1868 * @param bool $log Whether to generate a user creation log entry (since 1.36)
1869 * @param Authority|null $performer The performer of the action to use for user rights
1870 * checking. Normally null to indicate an anonymous performer. Added in 1.42 for
1871 * Special:CreateLocalAccount (T234371).
1873 * @return Status Good if user was created, Ok if user already existed, otherwise Fatal
1875 public function autoCreateUser(
1880 ?Authority
$performer = null
1883 self
::AUTOCREATE_SOURCE_SESSION
,
1884 self
::AUTOCREATE_SOURCE_MAINT
,
1885 self
::AUTOCREATE_SOURCE_TEMP
1887 if ( !in_array( $source, $validSources, true )
1888 && !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider
1890 throw new InvalidArgumentException( "Unknown auto-creation source: $source" );
1893 $username = $user->getName();
1895 // Try the local user from the replica DB, then fall back to the primary.
1896 $localUserIdentity = $this->userIdentityLookup
->getUserIdentityByName( $username );
1897 // @codeCoverageIgnoreStart
1898 if ( ( !$localUserIdentity ||
!$localUserIdentity->isRegistered() )
1899 && $this->loadBalancer
->getReaderIndex() !== 0
1901 $localUserIdentity = $this->userIdentityLookup
->getUserIdentityByName(
1902 $username, IDBAccessObject
::READ_LATEST
1905 // @codeCoverageIgnoreEnd
1906 $localId = ( $localUserIdentity && $localUserIdentity->isRegistered() )
1907 ?
$localUserIdentity->getId()
1911 $this->logger
->debug( __METHOD__
. ': {username} already exists locally', [
1912 'username' => $username,
1914 $user->setId( $localId );
1916 // Can't rely on a replica read, not even when getUserIdentityByName() used
1917 // READ_NORMAL, because that method has an in-process cache not shared
1919 $user->loadFromId( IDBAccessObject
::READ_LATEST
);
1921 $remember = $source === self
::AUTOCREATE_SOURCE_TEMP
;
1922 $this->setSessionDataForUser( $user, $remember );
1924 return Status
::newGood()->warning( 'userexists' );
1927 // Wiki is read-only?
1928 if ( $this->readOnlyMode
->isReadOnly() ) {
1929 $reason = $this->readOnlyMode
->getReason();
1930 $this->logger
->debug( __METHOD__
. ': denied because of read only mode: {reason}', [
1931 'username' => $username,
1932 'reason' => $reason,
1935 $user->loadFromId();
1936 $fatalStatus = Status
::newFatal( wfMessage( 'readonlytext', $reason ) );
1937 $this->logAutocreationAttempt( $fatalStatus, $user, $source, $login );
1938 return $fatalStatus;
1941 // If there is a non-anonymous performer, don't use their session
1943 if ( !$performer ||
$performer->getUser()->equals( $user ) ) {
1944 $session = $this->request
->getSession();
1947 // Check the session, if we tried to create this user already there's
1948 // no point in retrying.
1949 if ( $session && $session->get( self
::AUTOCREATE_BLOCKLIST
) ) {
1950 $this->logger
->debug( __METHOD__
. ': blacklisted in session {sessionid}', [
1951 'username' => $username,
1952 'sessionid' => $session->getId(),
1955 $user->loadFromId();
1956 $reason = $session->get( self
::AUTOCREATE_BLOCKLIST
);
1958 $status = $reason instanceof StatusValue ? Status
::wrap( $reason ) : Status
::newFatal( $reason );
1959 $this->logAutocreationAttempt( $status, $user, $source, $login );
1963 // Is the username usable? (Previously isCreatable() was checked here but
1964 // that doesn't work with auto-creation of TempUser accounts by CentralAuth)
1965 if ( !$this->userNameUtils
->isUsable( $username ) ) {
1966 $this->logger
->debug( __METHOD__
. ': name "{username}" is not usable', [
1967 'username' => $username,
1970 $session->set( self
::AUTOCREATE_BLOCKLIST
, 'noname' );
1973 $user->loadFromId();
1974 $fatalStatus = Status
::newFatal( 'noname' );
1975 $this->logAutocreationAttempt( $fatalStatus, $user, $source, $login );
1976 return $fatalStatus;
1979 // Is the IP user able to create accounts?
1980 $performer ??
= $this->userFactory
->newAnonymous();
1981 $bypassAuthorization = $session ?
$session->getProvider()->canAlwaysAutocreate() : false;
1982 if ( $source !== self
::AUTOCREATE_SOURCE_MAINT
&& !$bypassAuthorization ) {
1983 $status = $this->authorizeAutoCreateAccount( $performer );
1984 if ( !$status->isOK() ) {
1985 $this->logger
->debug( __METHOD__
. ': cannot create or autocreate accounts', [
1986 'username' => $username,
1987 'creator' => $performer->getUser()->getName(),
1990 $session->set( self
::AUTOCREATE_BLOCKLIST
, $status );
1991 $session->persist();
1994 $user->loadFromId();
1995 $statusWrapped = Status
::wrap( $status );
1996 $this->logAutocreationAttempt( $statusWrapped, $user, $source, $login );
1997 return $statusWrapped;
2001 // Avoid account creation races on double submissions
2002 $cache = MediaWikiServices
::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
2003 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
2005 $this->logger
->debug( __METHOD__
. ': Could not acquire account creation lock', [
2006 'user' => $username,
2009 $user->loadFromId();
2010 $status = Status
::newFatal( 'usernameinprogress' );
2011 $this->logAutocreationAttempt( $status, $user, $source, $login );
2015 // Denied by providers?
2017 'flags' => IDBAccessObject
::READ_LATEST
,
2019 'canAlwaysAutocreate' => $session && $session->getProvider()->canAlwaysAutocreate(),
2021 $providers = $this->getPreAuthenticationProviders() +
2022 $this->getPrimaryAuthenticationProviders() +
2023 $this->getSecondaryAuthenticationProviders();
2024 foreach ( $providers as $provider ) {
2025 $status = $provider->testUserForCreation( $user, $source, $options );
2026 if ( !$status->isGood() ) {
2027 $ret = Status
::wrap( $status );
2028 $this->logger
->debug( __METHOD__
. ': Provider denied creation of {username}: {reason}', [
2029 'username' => $username,
2030 'reason' => $ret->getWikiText( false, false, 'en' ),
2033 $session->set( self
::AUTOCREATE_BLOCKLIST
, $status );
2036 $user->loadFromId();
2037 $this->logAutocreationAttempt( $ret, $user, $source, $login );
2042 $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
2043 if ( $cache->get( $backoffKey ) ) {
2044 $this->logger
->debug( __METHOD__
. ': {username} denied by prior creation attempt failures', [
2045 'username' => $username,
2048 $user->loadFromId();
2049 $status = Status
::newFatal( 'authmanager-autocreate-exception' );
2050 $this->logAutocreationAttempt( $status, $user, $source, $login );
2055 // Checks passed, create the user...
2056 $from = $_SERVER['REQUEST_URI'] ??
'CLI';
2057 $this->logger
->info( __METHOD__
. ': creating new user ({username}) - from: {from}', [
2058 'username' => $username,
2062 // Ignore warnings about primary connections/writes...hard to avoid here
2063 $trxProfiler = \Profiler
::instance()->getTransactionProfiler();
2064 $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY
);
2066 $status = $user->addToDatabase();
2067 if ( !$status->isOK() ) {
2068 // Double-check for a race condition (T70012). We make use of the fact that when
2069 // addToDatabase fails due to the user already existing, the user object gets loaded.
2070 if ( $user->getId() ) {
2071 $this->logger
->info( __METHOD__
. ': {username} already exists locally (race)', [
2072 'username' => $username,
2075 $remember = $source === self
::AUTOCREATE_SOURCE_TEMP
;
2076 $this->setSessionDataForUser( $user, $remember );
2078 $status = Status
::newGood()->warning( 'userexists' );
2080 $this->logger
->error( __METHOD__
. ': {username} failed with message {msg}', [
2081 'username' => $username,
2082 'msg' => $status->getWikiText( false, false, 'en' )
2085 $user->loadFromId();
2087 $this->logAutocreationAttempt( $status, $user, $source, $login );
2090 } catch ( \Exception
$ex ) {
2091 $this->logger
->error( __METHOD__
. ': {username} failed with exception {exception}', [
2092 'username' => $username,
2095 // Do not keep throwing errors for a while
2096 $cache->set( $backoffKey, 1, 600 );
2097 // Bubble up error; which should normally trigger DB rollbacks
2101 $this->setDefaultUserOptions( $user, false );
2103 // Inform the providers
2104 $this->callMethodOnProviders( self
::CALL_PRIMARY | self
::CALL_SECONDARY
, 'autoCreatedAccount',
2108 $this->getHookRunner()->onLocalUserCreated( $user, true );
2109 $user->saveSettings();
2111 // Update user count
2112 DeferredUpdates
::addUpdate( SiteStatsUpdate
::factory( [ 'users' => 1 ] ) );
2113 // Watch user's userpage and talk page (except temp users)
2114 if ( $source !== self
::AUTOCREATE_SOURCE_TEMP
) {
2115 DeferredUpdates
::addCallableUpdate( function () use ( $user ) {
2116 $this->watchlistManager
->addWatchIgnoringRights( $user, $user->getUserPage() );
2121 if ( $this->config
->get( MainConfigNames
::NewUserLog
) && $log ) {
2122 $logEntry = new \
ManualLogEntry( 'newusers', 'autocreate' );
2123 $logEntry->setPerformer( $user );
2124 $logEntry->setTarget( $user->getUserPage() );
2125 $logEntry->setComment( '' );
2126 $logEntry->setParameters( [
2127 '4::userid' => $user->getId(),
2129 $logEntry->insert();
2132 ScopedCallback
::consume( $scope );
2135 $remember = $source === self
::AUTOCREATE_SOURCE_TEMP
;
2136 $this->setSessionDataForUser( $user, $remember );
2138 $retStatus = Status
::newGood();
2139 $this->logAutocreationAttempt( $retStatus, $user, $source, $login );
2144 * Authorize automatic account creation. This is like account creation but
2145 * checks the autocreateaccount right instead of the createaccount right.
2147 * @param Authority $creator
2148 * @return StatusValue
2150 private function authorizeAutoCreateAccount( Authority
$creator ) {
2151 return $this->authorizeInternal(
2154 PageIdentity
$target,
2155 PermissionStatus
$status
2156 ) use ( $creator ) {
2157 return $creator->authorizeWrite( $action, $target, $status );
2163 // endregion -- end of Account creation
2165 /***************************************************************************/
2166 // region Account linking
2167 /** @name Account linking */
2170 * Determine whether accounts can be linked
2173 public function canLinkAccounts() {
2174 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2175 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
) {
2183 * Start an account linking flow
2185 * @param User $user User being linked
2186 * @param AuthenticationRequest[] $reqs
2187 * @param string $returnToUrl Url that REDIRECT responses should eventually
2189 * @return AuthenticationResponse
2191 public function beginAccountLink( User
$user, array $reqs, $returnToUrl ) {
2192 $session = $this->request
->getSession();
2193 $session->remove( self
::ACCOUNT_LINK_STATE
);
2195 if ( !$this->canLinkAccounts() ) {
2196 // Caller should have called canLinkAccounts()
2197 throw new LogicException( 'Account linking is not possible' );
2200 if ( !$user->isRegistered() ) {
2201 if ( !$this->userNameUtils
->isUsable( $user->getName() ) ) {
2202 $msg = wfMessage( 'noname' );
2204 $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() );
2206 return AuthenticationResponse
::newFail( $msg );
2208 foreach ( $reqs as $req ) {
2209 $req->username
= $user->getName();
2210 $req->returnToUrl
= $returnToUrl;
2213 $this->removeAuthenticationSessionData( null );
2215 $providers = $this->getPreAuthenticationProviders();
2216 foreach ( $providers as $id => $provider ) {
2217 $status = $provider->testForAccountLink( $user );
2218 if ( !$status->isGood() ) {
2219 $this->logger
->debug( __METHOD__
. ": Account linking pre-check failed by $id", [
2220 'user' => $user->getName(),
2222 $ret = AuthenticationResponse
::newFail(
2223 Status
::wrap( $status )->getMessage()
2225 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink', [ $user, $ret ] );
2231 'username' => $user->getName(),
2232 'userid' => $user->getId(),
2233 'returnToUrl' => $returnToUrl,
2234 'providerIds' => $this->getProviderIds(),
2236 'continueRequests' => [],
2239 $providers = $this->getPrimaryAuthenticationProviders();
2240 foreach ( $providers as $id => $provider ) {
2241 if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider
::TYPE_LINK
) {
2245 $res = $provider->beginPrimaryAccountLink( $user, $reqs );
2246 switch ( $res->status
) {
2247 case AuthenticationResponse
::PASS
:
2248 $this->logger
->info( "Account linked to {user} by $id", [
2249 'user' => $user->getName(),
2251 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink',
2256 case AuthenticationResponse
::FAIL
:
2257 $this->logger
->debug( __METHOD__
. ": Account linking failed by $id", [
2258 'user' => $user->getName(),
2260 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink',
2265 case AuthenticationResponse
::ABSTAIN
:
2269 case AuthenticationResponse
::REDIRECT
:
2270 case AuthenticationResponse
::UI
:
2271 $this->logger
->debug( __METHOD__
. ": Account linking $res->status by $id", [
2272 'user' => $user->getName(),
2274 $this->fillRequests( $res->neededRequests
, self
::ACTION_LINK
, $user->getName() );
2275 $state['primary'] = $id;
2276 $state['continueRequests'] = $res->neededRequests
;
2277 $session->setSecret( self
::ACCOUNT_LINK_STATE
, $state );
2278 $session->persist();
2281 // @codeCoverageIgnoreStart
2283 throw new \
DomainException(
2284 get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status"
2286 // @codeCoverageIgnoreEnd
2290 $this->logger
->debug( __METHOD__
. ': Account linking failed because no provider accepted', [
2291 'user' => $user->getName(),
2293 $ret = AuthenticationResponse
::newFail(
2294 wfMessage( 'authmanager-link-no-primary' )
2296 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink', [ $user, $ret ] );
2301 * Continue an account linking flow
2302 * @param AuthenticationRequest[] $reqs
2303 * @return AuthenticationResponse
2305 public function continueAccountLink( array $reqs ) {
2306 $session = $this->request
->getSession();
2308 if ( !$this->canLinkAccounts() ) {
2309 // Caller should have called canLinkAccounts()
2310 $session->remove( self
::ACCOUNT_LINK_STATE
);
2311 throw new LogicException( 'Account linking is not possible' );
2314 $state = $session->getSecret( self
::ACCOUNT_LINK_STATE
);
2315 if ( !is_array( $state ) ) {
2316 return AuthenticationResponse
::newFail(
2317 wfMessage( 'authmanager-link-not-in-progress' )
2320 $state['continueRequests'] = [];
2322 // Step 0: Prepare and validate the input
2324 $user = $this->userFactory
->newFromName(
2325 (string)$state['username'],
2326 UserRigorOptions
::RIGOR_USABLE
2328 if ( !is_object( $user ) ) {
2329 $session->remove( self
::ACCOUNT_LINK_STATE
);
2330 return AuthenticationResponse
::newFail( wfMessage( 'noname' ) );
2332 if ( $user->getId() !== $state['userid'] ) {
2333 throw new \
UnexpectedValueException(
2334 "User \"{$state['username']}\" is valid, but " .
2335 "ID {$user->getId()} !== {$state['userid']}!"
2339 if ( $state['providerIds'] !== $this->getProviderIds() ) {
2340 // An inconsistent AuthManagerFilterProviders hook, or site configuration changed
2341 // while the user was in the middle of authentication. The first is a bug, the
2342 // second is rare but expected when deploying a config change. Try handle in a way
2343 // that's useful for both cases.
2344 // @codeCoverageIgnoreStart
2345 MWExceptionHandler
::logException( new NormalizedException(
2346 'Authentication failed because of inconsistent provider array',
2347 [ 'old' => json_encode( $state['providerIds'] ), 'new' => json_encode( $this->getProviderIds() ) ]
2349 $ret = AuthenticationResponse
::newFail(
2350 wfMessage( 'authmanager-link-not-in-progress' )
2352 $this->callMethodOnProviders( self
::CALL_ALL
, 'postAccountCreation', [ $user, $ret ] );
2353 $session->remove( self
::ACCOUNT_LINK_STATE
);
2355 // @codeCoverageIgnoreEnd
2358 foreach ( $reqs as $req ) {
2359 $req->username
= $state['username'];
2360 $req->returnToUrl
= $state['returnToUrl'];
2363 // Step 1: Call the primary again until it succeeds
2365 $provider = $this->getAuthenticationProvider( $state['primary'] );
2366 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
2367 // Configuration changed? Force them to start over.
2368 // @codeCoverageIgnoreStart
2369 $ret = AuthenticationResponse
::newFail(
2370 wfMessage( 'authmanager-link-not-in-progress' )
2372 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink', [ $user, $ret ] );
2373 $session->remove( self
::ACCOUNT_LINK_STATE
);
2375 // @codeCoverageIgnoreEnd
2377 $id = $provider->getUniqueId();
2378 $res = $provider->continuePrimaryAccountLink( $user, $reqs );
2379 switch ( $res->status
) {
2380 case AuthenticationResponse
::PASS
:
2381 $this->logger
->info( "Account linked to {user} by $id", [
2382 'user' => $user->getName(),
2384 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink',
2387 $session->remove( self
::ACCOUNT_LINK_STATE
);
2389 case AuthenticationResponse
::FAIL
:
2390 $this->logger
->debug( __METHOD__
. ": Account linking failed by $id", [
2391 'user' => $user->getName(),
2393 $this->callMethodOnProviders( self
::CALL_PRE | self
::CALL_PRIMARY
, 'postAccountLink',
2396 $session->remove( self
::ACCOUNT_LINK_STATE
);
2398 case AuthenticationResponse
::REDIRECT
:
2399 case AuthenticationResponse
::UI
:
2400 $this->logger
->debug( __METHOD__
. ": Account linking $res->status by $id", [
2401 'user' => $user->getName(),
2403 $this->fillRequests( $res->neededRequests
, self
::ACTION_LINK
, $user->getName() );
2404 $state['continueRequests'] = $res->neededRequests
;
2405 $session->setSecret( self
::ACCOUNT_LINK_STATE
, $state );
2408 throw new \
DomainException(
2409 get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
2412 } catch ( \Exception
$ex ) {
2413 $session->remove( self
::ACCOUNT_LINK_STATE
);
2418 // endregion -- end of Account linking
2420 /***************************************************************************/
2421 // region Information methods
2422 /** @name Information methods */
2425 * Return the applicable list of AuthenticationRequests
2427 * Possible values for $action:
2428 * - ACTION_LOGIN: Valid for passing to beginAuthentication
2429 * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state
2430 * - ACTION_CREATE: Valid for passing to beginAccountCreation
2431 * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state
2432 * - ACTION_LINK: Valid for passing to beginAccountLink
2433 * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state
2434 * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials
2435 * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials.
2436 * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts.
2438 * @param string $action One of the AuthManager::ACTION_* constants
2439 * @param UserIdentity|null $user User being acted on, instead of the current user.
2440 * @return AuthenticationRequest[]
2442 public function getAuthenticationRequests( $action, ?UserIdentity
$user = null ) {
2444 $providerAction = $action;
2446 // Figure out which providers to query
2447 switch ( $action ) {
2448 case self
::ACTION_LOGIN
:
2449 case self
::ACTION_CREATE
:
2450 $providers = $this->getPreAuthenticationProviders() +
2451 $this->getPrimaryAuthenticationProviders() +
2452 $this->getSecondaryAuthenticationProviders();
2455 case self
::ACTION_LOGIN_CONTINUE
:
2456 $state = $this->request
->getSession()->getSecret( self
::AUTHN_STATE
);
2457 return is_array( $state ) ?
$state['continueRequests'] : [];
2459 case self
::ACTION_CREATE_CONTINUE
:
2460 $state = $this->request
->getSession()->getSecret( self
::ACCOUNT_CREATION_STATE
);
2461 return is_array( $state ) ?
$state['continueRequests'] : [];
2463 case self
::ACTION_LINK
:
2465 foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2466 if ( $p->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
) {
2472 case self
::ACTION_UNLINK
:
2474 foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2475 if ( $p->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
) {
2480 // To providers, unlink and remove are identical.
2481 $providerAction = self
::ACTION_REMOVE
;
2484 case self
::ACTION_LINK_CONTINUE
:
2485 $state = $this->request
->getSession()->getSecret( self
::ACCOUNT_LINK_STATE
);
2486 return is_array( $state ) ?
$state['continueRequests'] : [];
2488 case self
::ACTION_CHANGE
:
2489 case self
::ACTION_REMOVE
:
2490 $providers = $this->getPrimaryAuthenticationProviders() +
2491 $this->getSecondaryAuthenticationProviders();
2494 // @codeCoverageIgnoreStart
2496 throw new \
DomainException( __METHOD__
. ": Invalid action \"$action\"" );
2498 // @codeCoverageIgnoreEnd
2500 return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user );
2504 * Internal request lookup for self::getAuthenticationRequests
2506 * @param string $providerAction Action to pass to providers
2507 * @param array $options Options to pass to providers
2508 * @param AuthenticationProvider[] $providers
2509 * @param UserIdentity|null $user being acted on
2510 * @return AuthenticationRequest[]
2512 private function getAuthenticationRequestsInternal(
2513 $providerAction, array $options, array $providers, ?UserIdentity
$user = null
2515 $user = $user ?
: RequestContext
::getMain()->getUser();
2516 $options['username'] = $user->isRegistered() ?
$user->getName() : null;
2518 // Query them and merge results
2520 foreach ( $providers as $provider ) {
2521 $isPrimary = $provider instanceof PrimaryAuthenticationProvider
;
2522 foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) {
2523 $id = $req->getUniqueId();
2525 // If a required request if from a Primary, mark it as "primary-required" instead
2526 if ( $isPrimary && $req->required
) {
2527 $req->required
= AuthenticationRequest
::PRIMARY_REQUIRED
;
2531 !isset( $reqs[$id] )
2532 ||
$req->required
=== AuthenticationRequest
::REQUIRED
2533 ||
$reqs[$id]->required
=== AuthenticationRequest
::OPTIONAL
2540 // AuthManager has its own req for some actions
2541 switch ( $providerAction ) {
2542 case self
::ACTION_LOGIN
:
2543 $reqs[] = new RememberMeAuthenticationRequest(
2544 $this->config
->get( MainConfigNames
::RememberMe
) );
2545 $options['username'] = null; // Don't fill in the username below
2548 case self
::ACTION_CREATE
:
2549 $reqs[] = new UsernameAuthenticationRequest
;
2550 $reqs[] = new UserDataAuthenticationRequest
;
2552 // Registered users should be prompted to provide a rationale for account creations,
2553 // except for the case of a temporary user registering a full account (T328718).
2555 $options['username'] !== null &&
2556 !$this->userNameUtils
->isTemp( $options['username'] )
2558 $reqs[] = new CreationReasonAuthenticationRequest
;
2559 $options['username'] = null; // Don't fill in the username below
2564 // Fill in reqs data
2565 $this->fillRequests( $reqs, $providerAction, $options['username'], true );
2567 // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing
2568 if ( $providerAction === self
::ACTION_CHANGE ||
$providerAction === self
::ACTION_REMOVE
) {
2569 $reqs = array_filter( $reqs, function ( $req ) {
2570 return $this->allowsAuthenticationDataChange( $req, false )->isGood();
2574 return array_values( $reqs );
2578 * Set values in an array of requests
2579 * @param AuthenticationRequest[] &$reqs
2580 * @param string $action
2581 * @param string|null $username
2582 * @param bool $forceAction
2584 private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) {
2585 foreach ( $reqs as $req ) {
2586 if ( !$req->action ||
$forceAction ) {
2587 $req->action
= $action;
2589 $req->username ??
= $username;
2594 * Determine whether a username exists
2595 * @param string $username
2596 * @param int $flags Bitfield of IDBAccessObject::READ_* constants
2599 public function userExists( $username, $flags = IDBAccessObject
::READ_NORMAL
) {
2600 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2601 if ( $provider->testUserExists( $username, $flags ) ) {
2610 * Determine whether a user property should be allowed to be changed.
2612 * Supported properties are:
2617 * @param string $property
2620 public function allowsPropertyChange( $property ) {
2621 $providers = $this->getPrimaryAuthenticationProviders() +
2622 $this->getSecondaryAuthenticationProviders();
2623 foreach ( $providers as $provider ) {
2624 if ( !$provider->providerAllowsPropertyChange( $property ) ) {
2632 * Get a provider by ID
2633 * @note This is public so extensions can check whether their own provider
2634 * is installed and so they can read its configuration if necessary.
2635 * Other uses are not recommended.
2637 * @return AuthenticationProvider|null
2639 public function getAuthenticationProvider( $id ) {
2641 if ( isset( $this->allAuthenticationProviders
[$id] ) ) {
2642 return $this->allAuthenticationProviders
[$id];
2645 // Slow version: instantiate each kind and check
2646 $providers = $this->getPrimaryAuthenticationProviders();
2647 if ( isset( $providers[$id] ) ) {
2648 return $providers[$id];
2650 $providers = $this->getSecondaryAuthenticationProviders();
2651 if ( isset( $providers[$id] ) ) {
2652 return $providers[$id];
2654 $providers = $this->getPreAuthenticationProviders();
2655 if ( isset( $providers[$id] ) ) {
2656 return $providers[$id];
2662 // endregion -- end of Information methods
2664 /***************************************************************************/
2665 // region Internal methods
2666 /** @name Internal methods */
2669 * Store authentication in the current session
2670 * @note For use by AuthenticationProviders only
2671 * @param string $key
2672 * @param mixed $data Must be serializable
2674 public function setAuthenticationSessionData( $key, $data ) {
2675 $session = $this->request
->getSession();
2676 $arr = $session->getSecret( 'authData' );
2677 if ( !is_array( $arr ) ) {
2681 $session->setSecret( 'authData', $arr );
2685 * Fetch authentication data from the current session
2686 * @note For use by AuthenticationProviders only
2687 * @param string $key
2688 * @param mixed|null $default
2691 public function getAuthenticationSessionData( $key, $default = null ) {
2692 $arr = $this->request
->getSession()->getSecret( 'authData' );
2693 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2701 * Remove authentication data
2702 * @note For use by AuthenticationProviders
2703 * @param string|null $key If null, all data is removed
2705 public function removeAuthenticationSessionData( $key ) {
2706 $session = $this->request
->getSession();
2707 if ( $key === null ) {
2708 $session->remove( 'authData' );
2710 $arr = $session->getSecret( 'authData' );
2711 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2712 unset( $arr[$key] );
2713 $session->setSecret( 'authData', $arr );
2719 * Create an array of AuthenticationProviders from an array of ObjectFactory specs
2720 * @param string $class
2721 * @param array[] $specs
2722 * @return AuthenticationProvider[]
2724 protected function providerArrayFromSpecs( $class, array $specs ) {
2726 foreach ( $specs as &$spec ) {
2727 $spec = [ 'sort2' => $i++
] +
$spec +
[ 'sort' => 0 ];
2730 // Sort according to the 'sort' field, and if they are equal, according to 'sort2'
2731 usort( $specs, static function ( $a, $b ) {
2732 return $a['sort'] <=> $b['sort']
2733 ?
: $a['sort2'] <=> $b['sort2'];
2737 foreach ( $specs as $spec ) {
2738 /** @var AbstractAuthenticationProvider $provider */
2739 $provider = $this->objectFactory
->createObject( $spec, [ 'assertClass' => $class ] );
2740 $provider->init( $this->logger
, $this, $this->getHookContainer(), $this->config
, $this->userNameUtils
);
2741 $id = $provider->getUniqueId();
2742 if ( isset( $this->allAuthenticationProviders
[$id] ) ) {
2743 throw new \
RuntimeException(
2744 "Duplicate specifications for id $id (classes " .
2745 get_class( $provider ) . ' and ' .
2746 get_class( $this->allAuthenticationProviders
[$id] ) . ')'
2749 $this->allAuthenticationProviders
[$id] = $provider;
2750 $ret[$id] = $provider;
2756 * Get the list of PreAuthenticationProviders
2757 * @return PreAuthenticationProvider[]
2759 protected function getPreAuthenticationProviders() {
2760 if ( $this->preAuthenticationProviders
=== null ) {
2761 $this->initializeAuthenticationProviders();
2763 return $this->preAuthenticationProviders
;
2767 * Get the list of PrimaryAuthenticationProviders
2768 * @return PrimaryAuthenticationProvider[]
2770 protected function getPrimaryAuthenticationProviders() {
2771 if ( $this->primaryAuthenticationProviders
=== null ) {
2772 $this->initializeAuthenticationProviders();
2774 return $this->primaryAuthenticationProviders
;
2778 * Get the list of SecondaryAuthenticationProviders
2779 * @return SecondaryAuthenticationProvider[]
2781 protected function getSecondaryAuthenticationProviders() {
2782 if ( $this->secondaryAuthenticationProviders
=== null ) {
2783 $this->initializeAuthenticationProviders();
2785 return $this->secondaryAuthenticationProviders
;
2788 private function getProviderIds(): array {
2790 'preauth' => array_keys( $this->getPreAuthenticationProviders() ),
2791 'primaryauth' => array_keys( $this->getPrimaryAuthenticationProviders() ),
2792 'secondaryauth' => array_keys( $this->getSecondaryAuthenticationProviders() ),
2796 private function initializeAuthenticationProviders() {
2797 $conf = $this->config
->get( MainConfigNames
::AuthManagerConfig
)
2798 ?
: $this->config
->get( MainConfigNames
::AuthManagerAutoConfig
);
2800 $providers = array_map( fn ( $stepConf ) => array_fill_keys( array_keys( $stepConf ), true ), $conf );
2801 $this->getHookRunner()->onAuthManagerFilterProviders( $providers );
2802 foreach ( $conf as $step => $stepConf ) {
2803 $conf[$step] = array_intersect_key( $stepConf, array_filter( $providers[$step] ) );
2806 $this->preAuthenticationProviders
= $this->providerArrayFromSpecs(
2807 PreAuthenticationProvider
::class, $conf['preauth']
2809 $this->primaryAuthenticationProviders
= $this->providerArrayFromSpecs(
2810 PrimaryAuthenticationProvider
::class, $conf['primaryauth']
2812 $this->secondaryAuthenticationProviders
= $this->providerArrayFromSpecs(
2813 SecondaryAuthenticationProvider
::class, $conf['secondaryauth']
2820 * @param bool|null $remember
2822 private function setSessionDataForUser( $user, $remember = null ) {
2823 $session = $this->request
->getSession();
2824 $delay = $session->delaySave();
2826 $session->resetId();
2827 $session->resetAllTokens();
2828 if ( $session->canSetUser() ) {
2829 $session->setUser( $user );
2831 if ( $remember !== null ) {
2832 $session->setRememberUser( $remember );
2834 $session->set( 'AuthManager:lastAuthId', $user->getId() );
2835 $session->set( 'AuthManager:lastAuthTimestamp', time() );
2836 $session->persist();
2838 \Wikimedia\ScopedCallback
::consume( $delay );
2840 $this->getHookRunner()->onUserLoggedIn( $user );
2845 * @param bool $useContextLang Use 'uselang' to set the user's language
2847 private function setDefaultUserOptions( User
$user, $useContextLang ) {
2850 $lang = $useContextLang ? RequestContext
::getMain()->getLanguage() : $this->contentLanguage
;
2851 $this->userOptionsManager
->setOption(
2854 $this->languageConverterFactory
->getLanguageConverter( $lang )->getPreferredVariant()
2857 $contLangConverter = $this->languageConverterFactory
->getLanguageConverter( $this->contentLanguage
);
2858 if ( $contLangConverter->hasVariants() ) {
2859 $this->userOptionsManager
->setOption(
2862 $contLangConverter->getPreferredVariant()
2868 * @see AuthManagerVerifyAuthenticationHook::onAuthManagerVerifyAuthentication()
2870 private function runVerifyHook(
2872 ?UserIdentity
$user,
2873 AuthenticationResponse
&$response,
2876 $oldResponse = $response;
2878 'action' => $action,
2879 'primaryId' => $primaryId,
2881 $proceed = $this->getHookRunner()->onAuthManagerVerifyAuthentication( $user, $response, $this, $info );
2882 if ( !( $response instanceof AuthenticationResponse
) ) {
2883 throw new LogicException( '$response must be an AuthenticationResponse' );
2884 } elseif ( $proceed && $response !== $oldResponse ) {
2885 throw new LogicException(
2886 'AuthManagerVerifyAuthenticationHook must not modify the response unless it returns false' );
2887 } elseif ( !$proceed && $response->status
!== AuthenticationResponse
::FAIL
) {
2888 throw new LogicException(
2889 'AuthManagerVerifyAuthenticationHook must set the response to FAIL if it returns false' );
2892 $this->logger
->info(
2893 $action . ' action for {user} from {clientip} prevented by '
2894 . 'AuthManagerVerifyAuthentication hook: {reason}',
2896 'user' => $user ?
$user->getName() : '<null>',
2897 'clientip' => $this->request
->getIP(),
2898 'reason' => $response->message
->getKey(),
2899 'primaryId' => $primaryId,
2907 * @param int $which Bitmask of values of the self::CALL_* constants
2908 * @param string $method
2909 * @param array $args
2911 private function callMethodOnProviders( $which, $method, array $args ) {
2913 if ( $which & self
::CALL_PRE
) {
2914 $providers +
= $this->getPreAuthenticationProviders();
2916 if ( $which & self
::CALL_PRIMARY
) {
2917 $providers +
= $this->getPrimaryAuthenticationProviders();
2919 if ( $which & self
::CALL_SECONDARY
) {
2920 $providers +
= $this->getSecondaryAuthenticationProviders();
2922 foreach ( $providers as $provider ) {
2923 $provider->$method( ...$args );
2928 * @return HookContainer
2930 private function getHookContainer() {
2931 return $this->hookContainer
;
2935 * @return HookRunner
2937 private function getHookRunner() {
2938 return $this->hookRunner
;
2941 // endregion -- end of Internal methods
2946 * This file uses VisualStudio style region/endregion fold markers which are
2947 * recognised by PHPStorm. If modelines are enabled, the following editor
2948 * configuration will also enable folding in vim, if it is in the last 5 lines
2949 * of the file. We also use "@name" which creates sections in Doxygen.
2951 * vim: foldmarker=//\ region,//\ endregion foldmethod=marker