82 private static $instance =
null;
85 private static $globalSession =
null;
88 private static $globalSessionRequest =
null;
94 private $hookContainer;
103 private $userNameUtils;
109 private $sessionProviders =
null;
112 private $varyCookies =
null;
115 private $varyHeaders =
null;
118 private $allSessionBackends = [];
121 private $allSessionIds = [];
124 private $preventUsers = [];
131 if ( self::$instance ===
null ) {
132 self::$instance =
new self();
134 return self::$instance;
150 $request = RequestContext::getMain()->getRequest();
152 !self::$globalSession
153 || self::$globalSessionRequest !== $request
154 || ( $id !==
'' && self::$globalSession->getId() !== $id )
156 self::$globalSessionRequest = $request;
166 self::$globalSession = $request->
getSession();
171 self::$globalSession =
self::singleton()->getSessionById( $id,
true, $request )
175 return self::$globalSession;
186 if ( isset( $options[
'config'] ) ) {
187 $this->config = $options[
'config'];
188 if ( !$this->config instanceof
Config ) {
189 throw new \InvalidArgumentException(
190 '$options[\'config\'] must be an instance of Config'
194 $this->config = $services->getMainConfig();
197 if ( isset( $options[
'logger'] ) ) {
198 if ( !$options[
'logger'] instanceof LoggerInterface ) {
199 throw new \InvalidArgumentException(
200 '$options[\'logger\'] must be an instance of LoggerInterface'
203 $this->setLogger( $options[
'logger'] );
205 $this->setLogger( \
MediaWiki\Logger\LoggerFactory::getInstance(
'session' ) );
208 if ( isset( $options[
'hookContainer'] ) ) {
209 $this->setHookContainer( $options[
'hookContainer'] );
211 $this->setHookContainer( $services->getHookContainer() );
214 if ( isset( $options[
'store'] ) ) {
215 if ( !$options[
'store'] instanceof
BagOStuff ) {
216 throw new \InvalidArgumentException(
217 '$options[\'store\'] must be an instance of BagOStuff'
220 $store = $options[
'store'];
222 $store = $services->getObjectCacheFactory()
226 $this->logger->debug(
'SessionManager using store ' . get_class( $store ) );
228 $this->userNameUtils = $services->getUserNameUtils();
230 register_shutdown_function( [ $this,
'shutdown' ] );
234 $this->logger = $logger;
242 $this->hookContainer = $hookContainer;
243 $this->hookRunner =
new HookRunner( $hookContainer );
247 $info = $this->getSessionInfoForRequest( $request );
250 $session = $this->getInitialSession( $request );
252 $session = $this->getSessionFromInfo( $info, $request );
258 if ( !self::validateSessionId( $id ) ) {
259 throw new \InvalidArgumentException(
'Invalid session ID' );
269 if ( isset( $this->allSessionBackends[$id] ) ) {
270 return $this->getSessionFromInfo( $info, $request );
274 $key = $this->store->makeKey(
'MWSession', $id );
275 if ( is_array( $this->store->get( $key ) ) ) {
277 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
278 $session = $this->getSessionFromInfo( $info, $request );
282 if ( $create && $session ===
null ) {
284 $session = $this->getEmptySessionInternal( $request, $id );
285 }
catch ( \Exception $ex ) {
286 $this->logger->error(
'Failed to create empty session: {exception}',
288 'method' => __METHOD__,
299 return $this->getEmptySessionInternal( $request );
308 private function getEmptySessionInternal(
WebRequest $request =
null, $id =
null ) {
309 if ( $id !==
null ) {
310 if ( !self::validateSessionId( $id ) ) {
311 throw new \InvalidArgumentException(
'Invalid session ID' );
314 $key = $this->store->makeKey(
'MWSession', $id );
315 if ( is_array( $this->store->get( $key ) ) ) {
316 throw new \InvalidArgumentException(
'Session ID already exists' );
320 $request =
new FauxRequest;
324 foreach ( $this->getProviders() as $provider ) {
325 $info = $provider->newSessionInfo( $id );
330 throw new \UnexpectedValueException(
331 "$provider returned an empty session info for a different provider: $info"
334 if ( $id !==
null && $info->
getId() !== $id ) {
335 throw new \UnexpectedValueException(
336 "$provider returned empty session info with a wrong id: " .
337 $info->
getId() .
' != ' . $id
341 throw new \UnexpectedValueException(
342 "$provider returned empty session info with id flagged unsafe"
346 if ( $compare > 0 ) {
349 if ( $compare === 0 ) {
357 if ( count( $infos ) > 1 ) {
358 throw new \UnexpectedValueException(
359 'Multiple empty sessions tied for top priority: ' . implode(
', ', $infos )
361 } elseif ( count( $infos ) < 1 ) {
362 throw new \UnexpectedValueException(
'No provider could provide an empty session!' );
366 return $this->getSessionFromInfo( $infos[0], $request );
378 private function getInitialSession( WebRequest $request =
null ) {
379 $session = $this->getEmptySession( $request );
380 $session->getToken();
388 foreach ( $this->getProviders() as $provider ) {
389 $provider->invalidateSessionsForUser( $user );
398 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
402 if ( $this->varyHeaders ===
null ) {
404 foreach ( $this->getProviders() as $provider ) {
405 foreach ( $provider->getVaryHeaders() as
$header => $_ ) {
409 $this->varyHeaders = $headers;
411 return $this->varyHeaders;
416 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
420 if ( $this->varyCookies ===
null ) {
422 foreach ( $this->getProviders() as $provider ) {
423 $cookies = array_merge( $cookies, $provider->getVaryCookies() );
425 $this->varyCookies = array_values( array_unique( $cookies ) );
427 return $this->varyCookies;
436 return is_string( $id ) && preg_match(
'/^[a-zA-Z0-9_-]{32,}$/', $id );
453 $this->preventUsers[$username] =
true;
456 foreach ( $this->getProviders() as $provider ) {
457 $provider->preventSessionsForUser( $username );
468 return !empty( $this->preventUsers[$username] );
476 if ( $this->sessionProviders ===
null ) {
477 $this->sessionProviders = [];
481 $provider = $objectFactory->createObject( $spec );
486 $this->hookContainer,
489 if ( isset( $this->sessionProviders[(
string)$provider] ) ) {
491 throw new \UnexpectedValueException(
"Duplicate provider name \"$provider\"" );
493 $this->sessionProviders[(string)$provider] = $provider;
496 return $this->sessionProviders;
510 $providers = $this->getProviders();
511 return $providers[$name] ??
null;
519 if ( $this->allSessionBackends ) {
520 $this->logger->debug(
'Saving all sessions on shutdown' );
521 if ( session_id() !==
'' ) {
523 session_write_close();
526 foreach ( $this->allSessionBackends as $backend ) {
527 $backend->shutdown();
537 private function getSessionInfoForRequest(
WebRequest $request ) {
540 foreach ( $this->getProviders() as $provider ) {
541 $info = $provider->provideSessionInfo( $request );
546 throw new \UnexpectedValueException(
547 "$provider returned session info for a different provider: $info"
556 usort( $infos, [ SessionInfo::class,
'compare' ] );
559 $info = array_pop( $infos );
560 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
564 $info = array_pop( $infos );
569 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
575 $this->logUnpersist( $info, $request );
576 $info->
getProvider()->unpersistSession( $request );
581 $this->logUnpersist( $info, $request );
582 $info->
getProvider()->unpersistSession( $request );
586 if ( count( $retInfos ) > 1 ) {
587 throw new SessionOverflowException(
589 'Multiple sessions for this request tied for top priority: ' . implode(
', ', $retInfos )
593 return $retInfos[0] ??
null;
603 private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
604 $key = $this->store->makeKey(
'MWSession', $info->getId() );
605 $blob = $this->store->get( $key );
610 if ( $info->forceUse() && $blob !==
false ) {
611 $failHandler =
function () use ( $key, &$info, $request ) {
612 $this->store->delete( $key );
613 return $this->loadSessionInfoFromStore( $info, $request );
616 $failHandler =
static function () {
623 if ( $blob !==
false ) {
625 if ( !is_array( $blob ) ) {
626 $this->logger->warning(
'Session "{session}": Bad data', [
627 'session' => $info->__toString(),
629 $this->store->delete( $key );
630 return $failHandler();
634 if ( !isset( $blob[
'data'] ) || !is_array( $blob[
'data'] ) ||
635 !isset( $blob[
'metadata'] ) || !is_array( $blob[
'metadata'] )
637 $this->logger->warning(
'Session "{session}": Bad data structure', [
638 'session' => $info->__toString(),
640 $this->store->delete( $key );
641 return $failHandler();
644 $data = $blob[
'data'];
645 $metadata = $blob[
'metadata'];
649 if ( !array_key_exists(
'userId', $metadata ) ||
650 !array_key_exists(
'userName', $metadata ) ||
651 !array_key_exists(
'userToken', $metadata ) ||
652 !array_key_exists(
'provider', $metadata )
654 $this->logger->warning(
'Session "{session}": Bad metadata', [
655 'session' => $info->__toString(),
657 $this->store->delete( $key );
658 return $failHandler();
662 $provider = $info->getProvider();
663 if ( $provider ===
null ) {
664 $newParams[
'provider'] = $provider = $this->getProvider( $metadata[
'provider'] );
666 $this->logger->warning(
667 'Session "{session}": Unknown provider ' . $metadata[
'provider'],
669 'session' => $info->__toString(),
672 $this->store->delete( $key );
673 return $failHandler();
675 } elseif ( $metadata[
'provider'] !== (
string)$provider ) {
676 $this->logger->warning(
'Session "{session}": Wrong provider ' .
677 $metadata[
'provider'] .
' !== ' . $provider,
679 'session' => $info->__toString(),
681 return $failHandler();
685 $providerMetadata = $info->getProviderMetadata();
686 if ( isset( $metadata[
'providerMetadata'] ) ) {
687 if ( $providerMetadata ===
null ) {
688 $newParams[
'metadata'] = $metadata[
'providerMetadata'];
691 $newProviderMetadata = $provider->mergeMetadata(
692 $metadata[
'providerMetadata'], $providerMetadata
694 if ( $newProviderMetadata !== $providerMetadata ) {
695 $newParams[
'metadata'] = $newProviderMetadata;
697 }
catch ( MetadataMergeException $ex ) {
698 $this->logger->warning(
699 'Session "{session}": Metadata merge failed: {exception}',
701 'session' => $info->__toString(),
703 ] + $ex->getContext()
705 return $failHandler();
711 $userInfo = $info->getUserInfo();
715 if ( $metadata[
'userId'] ) {
717 } elseif ( $metadata[
'userName'] !==
null ) {
722 }
catch ( \InvalidArgumentException $ex ) {
723 $this->logger->error(
'Session "{session}": {exception}', [
724 'session' => $info->__toString(),
727 return $failHandler();
729 $newParams[
'userInfo'] = $userInfo;
733 if ( $metadata[
'userId'] ) {
734 if ( $metadata[
'userId'] !== $userInfo->getId() ) {
736 $this->logger->warning(
737 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
739 'session' => $info->__toString(),
740 'uid_a' => $metadata[
'userId'],
741 'uid_b' => $userInfo->getId(),
742 'uname_a' => $metadata[
'userName'] ??
'<null>',
743 'uname_b' => $userInfo->getName() ??
'<null>',
745 return $failHandler();
749 if ( $metadata[
'userName'] !==
null &&
750 $userInfo->getName() !== $metadata[
'userName']
752 $this->logger->warning(
753 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
755 'session' => $info->__toString(),
756 'uname_a' => $metadata[
'userName'],
757 'uname_b' => $userInfo->getName(),
759 return $failHandler();
762 } elseif ( $metadata[
'userName'] !==
null ) {
763 if ( $metadata[
'userName'] !== $userInfo->getName() ) {
764 $this->logger->warning(
765 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
767 'session' => $info->__toString(),
768 'uname_a' => $metadata[
'userName'],
769 'uname_b' => $userInfo->getName(),
771 return $failHandler();
773 } elseif ( !$userInfo->isAnon() ) {
777 $this->logger->warning(
778 'Session "{session}": the session store entry is for an anonymous user, '
779 .
'but the session metadata indicates a non-anonynmous user',
781 'session' => $info->__toString(),
783 return $failHandler();
789 if ( $metadata[
'userToken'] !==
null &&
790 $userInfo->getToken() !== $metadata[
'userToken']
792 $this->logger->warning(
'Session "{session}": User token mismatch', [
793 'session' => $info->__toString(),
795 return $failHandler();
797 if ( !$userInfo->isVerified() ) {
798 $newParams[
'userInfo'] = $userInfo->verified();
801 if ( !empty( $metadata[
'remember'] ) && !$info->wasRemembered() ) {
802 $newParams[
'remembered'] =
true;
804 if ( !empty( $metadata[
'forceHTTPS'] ) && !$info->forceHTTPS() ) {
805 $newParams[
'forceHTTPS'] =
true;
807 if ( !empty( $metadata[
'persisted'] ) && !$info->wasPersisted() ) {
808 $newParams[
'persisted'] =
true;
811 if ( !$info->isIdSafe() ) {
812 $newParams[
'idIsSafe'] =
true;
816 if ( $info->getProvider() ===
null ) {
817 $this->logger->warning(
818 'Session "{session}": Null provider and no metadata',
820 'session' => $info->__toString(),
822 return $failHandler();
826 if ( !$info->getUserInfo() ) {
827 if ( $info->getProvider()->canChangeUser() ) {
833 'Session "{session}": No user provided and provider cannot set user',
835 'session' => $info->__toString(),
837 return $failHandler();
839 } elseif ( !$info->getUserInfo()->isVerified() ) {
844 'Session "{session}": Unverified user provided and no metadata to auth it',
846 'session' => $info->__toString(),
848 return $failHandler();
854 if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
857 $newParams[
'idIsSafe'] =
true;
863 $newParams[
'copyFrom'] = $info;
864 $info =
new SessionInfo( $info->getPriority(), $newParams );
868 $providerMetadata = $info->getProviderMetadata();
869 if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
870 return $failHandler();
872 if ( $providerMetadata !== $info->getProviderMetadata() ) {
873 $info =
new SessionInfo( $info->getPriority(), [
874 'metadata' => $providerMetadata,
882 $reason =
'Hook aborted';
883 if ( !$this->hookRunner->onSessionCheckInfo(
884 $reason, $info, $request, $metadata, $data )
886 $this->logger->warning(
'Session "{session}": ' . $reason, [
887 'session' => $info->__toString(),
889 return $failHandler();
905 if ( defined(
'MW_NO_SESSION' ) ) {
910 $this->logger->error(
'Sessions are supposed to be disabled for this entry point', [
911 'exception' =>
new \BadMethodCallException(
"Sessions are disabled for $ep entry point" ),
914 throw new \BadMethodCallException(
"Sessions are disabled for $ep entry point" );
919 $id = $info->
getId();
921 if ( !isset( $this->allSessionBackends[$id] ) ) {
922 if ( !isset( $this->allSessionIds[$id] ) ) {
923 $this->allSessionIds[$id] =
new SessionId( $id );
926 $this->allSessionIds[$id],
930 $this->hookContainer,
933 $this->allSessionBackends[$id] = $backend;
934 $delay = $backend->delaySave();
936 $backend = $this->allSessionBackends[$id];
937 $delay = $backend->delaySave();
942 $backend->setRememberUser(
true );
947 $session = $backend->getSession( $request );
953 \Wikimedia\ScopedCallback::consume( $delay );
963 $id = $backend->
getId();
964 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
965 $this->allSessionBackends[$id] !== $backend ||
968 throw new \InvalidArgumentException(
'Backend was not registered with this SessionManager' );
971 unset( $this->allSessionBackends[$id] );
982 $oldId = (string)$sessionId;
983 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
984 $this->allSessionBackends[$oldId] !== $backend ||
985 $this->allSessionIds[$oldId] !== $sessionId
987 throw new \InvalidArgumentException(
'Backend was not registered with this SessionManager' );
990 $newId = $this->generateSessionId();
992 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
993 $sessionId->setId( $newId );
994 $this->allSessionBackends[$newId] = $backend;
995 $this->allSessionIds[$newId] = $sessionId;
1003 $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
1005 $key = $this->store->makeKey(
'MWSession', $id );
1006 $this->store->set( $key,
false, 0, BagOStuff::WRITE_CACHE_ONLY );
1016 $handler->
setManager( $this, $this->store, $this->logger );
1025 if ( !defined(
'MW_PHPUNIT_TEST' ) && !defined(
'MW_PARSER_TEST' ) ) {
1027 throw new LogicException( __METHOD__ .
' may only be called from unit tests!' );
1031 self::$globalSession =
null;
1032 self::$globalSessionRequest =
null;
1037 'id' => $info->
getId(),
1040 'clientip' => $request->
getIP(),
1041 'userAgent' => $request->
getHeader(
'user-agent' ),
1045 $logData[
'user'] = $info->
getUserInfo()->getName();
1047 $logData[
'userVerified'] = $info->
getUserInfo()->isVerified();
1049 $this->logger->info(
'Failed to load session, unpersisting', $logData );
1064 $session = $session ?: self::getGlobalSession();
1067 if ( $suspiciousIpExpiry ===
false
1069 || !$session->isPersistent() || $session->getUser()->isAnon()
1076 $ip = $session->getRequest()->getIP();
1080 if ( $ip ===
'127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1083 $mwuser = $session->getRequest()->getCookie(
'mwuser-sessionId' );
1084 $now = (int)\
MediaWiki\Utils\MWTimestamp::now( TS_UNIX );
1090 $data = $session->get(
'SessionManager-logPotentialSessionLeakage', [] )
1091 + [
'ip' =>
null,
'mwuser' =>
null,
'timestamp' => 0 ];
1095 ( $now - $data[
'timestamp'] > $suspiciousIpExpiry )
1097 $data[
'ip'] = $data[
'timestamp'] =
null;
1100 if ( $data[
'ip'] !== $ip || $data[
'mwuser'] !== $mwuser ) {
1101 $session->set(
'SessionManager-logPotentialSessionLeakage',
1102 [
'ip' => $ip,
'mwuser' => $mwuser,
'timestamp' => $now ] );
1105 $ipChanged = ( $data[
'ip'] && $data[
'ip'] !== $ip );
1106 $mwuserChanged = ( $data[
'mwuser'] && $data[
'mwuser'] !== $mwuser );
1107 $logLevel = $message =
null;
1115 $logLevel = LogLevel::INFO;
1116 $message =
'IP change within the same session';
1118 'oldIp' => $data[
'ip'],
1119 'oldIpRecorded' => $data[
'timestamp'],
1122 if ( $mwuserChanged ) {
1123 $logLevel = LogLevel::NOTICE;
1124 $message =
'mwuser change within the same session';
1126 'oldMwuser' => $data[
'mwuser'],
1127 'newMwuser' => $mwuser,
1130 if ( $ipChanged && $mwuserChanged ) {
1131 $logLevel = LogLevel::WARNING;
1132 $message =
'IP and mwuser change within the same session';
1136 'session' => $session->getId(),
1137 'user' => $session->getUser()->getName(),
1139 'userAgent' => $session->getRequest()->getHeader(
'user-agent' ),
1141 $logger = \MediaWiki\Logger\LoggerFactory::getInstance(
'session-ip' );
1143 $logger->log( $logLevel, $message, $logData );