83 private static ?
Session $globalSession =
null;
84 private static ?
WebRequest $globalSessionRequest =
null;
86 private LoggerInterface $logger;
94 private $sessionProviders =
null;
97 private $varyCookies =
null;
100 private $varyHeaders =
null;
103 private $allSessionBackends = [];
106 private $allSessionIds = [];
109 private $preventUsers = [];
116 if ( self::$instance ===
null ) {
117 self::$instance =
new self();
119 return self::$instance;
133 $request = RequestContext::getMain()->getRequest();
135 !self::$globalSession
136 || self::$globalSessionRequest !== $request
137 || ( $id !==
'' && self::$globalSession->getId() !== $id )
139 self::$globalSessionRequest = $request;
149 self::$globalSession = $request->
getSession();
154 self::$globalSession =
self::singleton()->getSessionById( $id,
true, $request )
158 return self::$globalSession;
170 $this->config = $options[
'config'] ?? $services->getMainConfig();
171 $this->setLogger( $options[
'logger'] ?? \
MediaWiki\Logger\LoggerFactory::getInstance(
'session' ) );
172 $this->setHookContainer( $options[
'hookContainer'] ?? $services->getHookContainer() );
174 $store = $options[
'store'] ?? $services->getObjectCacheFactory()
176 $this->logger->debug(
'SessionManager using store ' . get_class( $store ) );
179 $this->userNameUtils = $services->getUserNameUtils();
181 register_shutdown_function( [ $this,
'shutdown' ] );
184 public function setLogger( LoggerInterface $logger ): void {
185 $this->logger = $logger;
193 $this->hookContainer = $hookContainer;
194 $this->hookRunner =
new HookRunner( $hookContainer );
198 $info = $this->getSessionInfoForRequest( $request );
201 $session = $this->getInitialSession( $request );
203 $session = $this->getSessionFromInfo( $info, $request );
209 if ( !self::validateSessionId( $id ) ) {
210 throw new InvalidArgumentException(
'Invalid session ID' );
217 $info =
new SessionInfo( SessionInfo::MIN_PRIORITY, [
'id' => $id,
'idIsSafe' =>
true ] );
220 if ( isset( $this->allSessionBackends[$id] ) ) {
221 return $this->getSessionFromInfo( $info, $request );
225 $key = $this->store->makeKey(
'MWSession', $id );
226 if ( is_array( $this->store->get( $key ) ) ) {
228 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
229 $session = $this->getSessionFromInfo( $info, $request );
233 if ( $create && $session ===
null ) {
235 $session = $this->getEmptySessionInternal( $request, $id );
236 }
catch ( \Exception $ex ) {
237 $this->logger->error(
'Failed to create empty session: {exception}',
239 'method' => __METHOD__,
250 return $this->getEmptySessionInternal( $request );
259 private function getEmptySessionInternal( ?
WebRequest $request =
null, $id =
null ) {
260 if ( $id !==
null ) {
261 if ( !self::validateSessionId( $id ) ) {
262 throw new InvalidArgumentException(
'Invalid session ID' );
265 $key = $this->store->makeKey(
'MWSession', $id );
266 if ( is_array( $this->store->get( $key ) ) ) {
267 throw new InvalidArgumentException(
'Session ID already exists' );
271 $request =
new FauxRequest;
275 foreach ( $this->getProviders() as $provider ) {
276 $info = $provider->newSessionInfo( $id );
281 throw new \UnexpectedValueException(
282 "$provider returned an empty session info for a different provider: $info"
285 if ( $id !==
null && $info->
getId() !== $id ) {
286 throw new \UnexpectedValueException(
287 "$provider returned empty session info with a wrong id: " .
288 $info->
getId() .
' != ' . $id
292 throw new \UnexpectedValueException(
293 "$provider returned empty session info with id flagged unsafe"
296 $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
297 if ( $compare > 0 ) {
300 if ( $compare === 0 ) {
308 if ( count( $infos ) > 1 ) {
309 throw new \UnexpectedValueException(
310 'Multiple empty sessions tied for top priority: ' . implode(
', ', $infos )
312 } elseif ( count( $infos ) < 1 ) {
313 throw new \UnexpectedValueException(
'No provider could provide an empty session!' );
317 return $this->getSessionFromInfo( $infos[0], $request );
329 private function getInitialSession( ?WebRequest $request =
null ) {
330 $session = $this->getEmptySession( $request );
331 $session->getToken();
339 foreach ( $this->getProviders() as $provider ) {
340 $provider->invalidateSessionsForUser( $user );
349 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
353 if ( $this->varyHeaders ===
null ) {
355 foreach ( $this->getProviders() as $provider ) {
356 foreach ( $provider->getVaryHeaders() as
$header => $_ ) {
360 $this->varyHeaders = $headers;
362 return $this->varyHeaders;
367 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
371 if ( $this->varyCookies ===
null ) {
373 foreach ( $this->getProviders() as $provider ) {
374 $cookies = array_merge( $cookies, $provider->getVaryCookies() );
376 $this->varyCookies = array_values( array_unique( $cookies ) );
378 return $this->varyCookies;
387 return is_string( $id ) && preg_match(
'/^[a-zA-Z0-9_-]{32,}$/', $id );
404 $this->preventUsers[$username] =
true;
407 foreach ( $this->getProviders() as $provider ) {
408 $provider->preventSessionsForUser( $username );
419 return !empty( $this->preventUsers[$username] );
427 if ( $this->sessionProviders ===
null ) {
428 $this->sessionProviders = [];
429 $objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
430 foreach ( $this->config->get( MainConfigNames::SessionProviders ) as $spec ) {
432 $provider = $objectFactory->createObject( $spec );
437 $this->hookContainer,
440 if ( isset( $this->sessionProviders[(
string)$provider] ) ) {
442 throw new \UnexpectedValueException(
"Duplicate provider name \"$provider\"" );
444 $this->sessionProviders[(string)$provider] = $provider;
447 return $this->sessionProviders;
461 $providers = $this->getProviders();
462 return $providers[$name] ??
null;
470 if ( $this->allSessionBackends ) {
471 $this->logger->debug(
'Saving all sessions on shutdown' );
472 if ( session_id() !==
'' ) {
474 session_write_close();
477 foreach ( $this->allSessionBackends as $backend ) {
478 $backend->shutdown();
488 private function getSessionInfoForRequest(
WebRequest $request ) {
491 foreach ( $this->getProviders() as $provider ) {
492 $info = $provider->provideSessionInfo( $request );
497 throw new \UnexpectedValueException(
498 "$provider returned session info for a different provider: $info"
507 usort( $infos, [ SessionInfo::class,
'compare' ] );
510 $info = array_pop( $infos );
511 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
515 $info = array_pop( $infos );
516 if ( SessionInfo::compare( $retInfos[0], $info ) ) {
520 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
526 $this->logUnpersist( $info, $request );
527 $info->
getProvider()->unpersistSession( $request );
532 $this->logUnpersist( $info, $request );
533 $info->
getProvider()->unpersistSession( $request );
537 if ( count( $retInfos ) > 1 ) {
538 throw new SessionOverflowException(
540 'Multiple sessions for this request tied for top priority: ' . implode(
', ', $retInfos )
544 return $retInfos[0] ??
null;
554 private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
555 $key = $this->store->makeKey(
'MWSession', $info->getId() );
556 $blob = $this->store->get( $key );
561 if ( $info->forceUse() && $blob !==
false ) {
562 $failHandler =
function () use ( $key, &$info, $request ) {
563 $this->logSessionWrite( $request, $info, [
565 'reason' =>
'loadSessionInfo fail',
567 $this->store->delete( $key );
568 return $this->loadSessionInfoFromStore( $info, $request );
571 $failHandler =
static function () {
578 if ( $blob !==
false ) {
580 if ( !is_array( $blob ) ) {
581 $this->logSessionWrite( $request, $info, [
583 'reason' =>
'bad data',
584 ], LogLevel::WARNING );
585 $this->store->delete( $key );
586 return $failHandler();
590 if ( !isset( $blob[
'data'] ) || !is_array( $blob[
'data'] ) ||
591 !isset( $blob[
'metadata'] ) || !is_array( $blob[
'metadata'] )
593 $this->logSessionWrite( $request, $info, [
595 'reason' =>
'bad data structure',
596 ], LogLevel::WARNING );
597 $this->store->delete( $key );
598 return $failHandler();
601 $data = $blob[
'data'];
602 $metadata = $blob[
'metadata'];
606 if ( !array_key_exists(
'userId', $metadata ) ||
607 !array_key_exists(
'userName', $metadata ) ||
608 !array_key_exists(
'userToken', $metadata ) ||
609 !array_key_exists(
'provider', $metadata )
611 $this->logSessionWrite( $request, $info, [
613 'reason' =>
'bad metadata',
614 ], LogLevel::WARNING );
615 $this->store->delete( $key );
616 return $failHandler();
620 $provider = $info->getProvider();
621 if ( $provider ===
null ) {
622 $newParams[
'provider'] = $provider = $this->getProvider( $metadata[
'provider'] );
624 $this->logSessionWrite( $request, $info, [
626 'reason' =>
'unknown provider',
627 'provider' => $metadata[
'provider'],
628 ], LogLevel::WARNING );
629 $this->store->delete( $key );
630 return $failHandler();
632 } elseif ( $metadata[
'provider'] !== (
string)$provider ) {
633 $this->logger->warning(
'Session "{session}": Wrong provider ' .
634 $metadata[
'provider'] .
' !== ' . $provider,
636 'session' => $info->__toString(),
638 return $failHandler();
642 $providerMetadata = $info->getProviderMetadata();
643 if ( isset( $metadata[
'providerMetadata'] ) ) {
644 if ( $providerMetadata ===
null ) {
645 $newParams[
'metadata'] = $metadata[
'providerMetadata'];
648 $newProviderMetadata = $provider->mergeMetadata(
649 $metadata[
'providerMetadata'], $providerMetadata
651 if ( $newProviderMetadata !== $providerMetadata ) {
652 $newParams[
'metadata'] = $newProviderMetadata;
654 }
catch ( MetadataMergeException $ex ) {
655 $this->logger->warning(
656 'Session "{session}": Metadata merge failed: {exception}',
658 'session' => $info->__toString(),
660 ] + $ex->getContext()
662 return $failHandler();
668 $userInfo = $info->getUserInfo();
672 if ( $metadata[
'userId'] ) {
673 $userInfo = UserInfo::newFromId( $metadata[
'userId'] );
674 } elseif ( $metadata[
'userName'] !==
null ) {
675 $userInfo = UserInfo::newFromName( $metadata[
'userName'] );
677 $userInfo = UserInfo::newAnonymous();
679 }
catch ( InvalidArgumentException $ex ) {
680 $this->logger->error(
'Session "{session}": {exception}', [
681 'session' => $info->__toString(),
684 return $failHandler();
686 $newParams[
'userInfo'] = $userInfo;
690 if ( $metadata[
'userId'] ) {
691 if ( $metadata[
'userId'] !== $userInfo->getId() ) {
693 $this->logger->warning(
694 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
696 'session' => $info->__toString(),
697 'uid_a' => $metadata[
'userId'],
698 'uid_b' => $userInfo->getId(),
699 'uname_a' => $metadata[
'userName'] ??
'<null>',
700 'uname_b' => $userInfo->getName() ??
'<null>',
702 return $failHandler();
706 if ( $metadata[
'userName'] !==
null &&
707 $userInfo->getName() !== $metadata[
'userName']
709 $this->logger->warning(
710 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
712 'session' => $info->__toString(),
713 'uname_a' => $metadata[
'userName'],
714 'uname_b' => $userInfo->getName(),
716 return $failHandler();
719 } elseif ( $metadata[
'userName'] !==
null ) {
720 if ( $metadata[
'userName'] !== $userInfo->getName() ) {
721 $this->logger->warning(
722 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
724 'session' => $info->__toString(),
725 'uname_a' => $metadata[
'userName'],
726 'uname_b' => $userInfo->getName(),
728 return $failHandler();
730 } elseif ( !$userInfo->isAnon() ) {
734 $this->logger->warning(
735 'Session "{session}": the session store entry is for an anonymous user, '
736 .
'but the session metadata indicates a non-anonynmous user',
738 'session' => $info->__toString(),
740 return $failHandler();
746 if ( $metadata[
'userToken'] !==
null &&
747 $userInfo->getToken() !== $metadata[
'userToken']
749 $this->logger->warning(
'Session "{session}": User token mismatch', [
750 'session' => $info->__toString(),
752 return $failHandler();
754 if ( !$userInfo->isVerified() ) {
755 $newParams[
'userInfo'] = $userInfo->verified();
758 if ( !empty( $metadata[
'remember'] ) && !$info->wasRemembered() ) {
759 $newParams[
'remembered'] =
true;
761 if ( !empty( $metadata[
'forceHTTPS'] ) && !$info->forceHTTPS() ) {
762 $newParams[
'forceHTTPS'] =
true;
764 if ( !empty( $metadata[
'persisted'] ) && !$info->wasPersisted() ) {
765 $newParams[
'persisted'] =
true;
768 if ( !$info->isIdSafe() ) {
769 $newParams[
'idIsSafe'] =
true;
773 if ( $info->getProvider() ===
null ) {
774 $this->logger->warning(
775 'Session "{session}": Null provider and no metadata',
777 'session' => $info->__toString(),
779 return $failHandler();
783 if ( !$info->getUserInfo() ) {
784 if ( $info->getProvider()->canChangeUser() ) {
785 $newParams[
'userInfo'] = UserInfo::newAnonymous();
790 'Session "{session}": No user provided and provider cannot set user',
792 'session' => $info->__toString(),
794 return $failHandler();
796 } elseif ( !$info->getUserInfo()->isVerified() ) {
801 'Session "{session}": Unverified user provided and no metadata to auth it',
803 'session' => $info->__toString(),
805 return $failHandler();
811 if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
814 $newParams[
'idIsSafe'] =
true;
820 $newParams[
'copyFrom'] = $info;
821 $info =
new SessionInfo( $info->getPriority(), $newParams );
825 $providerMetadata = $info->getProviderMetadata();
826 if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
827 return $failHandler();
829 if ( $providerMetadata !== $info->getProviderMetadata() ) {
830 $info =
new SessionInfo( $info->getPriority(), [
831 'metadata' => $providerMetadata,
839 $reason =
'Hook aborted';
840 if ( !$this->hookRunner->onSessionCheckInfo(
841 $reason, $info, $request, $metadata, $data )
843 $this->logger->warning(
'Session "{session}": ' . $reason, [
844 'session' => $info->__toString(),
846 return $failHandler();
862 if ( defined(
'MW_NO_SESSION' ) ) {
867 $this->logger->error(
'Sessions are supposed to be disabled for this entry point', [
868 'exception' =>
new \BadMethodCallException(
"Sessions are disabled for $ep entry point" ),
871 throw new \BadMethodCallException(
"Sessions are disabled for $ep entry point" );
876 $id = $info->
getId();
878 if ( !isset( $this->allSessionBackends[$id] ) ) {
879 if ( !isset( $this->allSessionIds[$id] ) ) {
880 $this->allSessionIds[$id] =
new SessionId( $id );
883 $this->allSessionIds[$id],
887 $this->hookContainer,
888 $this->config->get( MainConfigNames::ObjectCacheSessionExpiry )
890 $this->allSessionBackends[$id] = $backend;
891 $delay = $backend->delaySave();
893 $backend = $this->allSessionBackends[$id];
894 $delay = $backend->delaySave();
899 $backend->setRememberUser(
true );
904 $session = $backend->getSession( $request );
910 \Wikimedia\ScopedCallback::consume( $delay );
920 $id = $backend->
getId();
921 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
922 $this->allSessionBackends[$id] !== $backend ||
925 throw new InvalidArgumentException(
'Backend was not registered with this SessionManager' );
928 unset( $this->allSessionBackends[$id] );
939 $oldId = (string)$sessionId;
940 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
941 $this->allSessionBackends[$oldId] !== $backend ||
942 $this->allSessionIds[$oldId] !== $sessionId
944 throw new InvalidArgumentException(
'Backend was not registered with this SessionManager' );
947 $newId = $this->generateSessionId();
949 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
950 $sessionId->setId( $newId );
951 $this->allSessionBackends[$newId] = $backend;
952 $this->allSessionIds[$newId] = $sessionId;
960 $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
962 $key = $this->store->makeKey(
'MWSession', $id );
963 $this->store->set( $key,
false, 0, BagOStuff::WRITE_CACHE_ONLY );
973 $handler->
setManager( $this, $this->store, $this->logger );
982 if ( !defined(
'MW_PHPUNIT_TEST' ) && !defined(
'MW_PARSER_TEST' ) ) {
984 throw new LogicException( __METHOD__ .
' may only be called from unit tests!' );
988 self::$globalSession =
null;
989 self::$globalSessionRequest =
null;
994 'id' => $info->
getId(),
998 'clientip' => $request->
getIP(),
999 'userAgent' => $request->
getHeader(
'user-agent' ),
1003 $logData[
'user'] = $info->
getUserInfo()->getName();
1005 $logData[
'userVerified'] = $info->
getUserInfo()->isVerified();
1007 $this->logger->info(
'Failed to load session, unpersisting', $logData );
1021 $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
1022 $session = $session ?: self::getGlobalSession();
1023 $suspiciousIpExpiry = $this->config->get( MainConfigNames::SuspiciousIpExpiry );
1025 if ( $suspiciousIpExpiry ===
false
1027 || !$session->isPersistent() || $session->getUser()->isAnon()
1034 $ip = $session->getRequest()->getIP();
1038 if ( $ip ===
'127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1041 $mwuser = $session->getRequest()->getCookie(
'mwuser-sessionId' );
1042 $now = (int)\
MediaWiki\Utils\MWTimestamp::now( TS_UNIX );
1048 $data = $session->get(
'SessionManager-logPotentialSessionLeakage', [] )
1049 + [
'ip' =>
null,
'mwuser' =>
null,
'timestamp' => 0 ];
1053 ( $now - $data[
'timestamp'] > $suspiciousIpExpiry )
1055 $data[
'ip'] = $data[
'timestamp'] =
null;
1058 if ( $data[
'ip'] !== $ip || $data[
'mwuser'] !== $mwuser ) {
1059 $session->set(
'SessionManager-logPotentialSessionLeakage',
1060 [
'ip' => $ip,
'mwuser' => $mwuser,
'timestamp' => $now ] );
1063 $ipChanged = ( $data[
'ip'] && $data[
'ip'] !== $ip );
1064 $mwuserChanged = ( $data[
'mwuser'] && $data[
'mwuser'] !== $mwuser );
1065 $logLevel = $message =
null;
1073 $logLevel = LogLevel::INFO;
1074 $message =
'IP change within the same session';
1076 'oldIp' => $data[
'ip'],
1077 'oldIpRecorded' => $data[
'timestamp'],
1080 if ( $mwuserChanged ) {
1081 $logLevel = LogLevel::NOTICE;
1082 $message =
'mwuser change within the same session';
1084 'oldMwuser' => $data[
'mwuser'],
1085 'newMwuser' => $mwuser,
1088 if ( $ipChanged && $mwuserChanged ) {
1089 $logLevel = LogLevel::WARNING;
1090 $message =
'IP and mwuser change within the same session';
1094 'session' => $session->getId(),
1095 'user' => $session->getUser()->getName(),
1097 'userAgent' => $session->getRequest()->getHeader(
'user-agent' ),
1099 $logger = \MediaWiki\Logger\LoggerFactory::getInstance(
'session-ip' );
1101 $logger->log( $logLevel, $message, $logData );
1115 private function logSessionWrite(
1119 string $level = LogLevel::INFO
1121 $user = ( !$info->getUserInfo() || $info->getUserInfo()->isAnon() )
1123 : $info->getUserInfo()->getName();
1124 $this->logger->log( $level,
'Session store: {action} for {reason}', $data + [
1125 'action' => $data[
'type'],
1126 'reason' => $data[
'reason'],
1127 'id' => $info->
getId(),
1130 'clientip' => $request->getIP(),
1131 'userAgent' => $request->getHeader(
'user-agent' ),