36use Psr\Log\LoggerInterface;
80 private static $instance =
null;
83 private static $globalSession =
null;
86 private static $globalSessionRequest =
null;
92 private $hookContainer;
101 private $userNameUtils;
107 private $sessionProviders =
null;
110 private $varyCookies =
null;
113 private $varyHeaders =
null;
116 private $allSessionBackends = [];
119 private $allSessionIds = [];
122 private $preventUsers = [];
129 if ( self::$instance ===
null ) {
130 self::$instance =
new self();
132 return self::$instance;
148 $request = \RequestContext::getMain()->getRequest();
150 !self::$globalSession
151 || self::$globalSessionRequest !== $request
152 || $id !==
'' && self::$globalSession->getId() !== $id
154 self::$globalSessionRequest = $request;
164 self::$globalSession = $request->
getSession();
169 self::$globalSession =
self::singleton()->getSessionById( $id,
true, $request )
173 return self::$globalSession;
183 if ( isset( $options[
'config'] ) ) {
184 $this->config = $options[
'config'];
185 if ( !$this->config instanceof
Config ) {
186 throw new \InvalidArgumentException(
187 '$options[\'config\'] must be an instance of Config'
194 if ( isset( $options[
'logger'] ) ) {
195 if ( !$options[
'logger'] instanceof LoggerInterface ) {
196 throw new \InvalidArgumentException(
197 '$options[\'logger\'] must be an instance of LoggerInterface'
200 $this->setLogger( $options[
'logger'] );
202 $this->setLogger( \
MediaWiki\Logger\LoggerFactory::getInstance(
'session' ) );
205 if ( isset( $options[
'hookContainer'] ) ) {
206 $this->setHookContainer( $options[
'hookContainer'] );
211 if ( isset( $options[
'store'] ) ) {
212 if ( !$options[
'store'] instanceof
BagOStuff ) {
213 throw new \InvalidArgumentException(
214 '$options[\'store\'] must be an instance of BagOStuff'
217 $store = $options[
'store'];
222 $this->logger->debug(
'SessionManager using store ' . get_class( $store ) );
224 $this->userNameUtils = MediawikiServices::getInstance()->getUserNameUtils();
226 register_shutdown_function( [ $this,
'shutdown' ] );
230 $this->logger = $logger;
238 $this->hookContainer = $hookContainer;
239 $this->hookRunner =
new HookRunner( $hookContainer );
243 $info = $this->getSessionInfoForRequest( $request );
246 $session = $this->getInitialSession( $request );
248 $session = $this->getSessionFromInfo( $info, $request );
254 if ( !self::validateSessionId( $id ) ) {
255 throw new \InvalidArgumentException(
'Invalid session ID' );
265 if ( isset( $this->allSessionBackends[$id] ) ) {
266 return $this->getSessionFromInfo( $info, $request );
270 $key = $this->store->makeKey(
'MWSession', $id );
271 if ( is_array( $this->store->get( $key ) ) ) {
273 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
274 $session = $this->getSessionFromInfo( $info, $request );
278 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' );
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 ? $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 MWException( __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.
WebRequest clone which takes values from a provided array.
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.