26use BadMethodCallException;
37use 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;
149 $request = \RequestContext::getMain()->getRequest();
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'] );
203 $this->setLogger( \
MediaWiki\Logger\LoggerFactory::getInstance(
'session' ) );
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 );
992 $this->store->set( $key,
false, 0, BagOStuff::WRITE_CACHE_ONLY );
1002 $handler->
setManager( $this, $this->store, $this->logger );
1011 if ( !defined(
'MW_PHPUNIT_TEST' ) && !defined(
'MW_PARSER_TEST' ) ) {
1013 throw new BadMethodCallException( __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)\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' ),
1127 $logger = \MediaWiki\Logger\LoggerFactory::getInstance(
'session-ip' );
1129 $logger->log( $logLevel, $message, $logData );
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Class representing a cache/ephemeral data store.
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()
saveSettings()
Save this user's settings into the database.
setToken( $token=false)
Set the random token (used for persistent authentication) Called from loadDefaults() among other plac...
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
getSession()
Return the session for this request.
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
setSessionId(SessionId $sessionId)
Set the session for this request.
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.
Interface for configuration instances.