34 use Psr\Log\LoggerInterface;
38 use Wikimedia\ObjectFactory;
101 if ( self::$instance ===
null ) {
102 self::$instance =
new self();
122 !self::$globalSession
123 || self::$globalSessionRequest !== $request
124 || $id !==
'' && self::$globalSession->getId() !== $id
126 self::$globalSessionRequest = $request;
136 self::$globalSession = $request->getSession();
141 self::$globalSession =
self::singleton()->getSessionById( $id,
true, $request )
142 ?: $request->getSession();
155 if ( isset( $options[
'config'] ) ) {
156 $this->config = $options[
'config'];
157 if ( !$this->config instanceof
Config ) {
158 throw new \InvalidArgumentException(
159 '$options[\'config\'] must be an instance of Config'
166 if ( isset( $options[
'logger'] ) ) {
167 if ( !$options[
'logger'] instanceof LoggerInterface ) {
168 throw new \InvalidArgumentException(
169 '$options[\'logger\'] must be an instance of LoggerInterface'
177 if ( isset( $options[
'hookContainer'] ) ) {
183 if ( isset( $options[
'store'] ) ) {
184 if ( !$options[
'store'] instanceof
BagOStuff ) {
185 throw new \InvalidArgumentException(
186 '$options[\'store\'] must be an instance of BagOStuff'
189 $store = $options[
'store'];
194 $this->logger->debug(
'SessionManager using store ' . get_class(
$store ) );
197 register_shutdown_function( [ $this,
'shutdown' ] );
225 if ( !self::validateSessionId( $id ) ) {
226 throw new \InvalidArgumentException(
'Invalid session ID' );
236 if ( isset( $this->allSessionBackends[$id] ) ) {
241 $key = $this->store->makeKey(
'MWSession', $id );
242 if ( is_array( $this->store->get( $key ) ) ) {
249 if ( $create && $session ===
null ) {
253 }
catch ( \Exception $ex ) {
254 $this->logger->error(
'Failed to create empty session: {exception}',
256 'method' => __METHOD__,
277 if ( $id !==
null ) {
278 if ( !self::validateSessionId( $id ) ) {
279 throw new \InvalidArgumentException(
'Invalid session ID' );
282 $key = $this->store->makeKey(
'MWSession', $id );
283 if ( is_array( $this->store->get( $key ) ) ) {
284 throw new \InvalidArgumentException(
'Session ID already exists' );
293 $info = $provider->newSessionInfo( $id );
297 if ( $info->getProvider() !== $provider ) {
298 throw new \UnexpectedValueException(
299 "$provider returned an empty session info for a different provider: $info"
302 if ( $id !==
null && $info->getId() !== $id ) {
303 throw new \UnexpectedValueException(
304 "$provider returned empty session info with a wrong id: " .
305 $info->getId() .
' != ' . $id
308 if ( !$info->isIdSafe() ) {
309 throw new \UnexpectedValueException(
310 "$provider returned empty session info with id flagged unsafe"
314 if ( $compare > 0 ) {
317 if ( $compare === 0 ) {
325 if ( count( $infos ) > 1 ) {
326 throw new \UnexpectedValueException(
327 'Multiple empty sessions tied for top priority: ' . implode(
', ', $infos )
329 } elseif ( count( $infos ) < 1 ) {
330 throw new \UnexpectedValueException(
'No provider could provide an empty session!' );
341 $provider->invalidateSessionsForUser( $user );
347 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
351 if ( $this->varyHeaders ===
null ) {
354 foreach ( $provider->getVaryHeaders() as
$header => $options ) {
355 # Note that the $options value returned has been deprecated
360 $this->varyHeaders = $headers;
367 if ( defined(
'MW_NO_SESSION' ) &&
MW_NO_SESSION !==
'warn' ) {
371 if ( $this->varyCookies ===
null ) {
374 $cookies = array_merge( $cookies, $provider->getVaryCookies() );
376 $this->varyCookies = array_values( array_unique( $cookies ) );
387 return is_string( $id ) && preg_match(
'/^[a-zA-Z0-9_-]{32,}$/', $id );
404 $this->preventUsers[$username] =
true;
408 $provider->preventSessionsForUser( $username );
419 return !empty( $this->preventUsers[$username] );
427 if ( $this->sessionProviders ===
null ) {
428 $this->sessionProviders = [];
429 foreach ( $this->config->get(
'SessionProviders' ) as $spec ) {
431 $provider = ObjectFactory::getObjectFromSpec( $spec );
432 $provider->setLogger( $this->logger );
433 $provider->setConfig( $this->config );
434 $provider->setManager( $this );
435 $provider->setHookContainer( $this->hookContainer );
436 if ( isset( $this->sessionProviders[(
string)$provider] ) ) {
438 throw new \UnexpectedValueException(
"Duplicate provider name \"$provider\"" );
440 $this->sessionProviders[(string)$provider] = $provider;
458 return $providers[$name] ??
null;
466 if ( $this->allSessionBackends ) {
467 $this->logger->debug(
'Saving all sessions on shutdown' );
468 if ( session_id() !==
'' ) {
470 session_write_close();
473 foreach ( $this->allSessionBackends as $backend ) {
474 $backend->shutdown();
488 $info = $provider->provideSessionInfo( $request );
492 if ( $info->getProvider() !== $provider ) {
493 throw new \UnexpectedValueException(
494 "$provider returned session info for a different provider: $info"
503 usort( $infos, [ SessionInfo::class,
'compare' ] );
506 $info = array_pop( $infos );
511 $info = array_pop( $infos );
523 $info->getProvider()->unpersistSession( $request );
529 $info->getProvider()->unpersistSession( $request );
533 if ( count( $retInfos ) > 1 ) {
536 'Multiple sessions for this request tied for top priority: ' . implode(
', ', $retInfos )
540 return $retInfos ? $retInfos[0] :
null;
551 $key = $this->store->makeKey(
'MWSession', $info->
getId() );
552 $blob = $this->store->get( $key );
558 $failHandler =
function () use ( $key, &$info, $request ) {
559 $this->store->delete( $key );
563 $failHandler =
static function () {
570 if (
$blob !==
false ) {
572 if ( !is_array(
$blob ) ) {
573 $this->logger->warning(
'Session "{session}": Bad data', [
576 $this->store->delete( $key );
577 return $failHandler();
581 if ( !isset(
$blob[
'data'] ) || !is_array(
$blob[
'data'] ) ||
582 !isset(
$blob[
'metadata'] ) || !is_array(
$blob[
'metadata'] )
584 $this->logger->warning(
'Session "{session}": Bad data structure', [
587 $this->store->delete( $key );
588 return $failHandler();
591 $data =
$blob[
'data'];
592 $metadata =
$blob[
'metadata'];
596 if ( !array_key_exists(
'userId', $metadata ) ||
597 !array_key_exists(
'userName', $metadata ) ||
598 !array_key_exists(
'userToken', $metadata ) ||
599 !array_key_exists(
'provider', $metadata )
601 $this->logger->warning(
'Session "{session}": Bad metadata', [
604 $this->store->delete( $key );
605 return $failHandler();
610 if ( $provider ===
null ) {
611 $newParams[
'provider'] = $provider = $this->
getProvider( $metadata[
'provider'] );
613 $this->logger->warning(
614 'Session "{session}": Unknown provider ' . $metadata[
'provider'],
619 $this->store->delete( $key );
620 return $failHandler();
622 } elseif ( $metadata[
'provider'] !== (
string)$provider ) {
623 $this->logger->warning(
'Session "{session}": Wrong provider ' .
624 $metadata[
'provider'] .
' !== ' . $provider,
628 return $failHandler();
633 if ( isset( $metadata[
'providerMetadata'] ) ) {
634 if ( $providerMetadata ===
null ) {
635 $newParams[
'metadata'] = $metadata[
'providerMetadata'];
638 $newProviderMetadata = $provider->mergeMetadata(
639 $metadata[
'providerMetadata'], $providerMetadata
641 if ( $newProviderMetadata !== $providerMetadata ) {
642 $newParams[
'metadata'] = $newProviderMetadata;
645 $this->logger->warning(
646 'Session "{session}": Metadata merge failed: {exception}',
652 return $failHandler();
662 if ( $metadata[
'userId'] ) {
664 } elseif ( $metadata[
'userName'] !==
null ) {
669 }
catch ( \InvalidArgumentException $ex ) {
670 $this->logger->error(
'Session "{session}": {exception}', [
674 return $failHandler();
676 $newParams[
'userInfo'] = $userInfo;
680 if ( $metadata[
'userId'] ) {
681 if ( $metadata[
'userId'] !== $userInfo->getId() ) {
682 $this->logger->warning(
683 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
686 'uid_a' => $metadata[
'userId'],
687 'uid_b' => $userInfo->getId(),
689 return $failHandler();
693 if ( $metadata[
'userName'] !==
null &&
694 $userInfo->getName() !== $metadata[
'userName']
696 $this->logger->warning(
697 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
700 'uname_a' => $metadata[
'userName'],
701 'uname_b' => $userInfo->getName(),
703 return $failHandler();
706 } elseif ( $metadata[
'userName'] !==
null ) {
707 if ( $metadata[
'userName'] !== $userInfo->getName() ) {
708 $this->logger->warning(
709 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
712 'uname_a' => $metadata[
'userName'],
713 'uname_b' => $userInfo->getName(),
715 return $failHandler();
717 } elseif ( !$userInfo->isAnon() ) {
720 $this->logger->warning(
721 'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
725 return $failHandler();
730 if ( $metadata[
'userToken'] !==
null &&
731 $userInfo->getToken() !== $metadata[
'userToken']
733 $this->logger->warning(
'Session "{session}": User token mismatch', [
736 return $failHandler();
738 if ( !$userInfo->isVerified() ) {
739 $newParams[
'userInfo'] = $userInfo->verified();
742 if ( !empty( $metadata[
'remember'] ) && !$info->
wasRemembered() ) {
743 $newParams[
'remembered'] =
true;
745 if ( !empty( $metadata[
'forceHTTPS'] ) && !$info->
forceHTTPS() ) {
746 $newParams[
'forceHTTPS'] =
true;
748 if ( !empty( $metadata[
'persisted'] ) && !$info->
wasPersisted() ) {
749 $newParams[
'persisted'] =
true;
753 $newParams[
'idIsSafe'] =
true;
758 $this->logger->warning(
759 'Session "{session}": Null provider and no metadata',
763 return $failHandler();
772 'Session "{session}": No user provided and provider cannot set user',
776 return $failHandler();
781 'Session "{session}": Unverified user provided and no metadata to auth it',
785 return $failHandler();
794 $newParams[
'idIsSafe'] =
true;
800 $newParams[
'copyFrom'] = $info;
806 if ( !$info->
getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
807 return $failHandler();
811 'metadata' => $providerMetadata,
819 $reason =
'Hook aborted';
820 if ( !$this->hookRunner->onSessionCheckInfo(
821 $reason, $info, $request, $metadata, $data )
823 $this->logger->warning(
'Session "{session}": ' . $reason, [
826 return $failHandler();
842 if ( defined(
'MW_NO_SESSION' ) ) {
845 $this->logger->error(
'Sessions are supposed to be disabled for this entry point', [
846 'exception' =>
new \BadMethodCallException(
'Sessions are disabled for this entry point' ),
849 throw new \BadMethodCallException(
'Sessions are disabled for this entry point' );
854 $id = $info->
getId();
856 if ( !isset( $this->allSessionBackends[$id] ) ) {
857 if ( !isset( $this->allSessionIds[$id] ) ) {
858 $this->allSessionIds[$id] =
new SessionId( $id );
861 $this->allSessionIds[$id],
865 $this->hookContainer,
866 $this->config->get(
'ObjectCacheSessionExpiry' )
868 $this->allSessionBackends[$id] = $backend;
869 $delay = $backend->delaySave();
871 $backend = $this->allSessionBackends[$id];
872 $delay = $backend->delaySave();
877 $backend->setRememberUser(
true );
882 $session = $backend->getSession( $request );
888 \Wikimedia\ScopedCallback::consume( $delay );
898 $id = $backend->
getId();
899 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
900 $this->allSessionBackends[$id] !== $backend ||
903 throw new \InvalidArgumentException(
'Backend was not registered with this SessionManager' );
906 unset( $this->allSessionBackends[$id] );
917 $oldId = (string)$sessionId;
918 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
919 $this->allSessionBackends[$oldId] !== $backend ||
920 $this->allSessionIds[$oldId] !== $sessionId
922 throw new \InvalidArgumentException(
'Backend was not registered with this SessionManager' );
927 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
928 $sessionId->setId( $newId );
929 $this->allSessionBackends[$newId] = $backend;
930 $this->allSessionIds[$newId] = $sessionId;
940 $key = $this->store->makeKey(
'MWSession', $id );
941 }
while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
951 $handler->
setManager( $this, $this->store, $this->logger );
960 if ( !defined(
'MW_PHPUNIT_TEST' ) && !defined(
'MW_PARSER_TEST' ) ) {
962 throw new MWException( __METHOD__ .
' may only be called from unit tests!' );
966 self::$globalSession =
null;
967 self::$globalSessionRequest =
null;
972 'id' => $info->
getId(),
975 'clientip' => $request->
getIP(),
976 'userAgent' => $request->
getHeader(
'user-agent' ),
980 $logData[
'user'] = $info->
getUserInfo()->getName();
982 $logData[
'userVerified'] = $info->
getUserInfo()->isVerified();
984 $this->logger->info(
'Failed to load session, unpersisting', $logData );
1000 $suspiciousIpExpiry = $this->config->get(
'SuspiciousIpExpiry' );
1002 if ( $suspiciousIpExpiry ===
false
1004 || !$session->isPersistent() || $session->getUser()->isAnon()
1011 $ip = $session->getRequest()->getIP();
1015 if ( $ip ===
'127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1018 $mwuser = $session->getRequest()->getCookie(
'mwuser-sessionId' );
1019 $now = \MWTimestamp::now( TS_UNIX );
1025 $data = $session->get(
'SessionManager-logPotentialSessionLeakage', [] )
1026 + [
'ip' =>
null,
'mwuser' =>
null,
'timestamp' => 0 ];
1030 ( $now - $data[
'timestamp'] > $suspiciousIpExpiry )
1032 $data[
'ip'] = $data[
'timestamp'] =
null;
1035 if ( $data[
'ip'] !== $ip || $data[
'mwuser'] !== $mwuser ) {
1036 $session->set(
'SessionManager-logPotentialSessionLeakage',
1037 [
'ip' => $ip,
'mwuser' => $mwuser,
'timestamp' => $now ] );
1040 $ipChanged = ( $data[
'ip'] && $data[
'ip'] !== $ip );
1041 $mwuserChanged = ( $data[
'mwuser'] && $data[
'mwuser'] !== $mwuser );
1042 $logLevel = $message =
null;
1050 $logLevel = LogLevel::INFO;
1051 $message =
'IP change within the same session';
1053 'oldIp' => $data[
'ip'],
1054 'oldIpRecorded' => $data[
'timestamp'],
1057 if ( $mwuserChanged ) {
1058 $logLevel = LogLevel::NOTICE;
1059 $message =
'mwuser change within the same session';
1061 'oldMwuser' => $data[
'mwuser'],
1062 'newMwuser' => $mwuser,
1065 if ( $ipChanged && $mwuserChanged ) {
1066 $logLevel = LogLevel::WARNING;
1067 $message =
'IP and mwuser change within the same session';
1071 'session' => $session->getId(),
1072 'user' => $session->getUser()->getName(),
1074 'userAgent' => $session->getRequest()->getHeader(
'user-agent' ),
1077 $logger->log( $logLevel, $message, $logData );