Update git submodules
[mediawiki.git] / includes / auth / AuthManager.php
blobb43127460f0e875471ac706a07829cf59f22b770
1 <?php
2 /**
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
20 * @file
21 * @ingroup Auth
24 namespace MediaWiki\Auth;
26 use Language;
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;
52 use StatusValue;
53 use Wikimedia\ObjectFactory\ObjectFactory;
54 use Wikimedia\Rdbms\ILoadBalancer;
55 use Wikimedia\Rdbms\ReadOnlyMode;
56 use Wikimedia\ScopedCallback;
58 /**
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
62 * system.
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
95 * process.
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
100 * a non-OK status.
102 * @ingroup Auth
103 * @since 1.27
104 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
106 class AuthManager implements LoggerAwareInterface {
108 * @internal
109 * Key in the user's session data for storing login state.
111 public const AUTHN_STATE = 'AuthManager::authnState';
114 * @internal
115 * Key in the user's session data for storing account creation state.
117 public const ACCOUNT_CREATION_STATE = 'AuthManager::accountCreationState';
120 * @internal
121 * Key in the user's session data for storing account linking state.
123 public const ACCOUNT_LINK_STATE = 'AuthManager::accountLinkState';
126 * @internal
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 */
174 private $request;
176 /** @var Config */
177 private $config;
179 /** @var ObjectFactory */
180 private $objectFactory;
182 /** @var LoggerInterface */
183 private $logger;
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 */
207 private $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;
221 /** @var Language */
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(
257 WebRequest $request,
258 Config $config,
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;
300 * @return WebRequest
302 public function getRequest() {
303 return $this->request;
307 * Force certain PrimaryAuthenticationProviders
308 * @deprecated For backwards compatibility only
309 * @param PrimaryAuthenticationProvider[] $providers
310 * @param string $why
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.
363 * @return bool
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
375 * preserve state.
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
384 * return to.
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;
404 break;
407 // @codeCoverageIgnoreEnd
410 // Check for special-case login of a just-created account
411 $req = AuthenticationRequest::getRequestByClass(
412 $reqs, CreatedAccountAuthenticationRequest::class
414 if ( $req ) {
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
424 if ( !$user ) {
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(),
437 ] );
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(), [] );
444 return $ret;
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, [] );
460 return $ret;
464 $state = [
465 'reqs' => $reqs,
466 'returnToUrl' => $returnToUrl,
467 'guessUserName' => $guessUserName,
468 'primary' => null,
469 'primaryResponse' => null,
470 'secondary' => [],
471 'maybeLink' => [],
472 'continueRequests' => [],
475 // Preserve state from a previous failed login
476 $req = AuthenticationRequest::getRequestByClass(
477 $reqs, CreateFromLoginAuthenticationRequest::class
479 if ( $req ) {
480 $state['maybeLink'] = $req->maybeLink;
483 $session = $this->request->getSession();
484 $session->setSecret( self::AUTHN_STATE, $state );
485 $session->persist();
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();
514 try {
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;
548 break;
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" );
563 break 2;
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 ),
576 $res
579 $session->remove( self::AUTHN_STATE );
580 $this->getHookRunner()->onAuthManagerLoginAuthenticateAudit(
581 $res, null, $guessUserName, [] );
582 return $res;
583 case AuthenticationResponse::ABSTAIN:
584 // Continue loop
585 break;
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 );
593 return $res;
595 // @codeCoverageIgnoreStart
596 default:
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 );
613 return $ret;
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 );
627 return $ret;
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" );
636 break;
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, [] );
650 return $res;
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 );
657 return $res;
658 default:
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 );
679 return $ret;
680 // @codeCoverageIgnoreEnd
683 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK &&
684 $res->linkRequest &&
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';
690 } else {
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(
698 self::ACTION_LOGIN,
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
711 'primary' => null,
712 'primaryResponse' => null,
713 'secondary' => [],
714 'continueRequests' => $ret->neededRequests,
715 ] + $state );
716 return $ret;
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
726 if ( !$user ) {
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(),
736 ] );
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(), [] );
746 return $ret;
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 );
764 } else {
765 continue;
767 switch ( $res->status ) {
768 case AuthenticationResponse::PASS:
769 $this->logger->debug( "Secondary login with $id succeeded" );
770 // fall through
771 case AuthenticationResponse::ABSTAIN:
772 $state['secondary'][$id] = true;
773 break;
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(), [] );
780 return $res;
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 );
788 return $res;
790 // @codeCoverageIgnoreStart
791 default:
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
800 // clean up.
802 $this->logger->info( 'Login for {user} succeeded from {clientip}', [
803 'user' => $user->getName(),
804 'clientip' => $this->request->getIP(),
805 ] );
806 $rememberMeConfig = $this->config->get( MainConfigNames::RememberMe );
807 if ( $rememberMeConfig === RememberMeAuthenticationRequest::ALWAYS_REMEMBER ) {
808 $rememberMe = true;
809 } elseif ( $rememberMeConfig === RememberMeAuthenticationRequest::NEVER_REMEMBER ) {
810 $rememberMe = false;
811 } else {
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(), [] );
825 return $ret;
826 } catch ( \Exception $ex ) {
827 $session->remove( self::AUTHN_STATE );
828 throw $ex;
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();
850 if ( $aId === 0 ) {
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" );
854 return $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
862 } else {
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'];
871 } else {
872 throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
875 if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
876 $status = self::SEC_REAUTH;
878 } else {
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;
887 } else {
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(),
909 return $status;
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
919 * @return bool
921 public function userCanAuthenticate( $username ) {
922 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
923 if ( $provider->testUserCanAuthenticate( $username ) ) {
924 return true;
927 return false;
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
939 * username).
941 * @param string $username
942 * @return string[]
944 public function normalizeUsername( $username ) {
945 $ret = [];
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}', [
970 'user' => $username,
971 ] );
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.
982 * @return Status
984 public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
985 $any = false;
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';
1001 if ( !$any ) {
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 )
1018 * returned success.
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 ),
1029 ] );
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
1048 * @return bool
1050 public function canCreateAccounts() {
1051 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1052 switch ( $provider->accountCreationType() ) {
1053 case PrimaryAuthenticationProvider::TYPE_CREATE:
1054 case PrimaryAuthenticationProvider::TYPE_LINK:
1055 return true;
1058 return false;
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.
1067 * @return Status
1069 public function canCreateAccount( $username, $options = [] ) {
1070 // Back compat
1071 if ( is_int( $options ) ) {
1072 $options = [ 'flags' => $options ];
1074 $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' );
1092 } else {
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
1119 ): StatusValue {
1120 // Wiki is read-only?
1121 if ( $this->readOnlyMode->isReadOnly() ) {
1122 return StatusValue::newFatal( wfMessage( 'readonlytext', $this->readOnlyMode->getReason() ) );
1125 $permStatus = new PermissionStatus();
1126 if ( !$authorizer(
1127 'createaccount',
1128 SpecialPage::getTitleFor( 'CreateAccount' ),
1129 $permStatus
1130 ) ) {
1131 return $permStatus;
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.
1149 * @since 1.39
1150 * @param Authority $creator
1151 * @return StatusValue
1153 public function probablyCanCreateAccount( Authority $creator ): StatusValue {
1154 return $this->authorizeInternal(
1155 static function (
1156 string $action,
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.
1172 * @since 1.39
1173 * @param Authority $creator
1174 * @return StatusValue
1176 public function authorizeCreateAccount( Authority $creator ): StatusValue {
1177 return $this->authorizeInternal(
1178 static function (
1179 string $action,
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
1194 * <code>
1195 * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE )
1196 * </code>
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
1204 * return to.
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' );
1215 try {
1216 $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
1217 } catch ( \UnexpectedValueException $ex ) {
1218 $username = null;
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' )
1232 ] );
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' )
1244 ] );
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' ),
1262 ] );
1263 return AuthenticationResponse::newFail( $status->getMessage() );
1268 $this->removeAuthenticationSessionData( null );
1270 $state = [
1271 'username' => $username,
1272 'userid' => 0,
1273 'creatorid' => $creator->getUser()->getId(),
1274 'creatorname' => $creator->getUser()->getName(),
1275 'reqs' => $reqs,
1276 'returnToUrl' => $returnToUrl,
1277 'primary' => null,
1278 'primaryResponse' => null,
1279 'secondary' => [],
1280 'continueRequests' => [],
1281 'maybeLink' => [],
1282 'ranPreTests' => false,
1285 // Special case: converting a login to an account creation
1286 $req = AuthenticationRequest::getRequestByClass(
1287 $reqs, CreateFromLoginAuthenticationRequest::class
1289 if ( $req ) {
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();
1311 try {
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'],
1336 ] );
1337 return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1340 if ( $state['creatorid'] ) {
1341 $creator = $this->userFactory->newFromId( (int)$state['creatorid'] );
1342 } else {
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() ) ) );
1350 if ( !$lock ) {
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(),
1356 ] );
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' )
1367 ] );
1368 $ret = AuthenticationResponse::newFail( $status->getMessage() );
1369 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1370 $session->remove( self::ACCOUNT_CREATION_STATE );
1371 return $ret;
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(),
1382 ] );
1383 $ret = AuthenticationResponse::newFail( wfMessage( 'userexists' ) );
1384 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1385 $session->remove( self::ACCOUNT_CREATION_STATE );
1386 return $ret;
1388 } else {
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'],
1394 ] );
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(),
1405 ] );
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' ),
1422 ] );
1423 $ret = AuthenticationResponse::newFail( $status->getMessage() );
1424 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1425 $session->remove( self::ACCOUNT_CREATION_STATE );
1426 return $ret;
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(),
1447 ] );
1448 $ret = AuthenticationResponse::newFail(
1449 Status::wrap( $status )->getMessage()
1451 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1452 $session->remove( self::ACCOUNT_CREATION_STATE );
1453 return $ret;
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 ) {
1466 continue;
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(),
1474 ] );
1475 $state['primary'] = $id;
1476 $state['primaryResponse'] = $res;
1477 break 2;
1478 case AuthenticationResponse::FAIL:
1479 $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
1480 'user' => $user->getName(),
1481 'creator' => $creator->getName(),
1482 ] );
1483 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1484 $session->remove( self::ACCOUNT_CREATION_STATE );
1485 return $res;
1486 case AuthenticationResponse::ABSTAIN:
1487 // Continue loop
1488 break;
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(),
1494 ] );
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 );
1499 return $res;
1501 // @codeCoverageIgnoreStart
1502 default:
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(),
1514 ] );
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 );
1520 return $ret;
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 );
1532 return $ret;
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(),
1542 ] );
1543 $state['primaryResponse'] = $res;
1544 break;
1545 case AuthenticationResponse::FAIL:
1546 $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
1547 'user' => $user->getName(),
1548 'creator' => $creator->getName(),
1549 ] );
1550 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1551 $session->remove( self::ACCOUNT_CREATION_STATE );
1552 return $res;
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(),
1558 ] );
1559 $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1560 $state['continueRequests'] = $res->neededRequests;
1561 $session->setSecret( self::ACCOUNT_CREATION_STATE, $state );
1562 return $res;
1563 default:
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(),
1577 ] );
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 );
1584 return $ret;
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'] );
1603 // Log the creation
1604 if ( $this->config->get( MainConfigNames::NewUserLog ) ) {
1605 $isAnon = $creator->isAnon();
1606 $logEntry = new \ManualLogEntry(
1607 'newusers',
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(),
1619 ] );
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 );
1639 } else {
1640 continue;
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(),
1647 ] );
1648 // fall through
1649 case AuthenticationResponse::ABSTAIN:
1650 $state['secondary'][$id] = true;
1651 break;
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(),
1657 ] );
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 );
1662 return $res;
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
1670 default:
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(),
1688 ] );
1690 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1691 $session->remove( self::ACCOUNT_CREATION_STATE );
1692 $this->removeAuthenticationSessionData( null );
1693 return $ret;
1694 } catch ( \Exception $ex ) {
1695 $session->remove( self::ACCOUNT_CREATION_STATE );
1696 throw $ex;
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 ) {
1718 $validSources = [
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()
1735 : null;
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
1741 if (
1742 !$localId &&
1743 $this->loadBalancer->getReaderIndex() !== 0
1745 $localUserIdentity = $this->userIdentityLookup->getUserIdentityByName(
1746 $username,
1747 UserIdentityLookup::READ_LATEST
1749 $localId = ( $localUserIdentity && $localUserIdentity->getId() )
1750 ? $localUserIdentity->getId()
1751 : null;
1752 $flags = User::READ_LATEST;
1754 // @codeCoverageIgnoreEnd
1756 if ( $localId ) {
1757 $this->logger->debug( __METHOD__ . ': {username} already exists locally', [
1758 'username' => $username,
1759 ] );
1760 $user->setId( $localId );
1761 $user->loadFromId( $flags );
1762 if ( $login ) {
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,
1774 ] );
1775 $user->setId( 0 );
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(),
1787 ] );
1788 $user->setId( 0 );
1789 $user->loadFromId();
1790 $reason = $session->get( self::AUTOCREATE_BLOCKLIST );
1791 if ( $reason instanceof StatusValue ) {
1792 return Status::wrap( $reason );
1793 } else {
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,
1803 ] );
1804 $session->set( self::AUTOCREATE_BLOCKLIST, 'noname' );
1805 $user->setId( 0 );
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(),
1818 ] );
1819 $session->set( self::AUTOCREATE_BLOCKLIST, 'authmanager-autocreate-noperm' );
1820 $session->persist();
1821 $user->setId( 0 );
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 ) ) );
1829 if ( !$lock ) {
1830 $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
1831 'user' => $username,
1832 ] );
1833 $user->setId( 0 );
1834 $user->loadFromId();
1835 return Status::newFatal( 'usernameinprogress' );
1838 // Denied by providers?
1839 $options = [
1840 'flags' => User::READ_LATEST,
1841 'creating' => true,
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' ),
1853 ] );
1854 $session->set( self::AUTOCREATE_BLOCKLIST, $status );
1855 $user->setId( 0 );
1856 $user->loadFromId();
1857 return $ret;
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,
1865 ] );
1866 $user->setId( 0 );
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,
1875 'from' => $from,
1876 ] );
1878 // Ignore warnings about primary connections/writes...hard to avoid here
1879 $trxProfiler = \Profiler::instance()->getTransactionProfiler();
1880 $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY );
1881 try {
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,
1889 ] );
1890 if ( $login ) {
1891 $this->setSessionDataForUser( $user );
1893 $status = Status::newGood()->warning( 'userexists' );
1894 } else {
1895 $this->logger->error( __METHOD__ . ': {username} failed with message {msg}', [
1896 'username' => $username,
1897 'msg' => $status->getWikiText( false, false, 'en' )
1898 ] );
1899 $user->setId( 0 );
1900 $user->loadFromId();
1902 return $status;
1904 } catch ( \Exception $ex ) {
1905 $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [
1906 'username' => $username,
1907 'exception' => $ex,
1908 ] );
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
1912 throw $ex;
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() );
1929 } );
1932 // Log the creation
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(),
1940 ] );
1941 $logEntry->insert();
1944 ScopedCallback::consume( $scope );
1946 if ( $login ) {
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
1962 * @return bool
1964 public function canLinkAccounts() {
1965 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1966 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
1967 return true;
1970 return false;
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
1979 * return to.
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' );
1994 } else {
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(),
2012 ] );
2013 $ret = AuthenticationResponse::newFail(
2014 Status::wrap( $status )->getMessage()
2016 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2017 return $ret;
2021 $state = [
2022 'username' => $user->getName(),
2023 'userid' => $user->getId(),
2024 'returnToUrl' => $returnToUrl,
2025 'primary' => null,
2026 'continueRequests' => [],
2029 $providers = $this->getPrimaryAuthenticationProviders();
2030 foreach ( $providers as $id => $provider ) {
2031 if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) {
2032 continue;
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(),
2040 ] );
2041 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2042 return $res;
2044 case AuthenticationResponse::FAIL:
2045 $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
2046 'user' => $user->getName(),
2047 ] );
2048 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2049 return $res;
2051 case AuthenticationResponse::ABSTAIN:
2052 // Continue loop
2053 break;
2055 case AuthenticationResponse::REDIRECT:
2056 case AuthenticationResponse::UI:
2057 $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
2058 'user' => $user->getName(),
2059 ] );
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();
2065 return $res;
2067 // @codeCoverageIgnoreStart
2068 default:
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(),
2078 ] );
2079 $ret = AuthenticationResponse::newFail(
2080 wfMessage( 'authmanager-link-no-primary' )
2082 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
2083 return $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();
2093 try {
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 );
2141 return $ret;
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(),
2150 ] );
2151 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2152 $session->remove( self::ACCOUNT_LINK_STATE );
2153 return $res;
2154 case AuthenticationResponse::FAIL:
2155 $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
2156 'user' => $user->getName(),
2157 ] );
2158 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
2159 $session->remove( self::ACCOUNT_LINK_STATE );
2160 return $res;
2161 case AuthenticationResponse::REDIRECT:
2162 case AuthenticationResponse::UI:
2163 $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
2164 'user' => $user->getName(),
2165 ] );
2166 $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
2167 $state['continueRequests'] = $res->neededRequests;
2168 $session->setSecret( self::ACCOUNT_LINK_STATE, $state );
2169 return $res;
2170 default:
2171 throw new \DomainException(
2172 get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
2175 } catch ( \Exception $ex ) {
2176 $session->remove( self::ACCOUNT_LINK_STATE );
2177 throw $ex;
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 ) {
2206 $options = [];
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();
2216 break;
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:
2227 $providers = [];
2228 foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2229 if ( $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
2230 $providers[] = $p;
2233 break;
2235 case self::ACTION_UNLINK:
2236 $providers = [];
2237 foreach ( $this->getPrimaryAuthenticationProviders() as $p ) {
2238 if ( $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
2239 $providers[] = $p;
2243 // To providers, unlink and remove are identical.
2244 $providerAction = self::ACTION_REMOVE;
2245 break;
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();
2255 break;
2257 // @codeCoverageIgnoreStart
2258 default:
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
2282 $reqs = [];
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;
2293 if (
2294 !isset( $reqs[$id] )
2295 || $req->required === AuthenticationRequest::REQUIRED
2296 || $reqs[$id] === AuthenticationRequest::OPTIONAL
2298 $reqs[$id] = $req;
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
2309 break;
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
2318 break;
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();
2328 } );
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
2354 * @return bool
2356 public function userExists( $username, $flags = User::READ_NORMAL ) {
2357 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2358 if ( $provider->testUserExists( $username, $flags ) ) {
2359 return true;
2363 return false;
2367 * Determine whether a user property should be allowed to be changed.
2369 * Supported properties are:
2370 * - emailaddress
2371 * - realname
2372 * - nickname
2374 * @param string $property
2375 * @return bool
2377 public function allowsPropertyChange( $property ) {
2378 $providers = $this->getPrimaryAuthenticationProviders() +
2379 $this->getSecondaryAuthenticationProviders();
2380 foreach ( $providers as $provider ) {
2381 if ( !$provider->providerAllowsPropertyChange( $property ) ) {
2382 return false;
2385 return true;
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.
2393 * @param string $id
2394 * @return AuthenticationProvider|null
2396 public function getAuthenticationProvider( $id ) {
2397 // Fast version
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];
2416 return null;
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 ) ) {
2435 $arr = [];
2437 $arr[$key] = $data;
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
2446 * @return mixed
2448 public function getAuthenticationSessionData( $key, $default = null ) {
2449 $arr = $this->request->getSession()->getSecret( 'authData' );
2450 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2451 return $arr[$key];
2452 } else {
2453 return $default;
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' );
2466 } else {
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 ) {
2482 $i = 0;
2483 foreach ( $specs as &$spec ) {
2484 $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ];
2486 unset( $spec );
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'];
2491 } );
2493 $ret = [];
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;
2509 return $ret;
2513 * @return array
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;
2563 * Log the user in
2564 * @param User $user
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 );
2589 * @param User $user
2590 * @param bool $useContextLang Use 'uselang' to set the user's language
2592 private function setDefaultUserOptions( User $user, $useContextLang ) {
2593 $user->setToken();
2595 $lang = $useContextLang ? \RequestContext::getMain()->getLanguage() : $this->contentLanguage;
2596 $this->userOptionsManager->setOption(
2597 $user,
2598 'language',
2599 $this->languageConverterFactory->getLanguageConverter( $lang )->getPreferredVariant()
2602 $contLangConverter = $this->languageConverterFactory->getLanguageConverter( $this->contentLanguage );
2603 if ( $contLangConverter->hasVariants() ) {
2604 $this->userOptionsManager->setOption(
2605 $user,
2606 'variant',
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 ) {
2618 $providers = [];
2619 if ( $which & 1 ) {
2620 $providers += $this->getPreAuthenticationProviders();
2622 if ( $which & 2 ) {
2623 $providers += $this->getPrimaryAuthenticationProviders();
2625 if ( $which & 4 ) {
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