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
;
27 use MediaWiki\Block\BlockManager
;
28 use MediaWiki\Config\Config
;
29 use MediaWiki\HookContainer\HookContainer
;
30 use MediaWiki\HookContainer\HookRunner
;
31 use MediaWiki\Languages\LanguageConverterFactory
;
32 use MediaWiki\MainConfigNames
;
33 use MediaWiki\Page\PageIdentity
;
34 use MediaWiki\Permissions\Authority
;
35 use MediaWiki\Permissions\PermissionStatus
;
36 use MediaWiki\Request\WebRequest
;
37 use MediaWiki\SpecialPage\SpecialPage
;
38 use MediaWiki\Status\Status
;
39 use MediaWiki\User\BotPasswordStore
;
40 use MediaWiki\User\TempUser\TempUserCreator
;
41 use MediaWiki\User\User
;
42 use MediaWiki\User\UserFactory
;
43 use MediaWiki\User\UserIdentity
;
44 use MediaWiki\User\UserIdentityLookup
;
45 use MediaWiki\User\UserNameUtils
;
46 use MediaWiki\User\UserOptionsManager
;
47 use MediaWiki\User\UserRigorOptions
;
48 use MediaWiki\Watchlist\WatchlistManager
;
49 use Psr\Log\LoggerAwareInterface
;
50 use Psr\Log\LoggerInterface
;
51 use Psr\Log\NullLogger
;
53 use Wikimedia\ObjectFactory\ObjectFactory
;
54 use Wikimedia\Rdbms\ILoadBalancer
;
55 use Wikimedia\Rdbms\ReadOnlyMode
;
56 use Wikimedia\ScopedCallback
;
59 * This serves as the entry point to the authentication system.
61 * In the future, it may also serve as the entry point to the authorization
64 * If you are looking at this because you are working on an extension that creates its own
65 * login or signup page, then 1) you really shouldn't do that, 2) if you feel you absolutely
66 * have to, subclass AuthManagerSpecialPage or build it on the client side using the clientlogin
67 * or the createaccount API. Trying to call this class directly will very likely end up in
68 * security vulnerabilities or broken UX in edge cases.
70 * If you are working on an extension that needs to integrate with the authentication system
71 * (e.g. by providing a new login method, or doing extra permission checks), you'll probably
72 * need to write an AuthenticationProvider.
74 * If you want to create a "reserved" user programmatically, User::newSystemUser() might be what
75 * you are looking for. If you want to change user data, use User::changeAuthenticationData().
76 * Code that is related to some SessionProvider or PrimaryAuthenticationProvider can
77 * create a (non-reserved) user by calling AuthManager::autoCreateUser(); it is then the provider's
78 * responsibility to ensure that the user can authenticate somehow (see especially
79 * PrimaryAuthenticationProvider::autoCreatedAccount()). The same functionality can also be used
80 * from Maintenance scripts such as createAndPromote.php.
81 * If you are writing code that is not associated with such a provider and needs to create accounts
82 * programmatically for real users, you should rethink your architecture. There is no good way to
83 * do that as such code has no knowledge of what authentication methods are enabled on the wiki and
84 * cannot provide any means for users to access the accounts it would create.
86 * The two main control flows when using this class are as follows:
87 * * Login, user creation or account linking code will call getAuthenticationRequests(), populate
88 * the requests with data (by using them to build a HTMLForm and have the user fill it, or by
89 * exposing a form specification via the API, so that the client can build it), and pass them to
90 * the appropriate begin* method. That will return either a success/failure response, or more
91 * requests to fill (either by building a form or by redirecting the user to some external
92 * provider which will send the data back), in which case they need to be submitted to the
93 * appropriate continue* method and that step has to be repeated until the response is a success
94 * or failure response. AuthManager will use the session to maintain internal state during the
96 * * Code doing an authentication data change will call getAuthenticationRequests(), select
97 * a single request, populate it, and pass it to allowsAuthenticationDataChange() and then
98 * changeAuthenticationData(). If the data change is user-initiated, the whole process needs
99 * to be preceded by a call to securitySensitiveOperationStatus() and aborted if that returns
104 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
106 class AuthManager
implements LoggerAwareInterface
{
109 * Key in the user's session data for storing login state.
111 public const AUTHN_STATE
= 'AuthManager::authnState';
115 * Key in the user's session data for storing account creation state.
117 public const ACCOUNT_CREATION_STATE
= 'AuthManager::accountCreationState';
121 * Key in the user's session data for storing account linking state.
123 public const ACCOUNT_LINK_STATE
= 'AuthManager::accountLinkState';
127 * Key in the user's session data for storing autocreation failures,
128 * to avoid re-attempting expensive autocreation checks on every request.
130 public const AUTOCREATE_BLOCKLIST
= 'AuthManager::AutoCreateBlacklist';
132 /** Log in with an existing (not necessarily local) user */
133 public const ACTION_LOGIN
= 'login';
134 /** Continue a login process that was interrupted by the need for user input or communication
135 * with an external provider
137 public const ACTION_LOGIN_CONTINUE
= 'login-continue';
138 /** Create a new user */
139 public const ACTION_CREATE
= 'create';
140 /** Continue a user creation process that was interrupted by the need for user input or
141 * communication with an external provider
143 public const ACTION_CREATE_CONTINUE
= 'create-continue';
144 /** Link an existing user to a third-party account */
145 public const ACTION_LINK
= 'link';
146 /** Continue a user linking process that was interrupted by the need for user input or
147 * communication with an external provider
149 public const ACTION_LINK_CONTINUE
= 'link-continue';
150 /** Change a user's credentials */
151 public const ACTION_CHANGE
= 'change';
152 /** Remove a user's credentials */
153 public const ACTION_REMOVE
= 'remove';
154 /** Like ACTION_REMOVE but for linking providers only */
155 public const ACTION_UNLINK
= 'unlink';
157 /** Security-sensitive operations are ok. */
158 public const SEC_OK
= 'ok';
159 /** Security-sensitive operations should re-authenticate. */
160 public const SEC_REAUTH
= 'reauth';
161 /** Security-sensitive should not be performed. */
162 public const SEC_FAIL
= 'fail';
164 /** Auto-creation is due to SessionManager */
165 public const AUTOCREATE_SOURCE_SESSION
= \MediaWiki\Session\SessionManager
::class;
167 /** Auto-creation is due to a Maintenance script */
168 public const AUTOCREATE_SOURCE_MAINT
= '::Maintenance::';
170 /** Auto-creation is due to temporary account creation on page save */
171 public const AUTOCREATE_SOURCE_TEMP
= TempUserCreator
::class;
173 /** @var WebRequest */
179 /** @var ObjectFactory */
180 private $objectFactory;
182 /** @var LoggerInterface */
185 /** @var UserNameUtils */
186 private $userNameUtils;
188 /** @var AuthenticationProvider[] */
189 private $allAuthenticationProviders = [];
191 /** @var PreAuthenticationProvider[] */
192 private $preAuthenticationProviders = null;
194 /** @var PrimaryAuthenticationProvider[] */
195 private $primaryAuthenticationProviders = null;
197 /** @var SecondaryAuthenticationProvider[] */
198 private $secondaryAuthenticationProviders = null;
200 /** @var CreatedAccountAuthenticationRequest[] */
201 private $createdAccountAuthenticationRequests = [];
203 /** @var HookContainer */
204 private $hookContainer;
206 /** @var HookRunner */
209 /** @var ReadOnlyMode */
210 private $readOnlyMode;
212 /** @var BlockManager */
213 private $blockManager;
215 /** @var WatchlistManager */
216 private $watchlistManager;
218 /** @var ILoadBalancer */
219 private $loadBalancer;
222 private $contentLanguage;
224 /** @var LanguageConverterFactory */
225 private $languageConverterFactory;
227 /** @var BotPasswordStore */
228 private $botPasswordStore;
230 /** @var UserFactory */
231 private $userFactory;
233 /** @var UserIdentityLookup */
234 private $userIdentityLookup;
236 /** @var UserOptionsManager */
237 private $userOptionsManager;
240 * @param WebRequest $request
241 * @param Config $config
242 * @param ObjectFactory $objectFactory
243 * @param HookContainer $hookContainer
244 * @param ReadOnlyMode $readOnlyMode
245 * @param UserNameUtils $userNameUtils
246 * @param BlockManager $blockManager
247 * @param WatchlistManager $watchlistManager
248 * @param ILoadBalancer $loadBalancer
249 * @param Language $contentLanguage
250 * @param LanguageConverterFactory $languageConverterFactory
251 * @param BotPasswordStore $botPasswordStore
252 * @param UserFactory $userFactory
253 * @param UserIdentityLookup $userIdentityLookup
254 * @param UserOptionsManager $userOptionsManager
256 public function __construct(
259 ObjectFactory
$objectFactory,
260 HookContainer
$hookContainer,
261 ReadOnlyMode
$readOnlyMode,
262 UserNameUtils
$userNameUtils,
263 BlockManager
$blockManager,
264 WatchlistManager
$watchlistManager,
265 ILoadBalancer
$loadBalancer,
266 Language
$contentLanguage,
267 LanguageConverterFactory
$languageConverterFactory,
268 BotPasswordStore
$botPasswordStore,
269 UserFactory
$userFactory,
270 UserIdentityLookup
$userIdentityLookup,
271 UserOptionsManager
$userOptionsManager
273 $this->request
= $request;
274 $this->config
= $config;
275 $this->objectFactory
= $objectFactory;
276 $this->hookContainer
= $hookContainer;
277 $this->hookRunner
= new HookRunner( $hookContainer );
278 $this->setLogger( new NullLogger() );
279 $this->readOnlyMode
= $readOnlyMode;
280 $this->userNameUtils
= $userNameUtils;
281 $this->blockManager
= $blockManager;
282 $this->watchlistManager
= $watchlistManager;
283 $this->loadBalancer
= $loadBalancer;
284 $this->contentLanguage
= $contentLanguage;
285 $this->languageConverterFactory
= $languageConverterFactory;
286 $this->botPasswordStore
= $botPasswordStore;
287 $this->userFactory
= $userFactory;
288 $this->userIdentityLookup
= $userIdentityLookup;
289 $this->userOptionsManager
= $userOptionsManager;
293 * @param LoggerInterface $logger
295 public function setLogger( LoggerInterface
$logger ) {
296 $this->logger
= $logger;
302 public function getRequest() {
303 return $this->request
;
307 * Force certain PrimaryAuthenticationProviders
308 * @deprecated For backwards compatibility only
309 * @param PrimaryAuthenticationProvider[] $providers
312 public function forcePrimaryAuthenticationProviders( array $providers, $why ) {
313 $this->logger
->warning( "Overriding AuthManager primary authn because $why" );
315 if ( $this->primaryAuthenticationProviders
!== null ) {
316 $this->logger
->warning(
317 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
320 $this->allAuthenticationProviders
= array_diff_key(
321 $this->allAuthenticationProviders
,
322 $this->primaryAuthenticationProviders
324 $session = $this->request
->getSession();
325 $session->remove( self
::AUTHN_STATE
);
326 $session->remove( self
::ACCOUNT_CREATION_STATE
);
327 $session->remove( self
::ACCOUNT_LINK_STATE
);
328 $this->createdAccountAuthenticationRequests
= [];
331 $this->primaryAuthenticationProviders
= [];
332 foreach ( $providers as $provider ) {
333 if ( !$provider instanceof AbstractPrimaryAuthenticationProvider
) {
334 throw new \
RuntimeException(
335 'Expected instance of MediaWiki\\Auth\\AbstractPrimaryAuthenticationProvider, got ' .
336 get_class( $provider )
339 $provider->init( $this->logger
, $this, $this->hookContainer
, $this->config
, $this->userNameUtils
);
340 $id = $provider->getUniqueId();
341 if ( isset( $this->allAuthenticationProviders
[$id] ) ) {
342 throw new \
RuntimeException(
343 "Duplicate specifications for id $id (classes " .
344 get_class( $provider ) . ' and ' .
345 get_class( $this->allAuthenticationProviders
[$id] ) . ')'
348 $this->allAuthenticationProviders
[$id] = $provider;
349 $this->primaryAuthenticationProviders
[$id] = $provider;
353 /***************************************************************************/
354 // region Authentication
355 /** @name Authentication */
358 * Indicate whether user authentication is possible
360 * It may not be if the session is provided by something like OAuth
361 * for which each individual request includes authentication data.
365 public function canAuthenticateNow() {
366 return $this->request
->getSession()->canSetUser();
370 * Start an authentication flow
372 * In addition to the AuthenticationRequests returned by
373 * $this->getAuthenticationRequests(), a client might include a
374 * CreateFromLoginAuthenticationRequest from a previous login attempt to
377 * Instead of the AuthenticationRequests returned by
378 * $this->getAuthenticationRequests(), a client might pass a
379 * CreatedAccountAuthenticationRequest from an account creation that just
380 * succeeded to log in to the just-created account.
382 * @param AuthenticationRequest[] $reqs
383 * @param string $returnToUrl Url that REDIRECT responses should eventually
385 * @return AuthenticationResponse See self::continueAuthentication()
387 public function beginAuthentication( array $reqs, $returnToUrl ) {
388 $session = $this->request
->getSession();
389 if ( !$session->canSetUser() ) {
390 // Caller should have called canAuthenticateNow()
391 $session->remove( self
::AUTHN_STATE
);
392 throw new \
LogicException( 'Authentication is not possible now' );
395 $guessUserName = null;
396 foreach ( $reqs as $req ) {
397 $req->returnToUrl
= $returnToUrl;
398 // @codeCoverageIgnoreStart
399 if ( $req->username
!== null && $req->username
!== '' ) {
400 if ( $guessUserName === null ) {
401 $guessUserName = $req->username
;
402 } elseif ( $guessUserName !== $req->username
) {
403 $guessUserName = null;
407 // @codeCoverageIgnoreEnd
410 // Check for special-case login of a just-created account
411 $req = AuthenticationRequest
::getRequestByClass(
412 $reqs, CreatedAccountAuthenticationRequest
::class
415 if ( !in_array( $req, $this->createdAccountAuthenticationRequests
, true ) ) {
416 throw new \
LogicException(
417 'CreatedAccountAuthenticationRequests are only valid on ' .
418 'the same AuthManager that created the account'
422 $user = $this->userFactory
->newFromName( (string)$req->username
);
423 // @codeCoverageIgnoreStart
425 throw new \
UnexpectedValueException(
426 "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\""
428 } elseif ( $user->getId() != $req->id
) {
429 throw new \
UnexpectedValueException(
430 "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}"
433 // @codeCoverageIgnoreEnd
435 $this->logger
->info( 'Logging in {user} after account creation', [
436 'user' => $user->getName(),
438 $ret = AuthenticationResponse
::newPass( $user->getName() );
439 $this->setSessionDataForUser( $user );
440 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
441 $session->remove( self
::AUTHN_STATE
);
442 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
443 $ret, $user, $user->getName(), [] );
447 $this->removeAuthenticationSessionData( null );
449 foreach ( $this->getPreAuthenticationProviders() as $provider ) {
450 $status = $provider->testForAuthentication( $reqs );
451 if ( !$status->isGood() ) {
452 $this->logger
->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() );
453 $ret = AuthenticationResponse
::newFail(
454 Status
::wrap( $status )->getMessage()
456 $this->callMethodOnProviders( 7, 'postAuthentication',
457 [ $this->userFactory
->newFromName( (string)$guessUserName ), $ret ]
459 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit( $ret, null, $guessUserName, [] );
466 'returnToUrl' => $returnToUrl,
467 'guessUserName' => $guessUserName,
469 'primaryResponse' => null,
472 'continueRequests' => [],
475 // Preserve state from a previous failed login
476 $req = AuthenticationRequest
::getRequestByClass(
477 $reqs, CreateFromLoginAuthenticationRequest
::class
480 $state['maybeLink'] = $req->maybeLink
;
483 $session = $this->request
->getSession();
484 $session->setSecret( self
::AUTHN_STATE
, $state );
487 return $this->continueAuthentication( $reqs );
491 * Continue an authentication flow
493 * Return values are interpreted as follows:
494 * - status FAIL: Authentication failed. If $response->createRequest is
495 * set, that may be passed to self::beginAuthentication() or to
496 * self::beginAccountCreation() to preserve state.
497 * - status REDIRECT: The client should be redirected to the contained URL,
498 * new AuthenticationRequests should be made (if any), then
499 * AuthManager::continueAuthentication() should be called.
500 * - status UI: The client should be presented with a user interface for
501 * the fields in the specified AuthenticationRequests, then new
502 * AuthenticationRequests should be made, then
503 * AuthManager::continueAuthentication() should be called.
504 * - status RESTART: The user logged in successfully with a third-party
505 * service, but the third-party credentials aren't attached to any local
506 * account. This could be treated as a UI or a FAIL.
507 * - status PASS: Authentication was successful.
509 * @param AuthenticationRequest[] $reqs
510 * @return AuthenticationResponse
512 public function continueAuthentication( array $reqs ) {
513 $session = $this->request
->getSession();
515 if ( !$session->canSetUser() ) {
516 // Caller should have called canAuthenticateNow()
517 // @codeCoverageIgnoreStart
518 throw new \
LogicException( 'Authentication is not possible now' );
519 // @codeCoverageIgnoreEnd
522 $state = $session->getSecret( self
::AUTHN_STATE
);
523 if ( !is_array( $state ) ) {
524 return AuthenticationResponse
::newFail(
525 wfMessage( 'authmanager-authn-not-in-progress' )
528 $state['continueRequests'] = [];
530 $guessUserName = $state['guessUserName'];
532 foreach ( $reqs as $req ) {
533 $req->returnToUrl
= $state['returnToUrl'];
536 // Step 1: Choose a primary authentication provider, and call it until it succeeds.
538 if ( $state['primary'] === null ) {
539 // We haven't picked a PrimaryAuthenticationProvider yet
540 // @codeCoverageIgnoreStart
541 $guessUserName = null;
542 foreach ( $reqs as $req ) {
543 if ( $req->username
!== null && $req->username
!== '' ) {
544 if ( $guessUserName === null ) {
545 $guessUserName = $req->username
;
546 } elseif ( $guessUserName !== $req->username
) {
547 $guessUserName = null;
552 $state['guessUserName'] = $guessUserName;
553 // @codeCoverageIgnoreEnd
554 $state['reqs'] = $reqs;
556 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
557 $res = $provider->beginPrimaryAuthentication( $reqs );
558 switch ( $res->status
) {
559 case AuthenticationResponse
::PASS
:
560 $state['primary'] = $id;
561 $state['primaryResponse'] = $res;
562 $this->logger
->debug( "Primary login with $id succeeded" );
564 case AuthenticationResponse
::FAIL
:
565 $this->logger
->debug( "Login failed in primary authentication by $id" );
566 if ( $res->createRequest ||
$state['maybeLink'] ) {
567 $res->createRequest
= new CreateFromLoginAuthenticationRequest(
568 $res->createRequest
, $state['maybeLink']
571 $this->callMethodOnProviders(
573 'postAuthentication',
575 $this->userFactory
->newFromName( (string)$guessUserName ),
579 $session->remove( self
::AUTHN_STATE
);
580 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
581 $res, null, $guessUserName, [] );
583 case AuthenticationResponse
::ABSTAIN
:
586 case AuthenticationResponse
::REDIRECT
:
587 case AuthenticationResponse
::UI
:
588 $this->logger
->debug( "Primary login with $id returned $res->status" );
589 $this->fillRequests( $res->neededRequests
, self
::ACTION_LOGIN
, $guessUserName );
590 $state['primary'] = $id;
591 $state['continueRequests'] = $res->neededRequests
;
592 $session->setSecret( self
::AUTHN_STATE
, $state );
595 // @codeCoverageIgnoreStart
597 throw new \
DomainException(
598 get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status"
600 // @codeCoverageIgnoreEnd
603 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
604 if ( $state['primary'] === null ) {
605 $this->logger
->debug( 'Login failed in primary authentication because no provider accepted' );
606 $ret = AuthenticationResponse
::newFail(
607 wfMessage( 'authmanager-authn-no-primary' )
609 $this->callMethodOnProviders( 7, 'postAuthentication',
610 [ $this->userFactory
->newFromName( (string)$guessUserName ), $ret ]
612 $session->remove( self
::AUTHN_STATE
);
615 } elseif ( $state['primaryResponse'] === null ) {
616 $provider = $this->getAuthenticationProvider( $state['primary'] );
617 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
618 // Configuration changed? Force them to start over.
619 // @codeCoverageIgnoreStart
620 $ret = AuthenticationResponse
::newFail(
621 wfMessage( 'authmanager-authn-not-in-progress' )
623 $this->callMethodOnProviders( 7, 'postAuthentication',
624 [ $this->userFactory
->newFromName( (string)$guessUserName ), $ret ]
626 $session->remove( self
::AUTHN_STATE
);
628 // @codeCoverageIgnoreEnd
630 $id = $provider->getUniqueId();
631 $res = $provider->continuePrimaryAuthentication( $reqs );
632 switch ( $res->status
) {
633 case AuthenticationResponse
::PASS
:
634 $state['primaryResponse'] = $res;
635 $this->logger
->debug( "Primary login with $id succeeded" );
637 case AuthenticationResponse
::FAIL
:
638 $this->logger
->debug( "Login failed in primary authentication by $id" );
639 if ( $res->createRequest ||
$state['maybeLink'] ) {
640 $res->createRequest
= new CreateFromLoginAuthenticationRequest(
641 $res->createRequest
, $state['maybeLink']
644 $this->callMethodOnProviders( 7, 'postAuthentication',
645 [ $this->userFactory
->newFromName( (string)$guessUserName ), $res ]
647 $session->remove( self
::AUTHN_STATE
);
648 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
649 $res, null, $guessUserName, [] );
651 case AuthenticationResponse
::REDIRECT
:
652 case AuthenticationResponse
::UI
:
653 $this->logger
->debug( "Primary login with $id returned $res->status" );
654 $this->fillRequests( $res->neededRequests
, self
::ACTION_LOGIN
, $guessUserName );
655 $state['continueRequests'] = $res->neededRequests
;
656 $session->setSecret( self
::AUTHN_STATE
, $state );
659 throw new \
DomainException(
660 get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status"
665 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
666 $res = $state['primaryResponse'];
667 if ( $res->username
=== null ) {
668 $provider = $this->getAuthenticationProvider( $state['primary'] );
669 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
670 // Configuration changed? Force them to start over.
671 // @codeCoverageIgnoreStart
672 $ret = AuthenticationResponse
::newFail(
673 wfMessage( 'authmanager-authn-not-in-progress' )
675 $this->callMethodOnProviders( 7, 'postAuthentication',
676 [ $this->userFactory
->newFromName( (string)$guessUserName ), $ret ]
678 $session->remove( self
::AUTHN_STATE
);
680 // @codeCoverageIgnoreEnd
683 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
&&
685 // don't confuse the user with an incorrect message if linking is disabled
686 $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider
::class )
688 $state['maybeLink'][$res->linkRequest
->getUniqueId()] = $res->linkRequest
;
689 $msg = 'authmanager-authn-no-local-user-link';
691 $msg = 'authmanager-authn-no-local-user';
693 $this->logger
->debug(
694 "Primary login with {$provider->getUniqueId()} succeeded, but returned no user"
696 $ret = AuthenticationResponse
::newRestart( wfMessage( $msg ) );
697 $ret->neededRequests
= $this->getAuthenticationRequestsInternal(
700 $this->getPrimaryAuthenticationProviders() +
$this->getSecondaryAuthenticationProviders()
702 if ( $res->createRequest ||
$state['maybeLink'] ) {
703 $ret->createRequest
= new CreateFromLoginAuthenticationRequest(
704 $res->createRequest
, $state['maybeLink']
706 $ret->neededRequests
[] = $ret->createRequest
;
708 $this->fillRequests( $ret->neededRequests
, self
::ACTION_LOGIN
, null, true );
709 $session->setSecret( self
::AUTHN_STATE
, [
710 'reqs' => [], // Will be filled in later
712 'primaryResponse' => null,
714 'continueRequests' => $ret->neededRequests
,
719 // Step 2: Primary authentication succeeded, create the User object
720 // (and add the user locally if necessary)
722 $user = $this->userFactory
->newFromName(
723 (string)$res->username
,
724 UserRigorOptions
::RIGOR_USABLE
727 $provider = $this->getAuthenticationProvider( $state['primary'] );
728 throw new \
DomainException(
729 get_class( $provider ) . " returned an invalid username: {$res->username}"
732 if ( !$user->isRegistered() ) {
733 // User doesn't exist locally. Create it.
734 $this->logger
->info( 'Auto-creating {user} on login', [
735 'user' => $user->getName(),
737 $status = $this->autoCreateUser( $user, $state['primary'], false );
738 if ( !$status->isGood() ) {
739 $ret = AuthenticationResponse
::newFail(
740 Status
::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' )
742 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
743 $session->remove( self
::AUTHN_STATE
);
744 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
745 $ret, $user, $user->getName(), [] );
750 // Step 3: Iterate over all the secondary authentication providers.
752 $beginReqs = $state['reqs'];
754 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
755 if ( !isset( $state['secondary'][$id] ) ) {
756 // This provider isn't started yet, so we pass it the set
757 // of reqs from beginAuthentication instead of whatever
758 // might have been used by a previous provider in line.
759 $func = 'beginSecondaryAuthentication';
760 $res = $provider->beginSecondaryAuthentication( $user, $beginReqs );
761 } elseif ( !$state['secondary'][$id] ) {
762 $func = 'continueSecondaryAuthentication';
763 $res = $provider->continueSecondaryAuthentication( $user, $reqs );
767 switch ( $res->status
) {
768 case AuthenticationResponse
::PASS
:
769 $this->logger
->debug( "Secondary login with $id succeeded" );
771 case AuthenticationResponse
::ABSTAIN
:
772 $state['secondary'][$id] = true;
774 case AuthenticationResponse
::FAIL
:
775 $this->logger
->debug( "Login failed in secondary authentication by $id" );
776 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] );
777 $session->remove( self
::AUTHN_STATE
);
778 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
779 $res, $user, $user->getName(), [] );
781 case AuthenticationResponse
::REDIRECT
:
782 case AuthenticationResponse
::UI
:
783 $this->logger
->debug( "Secondary login with $id returned " . $res->status
);
784 $this->fillRequests( $res->neededRequests
, self
::ACTION_LOGIN
, $user->getName() );
785 $state['secondary'][$id] = false;
786 $state['continueRequests'] = $res->neededRequests
;
787 $session->setSecret( self
::AUTHN_STATE
, $state );
790 // @codeCoverageIgnoreStart
792 throw new \
DomainException(
793 get_class( $provider ) . "::{$func}() returned $res->status"
795 // @codeCoverageIgnoreEnd
799 // Step 4: Authentication complete! Set the user in the session and
802 $this->logger
->info( 'Login for {user} succeeded from {clientip}', [
803 'user' => $user->getName(),
804 'clientip' => $this->request
->getIP(),
806 $rememberMeConfig = $this->config
->get( MainConfigNames
::RememberMe
);
807 if ( $rememberMeConfig === RememberMeAuthenticationRequest
::ALWAYS_REMEMBER
) {
809 } elseif ( $rememberMeConfig === RememberMeAuthenticationRequest
::NEVER_REMEMBER
) {
812 /** @var RememberMeAuthenticationRequest $req */
813 $req = AuthenticationRequest
::getRequestByClass(
814 $beginReqs, RememberMeAuthenticationRequest
::class
816 $rememberMe = $req && $req->rememberMe
;
818 $this->setSessionDataForUser( $user, $rememberMe );
819 $ret = AuthenticationResponse
::newPass( $user->getName() );
820 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
821 $session->remove( self
::AUTHN_STATE
);
822 $this->removeAuthenticationSessionData( null );
823 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
824 $ret, $user, $user->getName(), [] );
826 } catch ( \Exception
$ex ) {
827 $session->remove( self
::AUTHN_STATE
);
833 * Whether security-sensitive operations should proceed.
835 * A "security-sensitive operation" is something like a password or email
836 * change, that would normally have a "reenter your password to confirm"
837 * box if we only supported password-based authentication.
839 * @param string $operation Operation being checked. This should be a
840 * message-key-like string such as 'change-password' or 'change-email'.
841 * @return string One of the SEC_* constants.
843 public function securitySensitiveOperationStatus( $operation ) {
844 $status = self
::SEC_OK
;
846 $this->logger
->debug( __METHOD__
. ": Checking $operation" );
848 $session = $this->request
->getSession();
849 $aId = $session->getUser()->getId();
851 // User isn't authenticated. DWIM?
852 $status = $this->canAuthenticateNow() ? self
::SEC_REAUTH
: self
::SEC_FAIL
;
853 $this->logger
->info( __METHOD__
. ": Not logged in! $operation is $status" );
857 if ( $session->canSetUser() ) {
858 $id = $session->get( 'AuthManager:lastAuthId' );
859 $last = $session->get( 'AuthManager:lastAuthTimestamp' );
860 if ( $id !== $aId ||
$last === null ) {
861 $timeSinceLogin = PHP_INT_MAX
; // Forever ago
863 $timeSinceLogin = max( 0, time() - $last );
866 $thresholds = $this->config
->get( MainConfigNames
::ReauthenticateTime
);
867 if ( isset( $thresholds[$operation] ) ) {
868 $threshold = $thresholds[$operation];
869 } elseif ( isset( $thresholds['default'] ) ) {
870 $threshold = $thresholds['default'];
872 throw new \
UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
875 if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
876 $status = self
::SEC_REAUTH
;
879 $timeSinceLogin = -1;
881 $pass = $this->config
->get(
882 MainConfigNames
::AllowSecuritySensitiveOperationIfCannotReauthenticate
);
883 if ( isset( $pass[$operation] ) ) {
884 $status = $pass[$operation] ? self
::SEC_OK
: self
::SEC_FAIL
;
885 } elseif ( isset( $pass['default'] ) ) {
886 $status = $pass['default'] ? self
::SEC_OK
: self
::SEC_FAIL
;
888 throw new \
UnexpectedValueException(
889 '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default'
894 $this->getHookRunner()->onSecuritySensitiveOperationStatus(
895 $status, $operation, $session, $timeSinceLogin );
897 // If authentication is not possible, downgrade from "REAUTH" to "FAIL".
898 if ( !$this->canAuthenticateNow() && $status === self
::SEC_REAUTH
) {
899 $status = self
::SEC_FAIL
;
902 $this->logger
->info( __METHOD__
. ": $operation is $status for '{user}'",
904 'user' => $session->getUser()->getName(),
905 'clientip' => $this->getRequest()->getIP(),
913 * Determine whether a username can authenticate
915 * This is mainly for internal purposes and only takes authentication data into account,
916 * not things like blocks that can change without the authentication system being aware.
918 * @param string $username MediaWiki username
921 public function userCanAuthenticate( $username ) {
922 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
923 if ( $provider->testUserCanAuthenticate( $username ) ) {
931 * Provide normalized versions of the username for security checks
933 * Since different providers can normalize the input in different ways,
934 * this returns an array of all the different ways the name might be
935 * normalized for authentication.
937 * The returned strings should not be revealed to the user, as that might
938 * leak private information (e.g. an email address might be normalized to a
941 * @param string $username
944 public function normalizeUsername( $username ) {
946 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
947 $normalized = $provider->providerNormalizeUsername( $username );
948 if ( $normalized !== null ) {
949 $ret[$normalized] = true;
952 return array_keys( $ret );
955 // endregion -- end of Authentication
957 /***************************************************************************/
958 // region Authentication data changing
959 /** @name Authentication data changing */
962 * Revoke any authentication credentials for a user
964 * After this, the user should no longer be able to log in.
966 * @param string $username
968 public function revokeAccessForUser( $username ) {
969 $this->logger
->info( 'Revoking access for {user}', [
972 $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] );
976 * Validate a change of authentication data (e.g. passwords)
977 * @param AuthenticationRequest $req
978 * @param bool $checkData If false, $req hasn't been loaded from the
979 * submission so checks on user-submitted fields should be skipped. $req->username is
980 * considered user-submitted for this purpose, even if it cannot be changed via
981 * $req->loadFromSubmission.
984 public function allowsAuthenticationDataChange( AuthenticationRequest
$req, $checkData = true ) {
986 $providers = $this->getPrimaryAuthenticationProviders() +
987 $this->getSecondaryAuthenticationProviders();
989 foreach ( $providers as $provider ) {
990 $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData );
991 if ( !$status->isGood() ) {
992 // If status is not good because reset email password last attempt was within
993 // $wgPasswordReminderResendTime then return good status with throttled-mailpassword value;
994 // otherwise, return the $status wrapped.
995 return $status->hasMessage( 'throttled-mailpassword' )
996 ? Status
::newGood( 'throttled-mailpassword' )
997 : Status
::wrap( $status );
999 $any = $any ||
$status->value
!== 'ignored';
1002 return Status
::newGood( 'ignored' )
1003 ->warning( 'authmanager-change-not-supported' );
1005 return Status
::newGood();
1009 * Change authentication data (e.g. passwords)
1011 * If $req was returned for AuthManager::ACTION_CHANGE, using $req should
1012 * result in a successful login in the future.
1014 * If $req was returned for AuthManager::ACTION_REMOVE, using $req should
1015 * no longer result in a successful login.
1017 * This method should only be called if allowsAuthenticationDataChange( $req, true )
1020 * @param AuthenticationRequest $req
1021 * @param bool $isAddition Set true if this represents an addition of
1022 * credentials rather than a change. The main difference is that additions
1023 * should not invalidate BotPasswords. If you're not sure, leave it false.
1025 public function changeAuthenticationData( AuthenticationRequest
$req, $isAddition = false ) {
1026 $this->logger
->info( 'Changing authentication data for {user} class {what}', [
1027 'user' => is_string( $req->username
) ?
$req->username
: '<no name>',
1028 'what' => get_class( $req ),
1031 $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] );
1033 // When the main account's authentication data is changed, invalidate
1034 // all BotPasswords too.
1035 if ( !$isAddition ) {
1036 $this->botPasswordStore
->invalidateUserPasswords( (string)$req->username
);
1040 // endregion -- end of Authentication data changing
1042 /***************************************************************************/
1043 // region Account creation
1044 /** @name Account creation */
1047 * Determine whether accounts can be created
1050 public function canCreateAccounts() {
1051 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1052 switch ( $provider->accountCreationType() ) {
1053 case PrimaryAuthenticationProvider
::TYPE_CREATE
:
1054 case PrimaryAuthenticationProvider
::TYPE_LINK
:
1062 * Determine whether a particular account can be created
1063 * @param string $username MediaWiki username
1064 * @param array $options
1065 * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
1066 * - creating: (bool) For internal use only. Never specify this.
1069 public function canCreateAccount( $username, $options = [] ) {
1071 if ( is_int( $options ) ) {
1072 $options = [ 'flags' => $options ];
1075 'flags' => User
::READ_NORMAL
,
1076 'creating' => false,
1078 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1079 $flags = $options['flags'];
1081 if ( !$this->canCreateAccounts() ) {
1082 return Status
::newFatal( 'authmanager-create-disabled' );
1085 if ( $this->userExists( $username, $flags ) ) {
1086 return Status
::newFatal( 'userexists' );
1089 $user = $this->userFactory
->newFromName( (string)$username, UserRigorOptions
::RIGOR_CREATABLE
);
1090 if ( !is_object( $user ) ) {
1091 return Status
::newFatal( 'noname' );
1093 $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL
1094 if ( $user->isRegistered() ) {
1095 return Status
::newFatal( 'userexists' );
1099 // Denied by providers?
1100 $providers = $this->getPreAuthenticationProviders() +
1101 $this->getPrimaryAuthenticationProviders() +
1102 $this->getSecondaryAuthenticationProviders();
1103 foreach ( $providers as $provider ) {
1104 $status = $provider->testUserForCreation( $user, false, $options );
1105 if ( !$status->isGood() ) {
1106 return Status
::wrap( $status );
1110 return Status
::newGood();
1114 * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
1115 * @return StatusValue
1117 private function authorizeInternal(
1118 callable
$authorizer
1120 // Wiki is read-only?
1121 if ( $this->readOnlyMode
->isReadOnly() ) {
1122 return StatusValue
::newFatal( wfMessage( 'readonlytext', $this->readOnlyMode
->getReason() ) );
1125 $permStatus = new PermissionStatus();
1128 SpecialPage
::getTitleFor( 'CreateAccount' ),
1134 $ip = $this->getRequest()->getIP();
1135 if ( $this->blockManager
->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
1136 return StatusValue
::newFatal( 'sorbs_create_account_reason' );
1139 return StatusValue
::newGood();
1143 * Check whether $creator can create accounts.
1145 * @note this method does not guarantee full permissions check, so it should only
1146 * be used to to decide whether to show a form. To authorize the account creation
1147 * action use {@link self::authorizeCreateAccount} instead.
1150 * @param Authority $creator
1151 * @return StatusValue
1153 public function probablyCanCreateAccount( Authority
$creator ): StatusValue
{
1154 return $this->authorizeInternal(
1157 PageIdentity
$target,
1158 PermissionStatus
$status
1159 ) use ( $creator ) {
1160 return $creator->probablyCan( $action, $target, $status );
1166 * Authorize the account creation by $creator
1168 * @note this method should be used right before the account is created.
1169 * To check whether a current performer has the potential to create accounts,
1170 * use {@link self::probablyCanCreateAccount} instead.
1173 * @param Authority $creator
1174 * @return StatusValue
1176 public function authorizeCreateAccount( Authority
$creator ): StatusValue
{
1177 return $this->authorizeInternal(
1180 PageIdentity
$target,
1181 PermissionStatus
$status
1182 ) use ( $creator ) {
1183 return $creator->authorizeWrite( $action, $target, $status );
1189 * Start an account creation flow
1191 * In addition to the AuthenticationRequests returned by
1192 * $this->getAuthenticationRequests(), a client might include a
1193 * CreateFromLoginAuthenticationRequest from a previous login attempt. If
1195 * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE )
1197 * returns true, any AuthenticationRequest::PRIMARY_REQUIRED requests
1198 * should be omitted. If the CreateFromLoginAuthenticationRequest has a
1199 * username set, that username must be used for all other requests.
1201 * @param Authority $creator User doing the account creation
1202 * @param AuthenticationRequest[] $reqs
1203 * @param string $returnToUrl Url that REDIRECT responses should eventually
1205 * @return AuthenticationResponse
1207 public function beginAccountCreation( Authority
$creator, array $reqs, $returnToUrl ) {
1208 $session = $this->request
->getSession();
1209 if ( !$this->canCreateAccounts() ) {
1210 // Caller should have called canCreateAccounts()
1211 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1212 throw new \
LogicException( 'Account creation is not possible' );
1216 $username = AuthenticationRequest
::getUsernameFromRequests( $reqs );
1217 } catch ( \UnexpectedValueException
$ex ) {
1220 if ( $username === null ) {
1221 $this->logger
->debug( __METHOD__
. ': No username provided' );
1222 return AuthenticationResponse
::newFail( wfMessage( 'noname' ) );
1225 // Permissions check
1226 $status = Status
::wrap( $this->authorizeCreateAccount( $creator ) );
1227 if ( !$status->isGood() ) {
1228 $this->logger
->debug( __METHOD__
. ': {creator} cannot create users: {reason}', [
1229 'user' => $username,
1230 'creator' => $creator->getUser()->getName(),
1231 'reason' => $status->getWikiText( false, false, 'en' )
1233 return AuthenticationResponse
::newFail( $status->getMessage() );
1236 $status = $this->canCreateAccount(
1237 $username, [ 'flags' => User
::READ_LOCKING
, 'creating' => true ]
1239 if ( !$status->isGood() ) {
1240 $this->logger
->debug( __METHOD__
. ': {user} cannot be created: {reason}', [
1241 'user' => $username,
1242 'creator' => $creator->getUser()->getName(),
1243 'reason' => $status->getWikiText( false, false, 'en' )
1245 return AuthenticationResponse
::newFail( $status->getMessage() );
1248 $user = $this->userFactory
->newFromName( (string)$username, UserRigorOptions
::RIGOR_CREATABLE
);
1249 foreach ( $reqs as $req ) {
1250 $req->username
= $username;
1251 $req->returnToUrl
= $returnToUrl;
1252 if ( $req instanceof UserDataAuthenticationRequest
) {
1253 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable user should be checked and valid here
1254 $status = $req->populateUser( $user );
1255 if ( !$status->isGood() ) {
1256 $status = Status
::wrap( $status );
1257 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1258 $this->logger
->debug( __METHOD__
. ': UserData is invalid: {reason}', [
1259 'user' => $user->getName(),
1260 'creator' => $creator->getUser()->getName(),
1261 'reason' => $status->getWikiText( false, false, 'en' ),
1263 return AuthenticationResponse
::newFail( $status->getMessage() );
1268 $this->removeAuthenticationSessionData( null );
1271 'username' => $username,
1273 'creatorid' => $creator->getUser()->getId(),
1274 'creatorname' => $creator->getUser()->getName(),
1276 'returnToUrl' => $returnToUrl,
1278 'primaryResponse' => null,
1280 'continueRequests' => [],
1282 'ranPreTests' => false,
1285 // Special case: converting a login to an account creation
1286 $req = AuthenticationRequest
::getRequestByClass(
1287 $reqs, CreateFromLoginAuthenticationRequest
::class
1290 $state['maybeLink'] = $req->maybeLink
;
1292 if ( $req->createRequest
) {
1293 $reqs[] = $req->createRequest
;
1294 $state['reqs'][] = $req->createRequest
;
1298 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1299 $session->persist();
1301 return $this->continueAccountCreation( $reqs );
1305 * Continue an account creation flow
1306 * @param AuthenticationRequest[] $reqs
1307 * @return AuthenticationResponse
1309 public function continueAccountCreation( array $reqs ) {
1310 $session = $this->request
->getSession();
1312 if ( !$this->canCreateAccounts() ) {
1313 // Caller should have called canCreateAccounts()
1314 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1315 throw new \
LogicException( 'Account creation is not possible' );
1318 $state = $session->getSecret( self
::ACCOUNT_CREATION_STATE
);
1319 if ( !is_array( $state ) ) {
1320 return AuthenticationResponse
::newFail(
1321 wfMessage( 'authmanager-create-not-in-progress' )
1324 $state['continueRequests'] = [];
1326 // Step 0: Prepare and validate the input
1328 $user = $this->userFactory
->newFromName(
1329 (string)$state['username'],
1330 UserRigorOptions
::RIGOR_CREATABLE
1332 if ( !is_object( $user ) ) {
1333 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1334 $this->logger
->debug( __METHOD__
. ': Invalid username', [
1335 'user' => $state['username'],
1337 return AuthenticationResponse
::newFail( wfMessage( 'noname' ) );
1340 if ( $state['creatorid'] ) {
1341 $creator = $this->userFactory
->newFromId( (int)$state['creatorid'] );
1343 $creator = $this->userFactory
->newAnonymous();
1344 $creator->setName( $state['creatorname'] );
1347 // Avoid account creation races on double submissions
1348 $cache = \ObjectCache
::getLocalClusterInstance();
1349 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) );
1351 // Don't clear AuthManager::accountCreationState for this code
1352 // path because the process that won the race owns it.
1353 $this->logger
->debug( __METHOD__
. ': Could not acquire account creation lock', [
1354 'user' => $user->getName(),
1355 'creator' => $creator->getName(),
1357 return AuthenticationResponse
::newFail( wfMessage( 'usernameinprogress' ) );
1360 // Permissions check
1361 $status = Status
::wrap( $this->authorizeCreateAccount( $creator ) );
1362 if ( !$status->isGood() ) {
1363 $this->logger
->debug( __METHOD__
. ': {creator} cannot create users: {reason}', [
1364 'user' => $user->getName(),
1365 'creator' => $creator->getName(),
1366 'reason' => $status->getWikiText( false, false, 'en' )
1368 $ret = AuthenticationResponse
::newFail( $status->getMessage() );
1369 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1370 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1374 // Load from primary DB for existence check
1375 $user->load( User
::READ_LOCKING
);
1377 if ( $state['userid'] === 0 ) {
1378 if ( $user->isRegistered() ) {
1379 $this->logger
->debug( __METHOD__
. ': User exists locally', [
1380 'user' => $user->getName(),
1381 'creator' => $creator->getName(),
1383 $ret = AuthenticationResponse
::newFail( wfMessage( 'userexists' ) );
1384 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1385 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1389 if ( !$user->isRegistered() ) {
1390 $this->logger
->debug( __METHOD__
. ': User does not exist locally when it should', [
1391 'user' => $user->getName(),
1392 'creator' => $creator->getName(),
1393 'expected_id' => $state['userid'],
1395 throw new \
UnexpectedValueException(
1396 "User \"{$state['username']}\" should exist now, but doesn't!"
1399 if ( $user->getId() !== $state['userid'] ) {
1400 $this->logger
->debug( __METHOD__
. ': User ID/name mismatch', [
1401 'user' => $user->getName(),
1402 'creator' => $creator->getName(),
1403 'expected_id' => $state['userid'],
1404 'actual_id' => $user->getId(),
1406 throw new \
UnexpectedValueException(
1407 "User \"{$state['username']}\" exists, but " .
1408 "ID {$user->getId()} !== {$state['userid']}!"
1412 foreach ( $state['reqs'] as $req ) {
1413 if ( $req instanceof UserDataAuthenticationRequest
) {
1414 $status = $req->populateUser( $user );
1415 if ( !$status->isGood() ) {
1416 // This should never happen...
1417 $status = Status
::wrap( $status );
1418 $this->logger
->debug( __METHOD__
. ': UserData is invalid: {reason}', [
1419 'user' => $user->getName(),
1420 'creator' => $creator->getName(),
1421 'reason' => $status->getWikiText( false, false, 'en' ),
1423 $ret = AuthenticationResponse
::newFail( $status->getMessage() );
1424 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1425 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1431 foreach ( $reqs as $req ) {
1432 $req->returnToUrl
= $state['returnToUrl'];
1433 $req->username
= $state['username'];
1436 // Run pre-creation tests, if we haven't already
1437 if ( !$state['ranPreTests'] ) {
1438 $providers = $this->getPreAuthenticationProviders() +
1439 $this->getPrimaryAuthenticationProviders() +
1440 $this->getSecondaryAuthenticationProviders();
1441 foreach ( $providers as $id => $provider ) {
1442 $status = $provider->testForAccountCreation( $user, $creator, $reqs );
1443 if ( !$status->isGood() ) {
1444 $this->logger
->debug( __METHOD__
. ": Fail in pre-authentication by $id", [
1445 'user' => $user->getName(),
1446 'creator' => $creator->getName(),
1448 $ret = AuthenticationResponse
::newFail(
1449 Status
::wrap( $status )->getMessage()
1451 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1452 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1457 $state['ranPreTests'] = true;
1460 // Step 1: Choose a primary authentication provider and call it until it succeeds.
1462 if ( $state['primary'] === null ) {
1463 // We haven't picked a PrimaryAuthenticationProvider yet
1464 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
1465 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_NONE
) {
1468 $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs );
1469 switch ( $res->status
) {
1470 case AuthenticationResponse
::PASS
:
1471 $this->logger
->debug( __METHOD__
. ": Primary creation passed by $id", [
1472 'user' => $user->getName(),
1473 'creator' => $creator->getName(),
1475 $state['primary'] = $id;
1476 $state['primaryResponse'] = $res;
1478 case AuthenticationResponse
::FAIL
:
1479 $this->logger
->debug( __METHOD__
. ": Primary creation failed by $id", [
1480 'user' => $user->getName(),
1481 'creator' => $creator->getName(),
1483 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1484 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1486 case AuthenticationResponse
::ABSTAIN
:
1489 case AuthenticationResponse
::REDIRECT
:
1490 case AuthenticationResponse
::UI
:
1491 $this->logger
->debug( __METHOD__
. ": Primary creation $res->status by $id", [
1492 'user' => $user->getName(),
1493 'creator' => $creator->getName(),
1495 $this->fillRequests( $res->neededRequests
, self
::ACTION_CREATE
, null );
1496 $state['primary'] = $id;
1497 $state['continueRequests'] = $res->neededRequests
;
1498 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1501 // @codeCoverageIgnoreStart
1503 throw new \
DomainException(
1504 get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status"
1506 // @codeCoverageIgnoreEnd
1509 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
1510 if ( $state['primary'] === null ) {
1511 $this->logger
->debug( __METHOD__
. ': Primary creation failed because no provider accepted', [
1512 'user' => $user->getName(),
1513 'creator' => $creator->getName(),
1515 $ret = AuthenticationResponse
::newFail(
1516 wfMessage( 'authmanager-create-no-primary' )
1518 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1519 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1522 } elseif ( $state['primaryResponse'] === null ) {
1523 $provider = $this->getAuthenticationProvider( $state['primary'] );
1524 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
1525 // Configuration changed? Force them to start over.
1526 // @codeCoverageIgnoreStart
1527 $ret = AuthenticationResponse
::newFail(
1528 wfMessage( 'authmanager-create-not-in-progress' )
1530 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1531 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1533 // @codeCoverageIgnoreEnd
1535 $id = $provider->getUniqueId();
1536 $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs );
1537 switch ( $res->status
) {
1538 case AuthenticationResponse
::PASS
:
1539 $this->logger
->debug( __METHOD__
. ": Primary creation passed by $id", [
1540 'user' => $user->getName(),
1541 'creator' => $creator->getName(),
1543 $state['primaryResponse'] = $res;
1545 case AuthenticationResponse
::FAIL
:
1546 $this->logger
->debug( __METHOD__
. ": Primary creation failed by $id", [
1547 'user' => $user->getName(),
1548 'creator' => $creator->getName(),
1550 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1551 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1553 case AuthenticationResponse
::REDIRECT
:
1554 case AuthenticationResponse
::UI
:
1555 $this->logger
->debug( __METHOD__
. ": Primary creation $res->status by $id", [
1556 'user' => $user->getName(),
1557 'creator' => $creator->getName(),
1559 $this->fillRequests( $res->neededRequests
, self
::ACTION_CREATE
, null );
1560 $state['continueRequests'] = $res->neededRequests
;
1561 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1564 throw new \
DomainException(
1565 get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status"
1570 // Step 2: Primary authentication succeeded, create the User object
1571 // and add the user locally.
1573 if ( $state['userid'] === 0 ) {
1574 $this->logger
->info( 'Creating user {user} during account creation', [
1575 'user' => $user->getName(),
1576 'creator' => $creator->getName(),
1578 $status = $user->addToDatabase();
1579 if ( !$status->isOK() ) {
1580 // @codeCoverageIgnoreStart
1581 $ret = AuthenticationResponse
::newFail( $status->getMessage() );
1582 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1583 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1585 // @codeCoverageIgnoreEnd
1587 $this->setDefaultUserOptions( $user, $creator->isAnon() );
1588 $this->getHookRunner()->onLocalUserCreated( $user, false );
1589 $user->saveSettings();
1590 $state['userid'] = $user->getId();
1592 // Update user count
1593 \DeferredUpdates
::addUpdate( \SiteStatsUpdate
::factory( [ 'users' => 1 ] ) );
1595 // Watch user's userpage and talk page
1596 $this->watchlistManager
->addWatchIgnoringRights( $user, $user->getUserPage() );
1598 // Inform the provider
1599 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
1600 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Always set in loop before, if passed
1601 $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] );
1604 if ( $this->config
->get( MainConfigNames
::NewUserLog
) ) {
1605 $isAnon = $creator->isAnon();
1606 $logEntry = new \
ManualLogEntry(
1608 $logSubtype ?
: ( $isAnon ?
'create' : 'create2' )
1610 $logEntry->setPerformer( $isAnon ?
$user : $creator );
1611 $logEntry->setTarget( $user->getUserPage() );
1612 /** @var CreationReasonAuthenticationRequest $req */
1613 $req = AuthenticationRequest
::getRequestByClass(
1614 $state['reqs'], CreationReasonAuthenticationRequest
::class
1616 $logEntry->setComment( $req ?
$req->reason
: '' );
1617 $logEntry->setParameters( [
1618 '4::userid' => $user->getId(),
1620 $logid = $logEntry->insert();
1621 $logEntry->publish( $logid );
1625 // Step 3: Iterate over all the secondary authentication providers.
1627 $beginReqs = $state['reqs'];
1629 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
1630 if ( !isset( $state['secondary'][$id] ) ) {
1631 // This provider isn't started yet, so we pass it the set
1632 // of reqs from beginAuthentication instead of whatever
1633 // might have been used by a previous provider in line.
1634 $func = 'beginSecondaryAccountCreation';
1635 $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs );
1636 } elseif ( !$state['secondary'][$id] ) {
1637 $func = 'continueSecondaryAccountCreation';
1638 $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs );
1642 switch ( $res->status
) {
1643 case AuthenticationResponse
::PASS
:
1644 $this->logger
->debug( __METHOD__
. ": Secondary creation passed by $id", [
1645 'user' => $user->getName(),
1646 'creator' => $creator->getName(),
1649 case AuthenticationResponse
::ABSTAIN
:
1650 $state['secondary'][$id] = true;
1652 case AuthenticationResponse
::REDIRECT
:
1653 case AuthenticationResponse
::UI
:
1654 $this->logger
->debug( __METHOD__
. ": Secondary creation $res->status by $id", [
1655 'user' => $user->getName(),
1656 'creator' => $creator->getName(),
1658 $this->fillRequests( $res->neededRequests
, self
::ACTION_CREATE
, null );
1659 $state['secondary'][$id] = false;
1660 $state['continueRequests'] = $res->neededRequests
;
1661 $session->setSecret( self
::ACCOUNT_CREATION_STATE
, $state );
1663 case AuthenticationResponse
::FAIL
:
1664 throw new \
DomainException(
1665 get_class( $provider ) . "::{$func}() returned $res->status." .
1666 ' Secondary providers are not allowed to fail account creation, that' .
1667 ' should have been done via testForAccountCreation().'
1669 // @codeCoverageIgnoreStart
1671 throw new \
DomainException(
1672 get_class( $provider ) . "::{$func}() returned $res->status"
1674 // @codeCoverageIgnoreEnd
1678 $id = $user->getId();
1679 $name = $user->getName();
1680 $req = new CreatedAccountAuthenticationRequest( $id, $name );
1681 $ret = AuthenticationResponse
::newPass( $name );
1682 $ret->loginRequest
= $req;
1683 $this->createdAccountAuthenticationRequests
[] = $req;
1685 $this->logger
->info( __METHOD__
. ': Account creation succeeded for {user}', [
1686 'user' => $user->getName(),
1687 'creator' => $creator->getName(),
1690 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1691 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1692 $this->removeAuthenticationSessionData( null );
1694 } catch ( \Exception
$ex ) {
1695 $session->remove( self
::ACCOUNT_CREATION_STATE
);
1701 * Auto-create an account, and optionally log into that account
1703 * PrimaryAuthenticationProviders can invoke this method by returning a PASS from
1704 * beginPrimaryAuthentication/continuePrimaryAuthentication with the username of a
1705 * non-existing user. SessionProviders can invoke it by returning a SessionInfo with
1706 * the username of a non-existing user from provideSessionInfo(). Calling this method
1707 * explicitly (e.g. from a maintenance script) is also fine.
1709 * @param User $user User to auto-create
1710 * @param string $source What caused the auto-creation? This must be one of:
1711 * - the ID of a PrimaryAuthenticationProvider,
1712 * - one of the self::AUTOCREATE_SOURCE_* constants
1713 * @param bool $login Whether to also log the user in
1714 * @param bool $log Whether to generate a user creation log entry (since 1.36)
1715 * @return Status Good if user was created, Ok if user already existed, otherwise Fatal
1717 public function autoCreateUser( User
$user, $source, $login = true, $log = true ) {
1719 self
::AUTOCREATE_SOURCE_SESSION
,
1720 self
::AUTOCREATE_SOURCE_MAINT
,
1721 self
::AUTOCREATE_SOURCE_TEMP
1723 if ( !in_array( $source, $validSources, true )
1724 && !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider
1726 throw new \
InvalidArgumentException( "Unknown auto-creation source: $source" );
1729 $username = $user->getName();
1731 // Try the local user from the replica DB
1732 $localUserIdentity = $this->userIdentityLookup
->getUserIdentityByName( $username );
1733 $localId = ( $localUserIdentity && $localUserIdentity->getId() )
1734 ?
$localUserIdentity->getId()
1736 $flags = User
::READ_NORMAL
;
1738 // Fetch the user ID from the primary, so that we don't try to create the user
1739 // when they already exist, due to replication lag
1740 // @codeCoverageIgnoreStart
1743 $this->loadBalancer
->getReaderIndex() !== 0
1745 $localUserIdentity = $this->userIdentityLookup
->getUserIdentityByName(
1747 UserIdentityLookup
::READ_LATEST
1749 $localId = ( $localUserIdentity && $localUserIdentity->getId() )
1750 ?
$localUserIdentity->getId()
1752 $flags = User
::READ_LATEST
;
1754 // @codeCoverageIgnoreEnd
1757 $this->logger
->debug( __METHOD__
. ': {username} already exists locally', [
1758 'username' => $username,
1760 $user->setId( $localId );
1761 $user->loadFromId( $flags );
1763 $this->setSessionDataForUser( $user );
1765 return Status
::newGood()->warning( 'userexists' );
1768 // Wiki is read-only?
1769 if ( $this->readOnlyMode
->isReadOnly() ) {
1770 $reason = $this->readOnlyMode
->getReason();
1771 $this->logger
->debug( __METHOD__
. ': denied because of read only mode: {reason}', [
1772 'username' => $username,
1773 'reason' => $reason,
1776 $user->loadFromId();
1777 return Status
::newFatal( wfMessage( 'readonlytext', $reason ) );
1780 // Check the session, if we tried to create this user already there's
1781 // no point in retrying.
1782 $session = $this->request
->getSession();
1783 if ( $session->get( self
::AUTOCREATE_BLOCKLIST
) ) {
1784 $this->logger
->debug( __METHOD__
. ': blacklisted in session {sessionid}', [
1785 'username' => $username,
1786 'sessionid' => $session->getId(),
1789 $user->loadFromId();
1790 $reason = $session->get( self
::AUTOCREATE_BLOCKLIST
);
1791 if ( $reason instanceof StatusValue
) {
1792 return Status
::wrap( $reason );
1794 return Status
::newFatal( $reason );
1798 // Is the username usable? (Previously isCreatable() was checked here but
1799 // that doesn't work with auto-creation of TempUser accounts by CentralAuth)
1800 if ( !$this->userNameUtils
->isUsable( $username ) ) {
1801 $this->logger
->debug( __METHOD__
. ': name "{username}" is not usable', [
1802 'username' => $username,
1804 $session->set( self
::AUTOCREATE_BLOCKLIST
, 'noname' );
1806 $user->loadFromId();
1807 return Status
::newFatal( 'noname' );
1810 // Is the IP user able to create accounts?
1811 $anon = $this->userFactory
->newAnonymous();
1812 if ( $source !== self
::AUTOCREATE_SOURCE_MAINT
&&
1813 !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' )
1815 $this->logger
->debug( __METHOD__
. ': IP lacks the ability to create or autocreate accounts', [
1816 'username' => $username,
1817 'clientip' => $anon->getName(),
1819 $session->set( self
::AUTOCREATE_BLOCKLIST
, 'authmanager-autocreate-noperm' );
1820 $session->persist();
1822 $user->loadFromId();
1823 return Status
::newFatal( 'authmanager-autocreate-noperm' );
1826 // Avoid account creation races on double submissions
1827 $cache = \ObjectCache
::getLocalClusterInstance();
1828 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
1830 $this->logger
->debug( __METHOD__
. ': Could not acquire account creation lock', [
1831 'user' => $username,
1834 $user->loadFromId();
1835 return Status
::newFatal( 'usernameinprogress' );
1838 // Denied by providers?
1840 'flags' => User
::READ_LATEST
,
1843 $providers = $this->getPreAuthenticationProviders() +
1844 $this->getPrimaryAuthenticationProviders() +
1845 $this->getSecondaryAuthenticationProviders();
1846 foreach ( $providers as $provider ) {
1847 $status = $provider->testUserForCreation( $user, $source, $options );
1848 if ( !$status->isGood() ) {
1849 $ret = Status
::wrap( $status );
1850 $this->logger
->debug( __METHOD__
. ': Provider denied creation of {username}: {reason}', [
1851 'username' => $username,
1852 'reason' => $ret->getWikiText( false, false, 'en' ),
1854 $session->set( self
::AUTOCREATE_BLOCKLIST
, $status );
1856 $user->loadFromId();
1861 $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
1862 if ( $cache->get( $backoffKey ) ) {
1863 $this->logger
->debug( __METHOD__
. ': {username} denied by prior creation attempt failures', [
1864 'username' => $username,
1867 $user->loadFromId();
1868 return Status
::newFatal( 'authmanager-autocreate-exception' );
1871 // Checks passed, create the user...
1872 $from = $_SERVER['REQUEST_URI'] ??
'CLI';
1873 $this->logger
->info( __METHOD__
. ': creating new user ({username}) - from: {from}', [
1874 'username' => $username,
1878 // Ignore warnings about primary connections/writes...hard to avoid here
1879 $trxProfiler = \Profiler
::instance()->getTransactionProfiler();
1880 $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY
);
1882 $status = $user->addToDatabase();
1883 if ( !$status->isOK() ) {
1884 // Double-check for a race condition (T70012). We make use of the fact that when
1885 // addToDatabase fails due to the user already existing, the user object gets loaded.
1886 if ( $user->getId() ) {
1887 $this->logger
->info( __METHOD__
. ': {username} already exists locally (race)', [
1888 'username' => $username,
1891 $this->setSessionDataForUser( $user );
1893 $status = Status
::newGood()->warning( 'userexists' );
1895 $this->logger
->error( __METHOD__
. ': {username} failed with message {msg}', [
1896 'username' => $username,
1897 'msg' => $status->getWikiText( false, false, 'en' )
1900 $user->loadFromId();
1904 } catch ( \Exception
$ex ) {
1905 $this->logger
->error( __METHOD__
. ': {username} failed with exception {exception}', [
1906 'username' => $username,
1909 // Do not keep throwing errors for a while
1910 $cache->set( $backoffKey, 1, 600 );
1911 // Bubble up error; which should normally trigger DB rollbacks
1915 $this->setDefaultUserOptions( $user, false );
1917 // Inform the providers
1918 $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] );
1920 $this->getHookRunner()->onLocalUserCreated( $user, true );
1921 $user->saveSettings();
1923 // Update user count
1924 \DeferredUpdates
::addUpdate( \SiteStatsUpdate
::factory( [ 'users' => 1 ] ) );
1925 // Watch user's userpage and talk page (except temp users)
1926 if ( $source !== self
::AUTOCREATE_SOURCE_TEMP
) {
1927 \DeferredUpdates
::addCallableUpdate( function () use ( $user ) {
1928 $this->watchlistManager
->addWatchIgnoringRights( $user, $user->getUserPage() );
1933 if ( $this->config
->get( MainConfigNames
::NewUserLog
) && $log ) {
1934 $logEntry = new \
ManualLogEntry( 'newusers', 'autocreate' );
1935 $logEntry->setPerformer( $user );
1936 $logEntry->setTarget( $user->getUserPage() );
1937 $logEntry->setComment( '' );
1938 $logEntry->setParameters( [
1939 '4::userid' => $user->getId(),
1941 $logEntry->insert();
1944 ScopedCallback
::consume( $scope );
1947 $remember = $source === self
::AUTOCREATE_SOURCE_TEMP
;
1948 $this->setSessionDataForUser( $user, $remember );
1951 return Status
::newGood();
1954 // endregion -- end of Account creation
1956 /***************************************************************************/
1957 // region Account linking
1958 /** @name Account linking */
1961 * Determine whether accounts can be linked
1964 public function canLinkAccounts() {
1965 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1966 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
) {
1974 * Start an account linking flow
1976 * @param User $user User being linked
1977 * @param AuthenticationRequest[] $reqs
1978 * @param string $returnToUrl Url that REDIRECT responses should eventually
1980 * @return AuthenticationResponse
1982 public function beginAccountLink( User
$user, array $reqs, $returnToUrl ) {
1983 $session = $this->request
->getSession();
1984 $session->remove( self
::ACCOUNT_LINK_STATE
);
1986 if ( !$this->canLinkAccounts() ) {
1987 // Caller should have called canLinkAccounts()
1988 throw new \
LogicException( 'Account linking is not possible' );
1991 if ( !$user->isRegistered() ) {
1992 if ( !$this->userNameUtils
->isUsable( $user->getName() ) ) {
1993 $msg = wfMessage( 'noname' );
1995 $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() );
1997 return AuthenticationResponse
::newFail( $msg );
1999 foreach ( $reqs as $req ) {
2000 $req->username
= $user->getName();
2001 $req->returnToUrl
= $returnToUrl;
2004 $this->removeAuthenticationSessionData( null );
2006 $providers = $this->getPreAuthenticationProviders();
2007 foreach ( $providers as $id => $provider ) {
2008 $status = $provider->testForAccountLink( $user );
2009 if ( !$status->isGood() ) {
2010 $this->logger
->debug( __METHOD__
. ": Account linking pre-check failed by $id", [
2011 'user' => $user->getName(),
2013 $ret = AuthenticationResponse
::newFail(
2014 Status
::wrap( $status )->getMessage()
2016 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2022 'username' => $user->getName(),
2023 'userid' => $user->getId(),
2024 'returnToUrl' => $returnToUrl,
2026 'continueRequests' => [],
2029 $providers = $this->getPrimaryAuthenticationProviders();
2030 foreach ( $providers as $id => $provider ) {
2031 if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider
::TYPE_LINK
) {
2035 $res = $provider->beginPrimaryAccountLink( $user, $reqs );
2036 switch ( $res->status
) {
2037 case AuthenticationResponse
::PASS
:
2038 $this->logger
->info( "Account linked to {user} by $id", [
2039 'user' => $user->getName(),
2041 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2044 case AuthenticationResponse
::FAIL
:
2045 $this->logger
->debug( __METHOD__
. ": Account linking failed by $id", [
2046 'user' => $user->getName(),
2048 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2051 case AuthenticationResponse
::ABSTAIN
:
2055 case AuthenticationResponse
::REDIRECT
:
2056 case AuthenticationResponse
::UI
:
2057 $this->logger
->debug( __METHOD__
. ": Account linking $res->status by $id", [
2058 'user' => $user->getName(),
2060 $this->fillRequests( $res->neededRequests
, self
::ACTION_LINK
, $user->getName() );
2061 $state['primary'] = $id;
2062 $state['continueRequests'] = $res->neededRequests
;
2063 $session->setSecret( self
::ACCOUNT_LINK_STATE
, $state );
2064 $session->persist();
2067 // @codeCoverageIgnoreStart
2069 throw new \
DomainException(
2070 get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status"
2072 // @codeCoverageIgnoreEnd
2076 $this->logger
->debug( __METHOD__
. ': Account linking failed because no provider accepted', [
2077 'user' => $user->getName(),
2079 $ret = AuthenticationResponse
::newFail(
2080 wfMessage( 'authmanager-link-no-primary' )
2082 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2087 * Continue an account linking flow
2088 * @param AuthenticationRequest[] $reqs
2089 * @return AuthenticationResponse
2091 public function continueAccountLink( array $reqs ) {
2092 $session = $this->request
->getSession();
2094 if ( !$this->canLinkAccounts() ) {
2095 // Caller should have called canLinkAccounts()
2096 $session->remove( self
::ACCOUNT_LINK_STATE
);
2097 throw new \
LogicException( 'Account linking is not possible' );
2100 $state = $session->getSecret( self
::ACCOUNT_LINK_STATE
);
2101 if ( !is_array( $state ) ) {
2102 return AuthenticationResponse
::newFail(
2103 wfMessage( 'authmanager-link-not-in-progress' )
2106 $state['continueRequests'] = [];
2108 // Step 0: Prepare and validate the input
2110 $user = $this->userFactory
->newFromName(
2111 (string)$state['username'],
2112 UserRigorOptions
::RIGOR_USABLE
2114 if ( !is_object( $user ) ) {
2115 $session->remove( self
::ACCOUNT_LINK_STATE
);
2116 return AuthenticationResponse
::newFail( wfMessage( 'noname' ) );
2118 if ( $user->getId() !== $state['userid'] ) {
2119 throw new \
UnexpectedValueException(
2120 "User \"{$state['username']}\" is valid, but " .
2121 "ID {$user->getId()} !== {$state['userid']}!"
2125 foreach ( $reqs as $req ) {
2126 $req->username
= $state['username'];
2127 $req->returnToUrl
= $state['returnToUrl'];
2130 // Step 1: Call the primary again until it succeeds
2132 $provider = $this->getAuthenticationProvider( $state['primary'] );
2133 if ( !$provider instanceof PrimaryAuthenticationProvider
) {
2134 // Configuration changed? Force them to start over.
2135 // @codeCoverageIgnoreStart
2136 $ret = AuthenticationResponse
::newFail(
2137 wfMessage( 'authmanager-link-not-in-progress' )
2139 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2140 $session->remove( self
::ACCOUNT_LINK_STATE
);
2142 // @codeCoverageIgnoreEnd
2144 $id = $provider->getUniqueId();
2145 $res = $provider->continuePrimaryAccountLink( $user, $reqs );
2146 switch ( $res->status
) {
2147 case AuthenticationResponse
::PASS
:
2148 $this->logger
->info( "Account linked to {user} by $id", [
2149 'user' => $user->getName(),
2151 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2152 $session->remove( self
::ACCOUNT_LINK_STATE
);
2154 case AuthenticationResponse
::FAIL
:
2155 $this->logger
->debug( __METHOD__
. ": Account linking failed by $id", [
2156 'user' => $user->getName(),
2158 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2159 $session->remove( self
::ACCOUNT_LINK_STATE
);
2161 case AuthenticationResponse
::REDIRECT
:
2162 case AuthenticationResponse
::UI
:
2163 $this->logger
->debug( __METHOD__
. ": Account linking $res->status by $id", [
2164 'user' => $user->getName(),
2166 $this->fillRequests( $res->neededRequests
, self
::ACTION_LINK
, $user->getName() );
2167 $state['continueRequests'] = $res->neededRequests
;
2168 $session->setSecret( self
::ACCOUNT_LINK_STATE
, $state );
2171 throw new \
DomainException(
2172 get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
2175 } catch ( \Exception
$ex ) {
2176 $session->remove( self
::ACCOUNT_LINK_STATE
);
2181 // endregion -- end of Account linking
2183 /***************************************************************************/
2184 // region Information methods
2185 /** @name Information methods */
2188 * Return the applicable list of AuthenticationRequests
2190 * Possible values for $action:
2191 * - ACTION_LOGIN: Valid for passing to beginAuthentication
2192 * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state
2193 * - ACTION_CREATE: Valid for passing to beginAccountCreation
2194 * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state
2195 * - ACTION_LINK: Valid for passing to beginAccountLink
2196 * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state
2197 * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials
2198 * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials.
2199 * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts.
2201 * @param string $action One of the AuthManager::ACTION_* constants
2202 * @param UserIdentity|null $user User being acted on, instead of the current user.
2203 * @return AuthenticationRequest[]
2205 public function getAuthenticationRequests( $action, UserIdentity
$user = null ) {
2207 $providerAction = $action;
2209 // Figure out which providers to query
2210 switch ( $action ) {
2211 case self
::ACTION_LOGIN
:
2212 case self
::ACTION_CREATE
:
2213 $providers = $this->getPreAuthenticationProviders() +
2214 $this->getPrimaryAuthenticationProviders() +
2215 $this->getSecondaryAuthenticationProviders();
2218 case self
::ACTION_LOGIN_CONTINUE
:
2219 $state = $this->request
->getSession()->getSecret( self
::AUTHN_STATE
);
2220 return is_array( $state ) ?
$state['continueRequests'] : [];
2222 case self
::ACTION_CREATE_CONTINUE
:
2223 $state = $this->request
->getSession()->getSecret( self
::ACCOUNT_CREATION_STATE
);
2224 return is_array( $state ) ?
$state['continueRequests'] : [];
2226 case self
::ACTION_LINK
:
2228 foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2229 if ( $p->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
) {
2235 case self
::ACTION_UNLINK
:
2237 foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2238 if ( $p->accountCreationType() === PrimaryAuthenticationProvider
::TYPE_LINK
) {
2243 // To providers, unlink and remove are identical.
2244 $providerAction = self
::ACTION_REMOVE
;
2247 case self
::ACTION_LINK_CONTINUE
:
2248 $state = $this->request
->getSession()->getSecret( self
::ACCOUNT_LINK_STATE
);
2249 return is_array( $state ) ?
$state['continueRequests'] : [];
2251 case self
::ACTION_CHANGE
:
2252 case self
::ACTION_REMOVE
:
2253 $providers = $this->getPrimaryAuthenticationProviders() +
2254 $this->getSecondaryAuthenticationProviders();
2257 // @codeCoverageIgnoreStart
2259 throw new \
DomainException( __METHOD__
. ": Invalid action \"$action\"" );
2261 // @codeCoverageIgnoreEnd
2263 return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user );
2267 * Internal request lookup for self::getAuthenticationRequests
2269 * @param string $providerAction Action to pass to providers
2270 * @param array $options Options to pass to providers
2271 * @param AuthenticationProvider[] $providers
2272 * @param UserIdentity|null $user being acted on
2273 * @return AuthenticationRequest[]
2275 private function getAuthenticationRequestsInternal(
2276 $providerAction, array $options, array $providers, UserIdentity
$user = null
2278 $user = $user ?
: \RequestContext
::getMain()->getUser();
2279 $options['username'] = $user->isRegistered() ?
$user->getName() : null;
2281 // Query them and merge results
2283 foreach ( $providers as $provider ) {
2284 $isPrimary = $provider instanceof PrimaryAuthenticationProvider
;
2285 foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) {
2286 $id = $req->getUniqueId();
2288 // If a required request if from a Primary, mark it as "primary-required" instead
2289 if ( $isPrimary && $req->required
) {
2290 $req->required
= AuthenticationRequest
::PRIMARY_REQUIRED
;
2294 !isset( $reqs[$id] )
2295 ||
$req->required
=== AuthenticationRequest
::REQUIRED
2296 ||
$reqs[$id] === AuthenticationRequest
::OPTIONAL
2303 // AuthManager has its own req for some actions
2304 switch ( $providerAction ) {
2305 case self
::ACTION_LOGIN
:
2306 $reqs[] = new RememberMeAuthenticationRequest(
2307 $this->config
->get( MainConfigNames
::RememberMe
) );
2308 $options['username'] = null; // Don't fill in the username below
2311 case self
::ACTION_CREATE
:
2312 $reqs[] = new UsernameAuthenticationRequest
;
2313 $reqs[] = new UserDataAuthenticationRequest
;
2314 if ( $options['username'] !== null ) {
2315 $reqs[] = new CreationReasonAuthenticationRequest
;
2316 $options['username'] = null; // Don't fill in the username below
2321 // Fill in reqs data
2322 $this->fillRequests( $reqs, $providerAction, $options['username'], true );
2324 // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing
2325 if ( $providerAction === self
::ACTION_CHANGE ||
$providerAction === self
::ACTION_REMOVE
) {
2326 $reqs = array_filter( $reqs, function ( $req ) {
2327 return $this->allowsAuthenticationDataChange( $req, false )->isGood();
2331 return array_values( $reqs );
2335 * Set values in an array of requests
2336 * @param AuthenticationRequest[] &$reqs
2337 * @param string $action
2338 * @param string|null $username
2339 * @param bool $forceAction
2341 private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) {
2342 foreach ( $reqs as $req ) {
2343 if ( !$req->action ||
$forceAction ) {
2344 $req->action
= $action;
2346 $req->username ??
= $username;
2351 * Determine whether a username exists
2352 * @param string $username
2353 * @param int $flags Bitfield of User:READ_* constants
2356 public function userExists( $username, $flags = User
::READ_NORMAL
) {
2357 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2358 if ( $provider->testUserExists( $username, $flags ) ) {
2367 * Determine whether a user property should be allowed to be changed.
2369 * Supported properties are:
2374 * @param string $property
2377 public function allowsPropertyChange( $property ) {
2378 $providers = $this->getPrimaryAuthenticationProviders() +
2379 $this->getSecondaryAuthenticationProviders();
2380 foreach ( $providers as $provider ) {
2381 if ( !$provider->providerAllowsPropertyChange( $property ) ) {
2389 * Get a provider by ID
2390 * @note This is public so extensions can check whether their own provider
2391 * is installed and so they can read its configuration if necessary.
2392 * Other uses are not recommended.
2394 * @return AuthenticationProvider|null
2396 public function getAuthenticationProvider( $id ) {
2398 if ( isset( $this->allAuthenticationProviders
[$id] ) ) {
2399 return $this->allAuthenticationProviders
[$id];
2402 // Slow version: instantiate each kind and check
2403 $providers = $this->getPrimaryAuthenticationProviders();
2404 if ( isset( $providers[$id] ) ) {
2405 return $providers[$id];
2407 $providers = $this->getSecondaryAuthenticationProviders();
2408 if ( isset( $providers[$id] ) ) {
2409 return $providers[$id];
2411 $providers = $this->getPreAuthenticationProviders();
2412 if ( isset( $providers[$id] ) ) {
2413 return $providers[$id];
2419 // endregion -- end of Information methods
2421 /***************************************************************************/
2422 // region Internal methods
2423 /** @name Internal methods */
2426 * Store authentication in the current session
2427 * @note For use by AuthenticationProviders only
2428 * @param string $key
2429 * @param mixed $data Must be serializable
2431 public function setAuthenticationSessionData( $key, $data ) {
2432 $session = $this->request
->getSession();
2433 $arr = $session->getSecret( 'authData' );
2434 if ( !is_array( $arr ) ) {
2438 $session->setSecret( 'authData', $arr );
2442 * Fetch authentication data from the current session
2443 * @note For use by AuthenticationProviders only
2444 * @param string $key
2445 * @param mixed|null $default
2448 public function getAuthenticationSessionData( $key, $default = null ) {
2449 $arr = $this->request
->getSession()->getSecret( 'authData' );
2450 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2458 * Remove authentication data
2459 * @note For use by AuthenticationProviders
2460 * @param string|null $key If null, all data is removed
2462 public function removeAuthenticationSessionData( $key ) {
2463 $session = $this->request
->getSession();
2464 if ( $key === null ) {
2465 $session->remove( 'authData' );
2467 $arr = $session->getSecret( 'authData' );
2468 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2469 unset( $arr[$key] );
2470 $session->setSecret( 'authData', $arr );
2476 * Create an array of AuthenticationProviders from an array of ObjectFactory specs
2477 * @param string $class
2478 * @param array[] $specs
2479 * @return AuthenticationProvider[]
2481 protected function providerArrayFromSpecs( $class, array $specs ) {
2483 foreach ( $specs as &$spec ) {
2484 $spec = [ 'sort2' => $i++
] +
$spec +
[ 'sort' => 0 ];
2487 // Sort according to the 'sort' field, and if they are equal, according to 'sort2'
2488 usort( $specs, static function ( $a, $b ) {
2489 return $a['sort'] <=> $b['sort']
2490 ?
: $a['sort2'] <=> $b['sort2'];
2494 foreach ( $specs as $spec ) {
2495 /** @var AbstractAuthenticationProvider $provider */
2496 $provider = $this->objectFactory
->createObject( $spec, [ 'assertClass' => $class ] );
2497 $provider->init( $this->logger
, $this, $this->getHookContainer(), $this->config
, $this->userNameUtils
);
2498 $id = $provider->getUniqueId();
2499 if ( isset( $this->allAuthenticationProviders
[$id] ) ) {
2500 throw new \
RuntimeException(
2501 "Duplicate specifications for id $id (classes " .
2502 get_class( $provider ) . ' and ' .
2503 get_class( $this->allAuthenticationProviders
[$id] ) . ')'
2506 $this->allAuthenticationProviders
[$id] = $provider;
2507 $ret[$id] = $provider;
2515 private function getConfiguration() {
2516 return $this->config
->get( MainConfigNames
::AuthManagerConfig
)
2517 ?
: $this->config
->get( MainConfigNames
::AuthManagerAutoConfig
);
2521 * Get the list of PreAuthenticationProviders
2522 * @return PreAuthenticationProvider[]
2524 protected function getPreAuthenticationProviders() {
2525 if ( $this->preAuthenticationProviders
=== null ) {
2526 $conf = $this->getConfiguration();
2527 $this->preAuthenticationProviders
= $this->providerArrayFromSpecs(
2528 PreAuthenticationProvider
::class, $conf['preauth']
2531 return $this->preAuthenticationProviders
;
2535 * Get the list of PrimaryAuthenticationProviders
2536 * @return PrimaryAuthenticationProvider[]
2538 protected function getPrimaryAuthenticationProviders() {
2539 if ( $this->primaryAuthenticationProviders
=== null ) {
2540 $conf = $this->getConfiguration();
2541 $this->primaryAuthenticationProviders
= $this->providerArrayFromSpecs(
2542 PrimaryAuthenticationProvider
::class, $conf['primaryauth']
2545 return $this->primaryAuthenticationProviders
;
2549 * Get the list of SecondaryAuthenticationProviders
2550 * @return SecondaryAuthenticationProvider[]
2552 protected function getSecondaryAuthenticationProviders() {
2553 if ( $this->secondaryAuthenticationProviders
=== null ) {
2554 $conf = $this->getConfiguration();
2555 $this->secondaryAuthenticationProviders
= $this->providerArrayFromSpecs(
2556 SecondaryAuthenticationProvider
::class, $conf['secondaryauth']
2559 return $this->secondaryAuthenticationProviders
;
2565 * @param bool|null $remember
2567 private function setSessionDataForUser( $user, $remember = null ) {
2568 $session = $this->request
->getSession();
2569 $delay = $session->delaySave();
2571 $session->resetId();
2572 $session->resetAllTokens();
2573 if ( $session->canSetUser() ) {
2574 $session->setUser( $user );
2576 if ( $remember !== null ) {
2577 $session->setRememberUser( $remember );
2579 $session->set( 'AuthManager:lastAuthId', $user->getId() );
2580 $session->set( 'AuthManager:lastAuthTimestamp', time() );
2581 $session->persist();
2583 \Wikimedia\ScopedCallback
::consume( $delay );
2585 $this->getHookRunner()->onUserLoggedIn( $user );
2590 * @param bool $useContextLang Use 'uselang' to set the user's language
2592 private function setDefaultUserOptions( User
$user, $useContextLang ) {
2595 $lang = $useContextLang ? \RequestContext
::getMain()->getLanguage() : $this->contentLanguage
;
2596 $this->userOptionsManager
->setOption(
2599 $this->languageConverterFactory
->getLanguageConverter( $lang )->getPreferredVariant()
2602 $contLangConverter = $this->languageConverterFactory
->getLanguageConverter( $this->contentLanguage
);
2603 if ( $contLangConverter->hasVariants() ) {
2604 $this->userOptionsManager
->setOption(
2607 $contLangConverter->getPreferredVariant()
2613 * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary
2614 * @param string $method
2615 * @param array $args
2617 private function callMethodOnProviders( $which, $method, array $args ) {
2620 $providers +
= $this->getPreAuthenticationProviders();
2623 $providers +
= $this->getPrimaryAuthenticationProviders();
2626 $providers +
= $this->getSecondaryAuthenticationProviders();
2628 foreach ( $providers as $provider ) {
2629 $provider->$method( ...$args );
2634 * @return HookContainer
2636 private function getHookContainer() {
2637 return $this->hookContainer
;
2641 * @return HookRunner
2643 private function getHookRunner() {
2644 return $this->hookRunner
;
2647 // endregion -- end of Internal methods
2652 * This file uses VisualStudio style region/endregion fold markers which are
2653 * recognised by PHPStorm. If modelines are enabled, the following editor
2654 * configuration will also enable folding in vim, if it is in the last 5 lines
2655 * of the file. We also use "@name" which creates sections in Doxygen.
2657 * vim: foldmarker=//\ region,//\ endregion foldmethod=marker