39 use Psr\Log\LoggerInterface;
81 private static $instance =
null;
84 private static $globalSession =
null;
87 private static $globalSessionRequest =
null;
93 private $hookContainer;
102 private $userNameUtils;
108 private $sessionProviders =
null;
111 private $varyCookies =
null;
114 private $varyHeaders =
null;
117 private $allSessionBackends = [];
120 private $allSessionIds = [];
123 private $preventUsers = [];
130 if ( self::$instance ===
null ) {
131 self::$instance =
new self();
133 return self::$instance;
151 !self::$globalSession
152 || self::$globalSessionRequest !== $request
153 || $id !==
'' && self::$globalSession->getId() !== $id
155 self::$globalSessionRequest = $request;
165 self::$globalSession = $request->
getSession();
170 self::$globalSession =
self::singleton()->getSessionById( $id,
true, $request )
174 return self::$globalSession;
184 if ( isset( $options[
'config'] ) ) {
185 $this->config = $options[
'config'];
186 if ( !$this->config instanceof
Config ) {
187 throw new \InvalidArgumentException(
188 '$options[\'config\'] must be an instance of Config'
195 if ( isset( $options[
'logger'] ) ) {
196 if ( !$options[
'logger'] instanceof LoggerInterface ) {
197 throw new \InvalidArgumentException(
198 '$options[\'logger\'] must be an instance of LoggerInterface'
201 $this->setLogger( $options[
'logger'] );
206 if ( isset( $options[
'hookContainer'] ) ) {
207 $this->setHookContainer( $options[
'hookContainer'] );
212 if ( isset( $options[
'store'] ) ) {
213 if ( !$options[
'store'] instanceof
BagOStuff ) {
214 throw new \InvalidArgumentException(
215 '$options[\'store\'] must be an instance of BagOStuff'
218 $store = $options[
'store'];
223 $this->logger->debug(
'SessionManager using store ' . get_class( $store ) );
227 register_shutdown_function( [ $this,
'shutdown' ] );
231 $this->logger = $logger;
239 $this->hookContainer = $hookContainer;
240 $this->hookRunner =
new HookRunner( $hookContainer );
244 $info = $this->getSessionInfoForRequest( $request );
247 $session = $this->getInitialSession( $request );
249 $session = $this->getSessionFromInfo( $info, $request );
255 if ( !self::validateSessionId( $id ) ) {
256 throw new \InvalidArgumentException(
'Invalid session ID' );
266 if ( isset( $this->allSessionBackends[$id] ) ) {
267 return $this->getSessionFromInfo( $info, $request );
271 $key = $this->store->makeKey(
'MWSession', $id );
272 if ( is_array( $this->store->get( $key ) ) ) {
274 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
275 $session = $this->getSessionFromInfo( $info, $request );
279 if ( $create && $session ===
null ) {
281 $session = $this->getEmptySessionInternal( $request, $id );
282 }
catch ( \Exception $ex ) {
283 $this->logger->error(
'Failed to create empty session: {exception}',
285 'method' => __METHOD__,
296 return $this->getEmptySessionInternal( $request );
305 private function getEmptySessionInternal(
WebRequest $request =
null, $id =
null ) {
306 if ( $id !==
null ) {
307 if ( !self::validateSessionId( $id ) ) {
308 throw new \InvalidArgumentException(
'Invalid session ID' );
311 $key = $this->store->makeKey(
'MWSession', $id );
312 if ( is_array( $this->store->get( $key ) ) ) {
313 throw new \InvalidArgumentException(
'Session ID already exists' );
317 $request =
new FauxRequest;
321 foreach ( $this->getProviders() as $provider ) {
322 $info = $provider->newSessionInfo( $id );
327 throw new \UnexpectedValueException(
328 "$provider returned an empty session info for a different provider: $info"
331 if ( $id !==
null && $info->
getId() !== $id ) {
332 throw new \UnexpectedValueException(
333 "$provider returned empty session info with a wrong id: " .
334 $info->
getId() .
' != ' . $id
338 throw new \UnexpectedValueException(
339 "$provider returned empty session info with id flagged unsafe"
343 if ( $compare > 0 ) {
346 if ( $compare === 0 ) {
354 if ( count( $infos ) > 1 ) {
355 throw new \UnexpectedValueException(
356 'Multiple empty sessions tied for top priority: ' . implode(
', ', $infos )
358 } elseif ( count( $infos ) < 1 ) {
359 throw new \UnexpectedValueException(
'No provider could provide an empty session!' );
363 return $this->getSessionFromInfo( $infos[0], $request );
375 private function getInitialSession( WebRequest $request =
null ) {
376 $session = $this->getEmptySession( $request );
377 $session->getToken();
385 foreach ( $this->getProviders() as $provider ) {
386 $provider->invalidateSessionsForUser( $user );
392 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
396 if ( $this->varyHeaders ===
null ) {
398 foreach ( $this->getProviders() as $provider ) {
399 foreach ( $provider->getVaryHeaders() as
$header => $options ) {
400 # Note that the $options value returned has been deprecated
405 $this->varyHeaders = $headers;
407 return $this->varyHeaders;
412 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
416 if ( $this->varyCookies ===
null ) {
418 foreach ( $this->getProviders() as $provider ) {
419 $cookies = array_merge( $cookies, $provider->getVaryCookies() );
421 $this->varyCookies = array_values( array_unique( $cookies ) );
423 return $this->varyCookies;
432 return is_string( $id ) && preg_match(
'/^[a-zA-Z0-9_-]{32,}$/', $id );
449 $this->preventUsers[$username] =
true;
452 foreach ( $this->getProviders() as $provider ) {
453 $provider->preventSessionsForUser( $username );
464 return !empty( $this->preventUsers[$username] );
472 if ( $this->sessionProviders ===
null ) {
473 $this->sessionProviders = [];
477 $provider = $objectFactory->createObject( $spec );
482 $this->hookContainer,
485 if ( isset( $this->sessionProviders[(
string)$provider] ) ) {
487 throw new \UnexpectedValueException(
"Duplicate provider name \"$provider\"" );
489 $this->sessionProviders[(string)$provider] = $provider;
492 return $this->sessionProviders;
506 $providers = $this->getProviders();
507 return $providers[$name] ??
null;
515 if ( $this->allSessionBackends ) {
516 $this->logger->debug(
'Saving all sessions on shutdown' );
517 if ( session_id() !==
'' ) {
519 session_write_close();
522 foreach ( $this->allSessionBackends as $backend ) {
523 $backend->shutdown();
533 private function getSessionInfoForRequest(
WebRequest $request ) {
536 foreach ( $this->getProviders() as $provider ) {
537 $info = $provider->provideSessionInfo( $request );
542 throw new \UnexpectedValueException(
543 "$provider returned session info for a different provider: $info"
552 usort( $infos, [ SessionInfo::class,
'compare' ] );
555 $info = array_pop( $infos );
556 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
560 $info = array_pop( $infos );
565 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
571 $this->logUnpersist( $info, $request );
572 $info->
getProvider()->unpersistSession( $request );
577 $this->logUnpersist( $info, $request );
578 $info->
getProvider()->unpersistSession( $request );
582 if ( count( $retInfos ) > 1 ) {
583 throw new SessionOverflowException(
585 'Multiple sessions for this request tied for top priority: ' . implode(
', ', $retInfos )
589 return $retInfos[0] ??
null;
599 private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
600 $key = $this->store->makeKey(
'MWSession', $info->getId() );
601 $blob = $this->store->get( $key );
606 if ( $info->forceUse() && $blob !==
false ) {
607 $failHandler =
function () use ( $key, &$info, $request ) {
608 $this->store->delete( $key );
609 return $this->loadSessionInfoFromStore( $info, $request );
612 $failHandler =
static function () {
619 if ( $blob !==
false ) {
621 if ( !is_array( $blob ) ) {
622 $this->logger->warning(
'Session "{session}": Bad data', [
623 'session' => $info->__toString(),
625 $this->store->delete( $key );
626 return $failHandler();
630 if ( !isset( $blob[
'data'] ) || !is_array( $blob[
'data'] ) ||
631 !isset( $blob[
'metadata'] ) || !is_array( $blob[
'metadata'] )
633 $this->logger->warning(
'Session "{session}": Bad data structure', [
634 'session' => $info->__toString(),
636 $this->store->delete( $key );
637 return $failHandler();
640 $data = $blob[
'data'];
641 $metadata = $blob[
'metadata'];
645 if ( !array_key_exists(
'userId', $metadata ) ||
646 !array_key_exists(
'userName', $metadata ) ||
647 !array_key_exists(
'userToken', $metadata ) ||
648 !array_key_exists(
'provider', $metadata )
650 $this->logger->warning(
'Session "{session}": Bad metadata', [
651 'session' => $info->__toString(),
653 $this->store->delete( $key );
654 return $failHandler();
658 $provider = $info->getProvider();
659 if ( $provider ===
null ) {
660 $newParams[
'provider'] = $provider = $this->getProvider( $metadata[
'provider'] );
662 $this->logger->warning(
663 'Session "{session}": Unknown provider ' . $metadata[
'provider'],
665 'session' => $info->__toString(),
668 $this->store->delete( $key );
669 return $failHandler();
671 } elseif ( $metadata[
'provider'] !== (
string)$provider ) {
672 $this->logger->warning(
'Session "{session}": Wrong provider ' .
673 $metadata[
'provider'] .
' !== ' . $provider,
675 'session' => $info->__toString(),
677 return $failHandler();
681 $providerMetadata = $info->getProviderMetadata();
682 if ( isset( $metadata[
'providerMetadata'] ) ) {
683 if ( $providerMetadata ===
null ) {
684 $newParams[
'metadata'] = $metadata[
'providerMetadata'];
687 $newProviderMetadata = $provider->mergeMetadata(
688 $metadata[
'providerMetadata'], $providerMetadata
690 if ( $newProviderMetadata !== $providerMetadata ) {
691 $newParams[
'metadata'] = $newProviderMetadata;
693 }
catch ( MetadataMergeException $ex ) {
694 $this->logger->warning(
695 'Session "{session}": Metadata merge failed: {exception}',
697 'session' => $info->__toString(),
699 ] + $ex->getContext()
701 return $failHandler();
707 $userInfo = $info->getUserInfo();
711 if ( $metadata[
'userId'] ) {
713 } elseif ( $metadata[
'userName'] !==
null ) {
718 }
catch ( \InvalidArgumentException $ex ) {
719 $this->logger->error(
'Session "{session}": {exception}', [
720 'session' => $info->__toString(),
723 return $failHandler();
725 $newParams[
'userInfo'] = $userInfo;
729 if ( $metadata[
'userId'] ) {
730 if ( $metadata[
'userId'] !== $userInfo->getId() ) {
731 $this->logger->warning(
732 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
734 'session' => $info->__toString(),
735 'uid_a' => $metadata[
'userId'],
736 'uid_b' => $userInfo->getId(),
738 return $failHandler();
742 if ( $metadata[
'userName'] !==
null &&
743 $userInfo->getName() !== $metadata[
'userName']
745 $this->logger->warning(
746 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
748 'session' => $info->__toString(),
749 'uname_a' => $metadata[
'userName'],
750 'uname_b' => $userInfo->getName(),
752 return $failHandler();
755 } elseif ( $metadata[
'userName'] !==
null ) {
756 if ( $metadata[
'userName'] !== $userInfo->getName() ) {
757 $this->logger->warning(
758 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
760 'session' => $info->__toString(),
761 'uname_a' => $metadata[
'userName'],
762 'uname_b' => $userInfo->getName(),
764 return $failHandler();
766 } elseif ( !$userInfo->isAnon() ) {
769 $this->logger->warning(
770 'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
772 'session' => $info->__toString(),
774 return $failHandler();
779 if ( $metadata[
'userToken'] !==
null &&
780 $userInfo->getToken() !== $metadata[
'userToken']
782 $this->logger->warning(
'Session "{session}": User token mismatch', [
783 'session' => $info->__toString(),
785 return $failHandler();
787 if ( !$userInfo->isVerified() ) {
788 $newParams[
'userInfo'] = $userInfo->verified();
791 if ( !empty( $metadata[
'remember'] ) && !$info->wasRemembered() ) {
792 $newParams[
'remembered'] =
true;
794 if ( !empty( $metadata[
'forceHTTPS'] ) && !$info->forceHTTPS() ) {
795 $newParams[
'forceHTTPS'] =
true;
797 if ( !empty( $metadata[
'persisted'] ) && !$info->wasPersisted() ) {
798 $newParams[
'persisted'] =
true;
801 if ( !$info->isIdSafe() ) {
802 $newParams[
'idIsSafe'] =
true;
806 if ( $info->getProvider() ===
null ) {
807 $this->logger->warning(
808 'Session "{session}": Null provider and no metadata',
810 'session' => $info->__toString(),
812 return $failHandler();
816 if ( !$info->getUserInfo() ) {
817 if ( $info->getProvider()->canChangeUser() ) {
821 'Session "{session}": No user provided and provider cannot set user',
823 'session' => $info->__toString(),
825 return $failHandler();
827 } elseif ( !$info->getUserInfo()->isVerified() ) {
830 'Session "{session}": Unverified user provided and no metadata to auth it',
832 'session' => $info->__toString(),
834 return $failHandler();
840 if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
843 $newParams[
'idIsSafe'] =
true;
849 $newParams[
'copyFrom'] = $info;
850 $info =
new SessionInfo( $info->getPriority(), $newParams );
854 $providerMetadata = $info->getProviderMetadata();
855 if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
856 return $failHandler();
858 if ( $providerMetadata !== $info->getProviderMetadata() ) {
859 $info =
new SessionInfo( $info->getPriority(), [
860 'metadata' => $providerMetadata,
868 $reason =
'Hook aborted';
869 if ( !$this->hookRunner->onSessionCheckInfo(
870 $reason, $info, $request, $metadata, $data )
872 $this->logger->warning(
'Session "{session}": ' . $reason, [
873 'session' => $info->__toString(),
875 return $failHandler();
891 if ( defined(
'MW_NO_SESSION' ) ) {
896 $this->logger->error(
'Sessions are supposed to be disabled for this entry point', [
897 'exception' =>
new \BadMethodCallException(
"Sessions are disabled for $ep entry point" ),
900 throw new \BadMethodCallException(
"Sessions are disabled for $ep entry point" );
905 $id = $info->
getId();
907 if ( !isset( $this->allSessionBackends[$id] ) ) {
908 if ( !isset( $this->allSessionIds[$id] ) ) {
909 $this->allSessionIds[$id] =
new SessionId( $id );
912 $this->allSessionIds[$id],
916 $this->hookContainer,
919 $this->allSessionBackends[$id] = $backend;
920 $delay = $backend->delaySave();
922 $backend = $this->allSessionBackends[$id];
923 $delay = $backend->delaySave();
928 $backend->setRememberUser(
true );
933 $session = $backend->getSession( $request );
939 \Wikimedia\ScopedCallback::consume( $delay );
949 $id = $backend->
getId();
950 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
951 $this->allSessionBackends[$id] !== $backend ||
954 throw new \InvalidArgumentException(
'Backend was not registered with this SessionManager' );
957 unset( $this->allSessionBackends[$id] );
968 $oldId = (string)$sessionId;
969 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
970 $this->allSessionBackends[$oldId] !== $backend ||
971 $this->allSessionIds[$oldId] !== $sessionId
973 throw new \InvalidArgumentException(
'Backend was not registered with this SessionManager' );
976 $newId = $this->generateSessionId();
978 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
979 $sessionId->setId( $newId );
980 $this->allSessionBackends[$newId] = $backend;
981 $this->allSessionIds[$newId] = $sessionId;
991 $key = $this->store->makeKey(
'MWSession', $id );
1002 $handler->
setManager( $this, $this->store, $this->logger );
1011 if ( !defined(
'MW_PHPUNIT_TEST' ) && !defined(
'MW_PARSER_TEST' ) ) {
1013 throw new LogicException( __METHOD__ .
' may only be called from unit tests!' );
1017 self::$globalSession =
null;
1018 self::$globalSessionRequest =
null;
1023 'id' => $info->
getId(),
1026 'clientip' => $request->
getIP(),
1027 'userAgent' => $request->
getHeader(
'user-agent' ),
1031 $logData[
'user'] = $info->
getUserInfo()->getName();
1033 $logData[
'userVerified'] = $info->
getUserInfo()->isVerified();
1035 $this->logger->info(
'Failed to load session, unpersisting', $logData );
1050 $session = $session ?: self::getGlobalSession();
1053 if ( $suspiciousIpExpiry ===
false
1055 || !$session->isPersistent() || $session->getUser()->isAnon()
1062 $ip = $session->getRequest()->getIP();
1066 if ( $ip ===
'127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1069 $mwuser = $session->getRequest()->getCookie(
'mwuser-sessionId' );
1070 $now = (int)\
MediaWiki\Utils\MWTimestamp::now( TS_UNIX );
1076 $data = $session->get(
'SessionManager-logPotentialSessionLeakage', [] )
1077 + [
'ip' =>
null,
'mwuser' =>
null,
'timestamp' => 0 ];
1081 ( $now - $data[
'timestamp'] > $suspiciousIpExpiry )
1083 $data[
'ip'] = $data[
'timestamp'] =
null;
1086 if ( $data[
'ip'] !== $ip || $data[
'mwuser'] !== $mwuser ) {
1087 $session->set(
'SessionManager-logPotentialSessionLeakage',
1088 [
'ip' => $ip,
'mwuser' => $mwuser,
'timestamp' => $now ] );
1091 $ipChanged = ( $data[
'ip'] && $data[
'ip'] !== $ip );
1092 $mwuserChanged = ( $data[
'mwuser'] && $data[
'mwuser'] !== $mwuser );
1093 $logLevel = $message =
null;
1101 $logLevel = LogLevel::INFO;
1102 $message =
'IP change within the same session';
1104 'oldIp' => $data[
'ip'],
1105 'oldIpRecorded' => $data[
'timestamp'],
1108 if ( $mwuserChanged ) {
1109 $logLevel = LogLevel::NOTICE;
1110 $message =
'mwuser change within the same session';
1112 'oldMwuser' => $data[
'mwuser'],
1113 'newMwuser' => $mwuser,
1116 if ( $ipChanged && $mwuserChanged ) {
1117 $logLevel = LogLevel::WARNING;
1118 $message =
'IP and mwuser change within the same session';
1122 'session' => $session->getId(),
1123 'user' => $session->getUser()->getName(),
1125 'userAgent' => $session->getRequest()->getHeader(
'user-agent' ),
1129 $logger->log( $logLevel, $message, $logData );
if(!defined('MW_SETUP_CALLBACK'))
Class representing a cache/ephemeral data store.
const WRITE_CACHE_ONLY
Bitfield constants for set()/merge(); these are only advisory.
Wrapper around a BagOStuff that caches data in memory.
static generateHex( $chars)
Generate a run of cryptographically random data and return it in hexadecimal string format.
A class containing constants representing the names of configuration variables.
const SuspiciousIpExpiry
Name constant for the SuspiciousIpExpiry setting, for use with Config::get()
const SessionCacheType
Name constant for the SessionCacheType setting, for use with Config::get()
const SessionProviders
Name constant for the SessionProviders setting, for use with Config::get()
const ObjectCacheSessionExpiry
Name constant for the ObjectCacheSessionExpiry setting, for use with Config::get()
static getInstance( $id)
Get a cached instance of the specified type of cache object.
static getMain()
Get the RequestContext object associated with the main request.