MediaWiki  master
SessionManager.php
Go to the documentation of this file.
1 <?php
24 namespace MediaWiki\Session;
25 
26 use BagOStuff;
27 use CachedBagOStuff;
28 use Config;
29 use FauxRequest;
33 use MWException;
34 use Psr\Log\LoggerInterface;
35 use Psr\Log\LogLevel;
36 use User;
37 use WebRequest;
38 use Wikimedia\ObjectFactory;
39 
53 final class SessionManager implements SessionManagerInterface {
55  private static $instance = null;
56 
58  private static $globalSession = null;
59 
61  private static $globalSessionRequest = null;
62 
64  private $logger;
65 
67  private $hookContainer;
68 
70  private $hookRunner;
71 
73  private $config;
74 
76  private $store;
77 
79  private $sessionProviders = null;
80 
82  private $varyCookies = null;
83 
85  private $varyHeaders = null;
86 
88  private $allSessionBackends = [];
89 
91  private $allSessionIds = [];
92 
94  private $preventUsers = [];
95 
100  public static function singleton() {
101  if ( self::$instance === null ) {
102  self::$instance = new self();
103  }
104  return self::$instance;
105  }
106 
113  public static function getGlobalSession() : Session {
114  if ( !PHPSessionHandler::isEnabled() ) {
115  $id = '';
116  } else {
117  $id = session_id();
118  }
119 
120  $request = \RequestContext::getMain()->getRequest();
121  if (
122  !self::$globalSession // No global session is set up yet
123  || self::$globalSessionRequest !== $request // The global WebRequest changed
124  || $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
125  ) {
126  self::$globalSessionRequest = $request;
127  if ( $id === '' ) {
128  // session_id() wasn't used, so fetch the Session from the WebRequest.
129  // We use $request->getSession() instead of $singleton->getSessionForRequest()
130  // because doing the latter would require a public
131  // "$request->getSessionId()" method that would confuse end
132  // users by returning SessionId|null where they'd expect it to
133  // be short for $request->getSession()->getId(), and would
134  // wind up being a duplicate of the code in
135  // $request->getSession() anyway.
136  self::$globalSession = $request->getSession();
137  } else {
138  // Someone used session_id(), so we need to follow suit.
139  // Note this overwrites whatever session might already be
140  // associated with $request with the one for $id.
141  self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
142  ?: $request->getSession();
143  }
144  }
145  return self::$globalSession;
146  }
147 
154  public function __construct( $options = [] ) {
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'
160  );
161  }
162  } else {
163  $this->config = MediaWikiServices::getInstance()->getMainConfig();
164  }
165 
166  if ( isset( $options['logger'] ) ) {
167  if ( !$options['logger'] instanceof LoggerInterface ) {
168  throw new \InvalidArgumentException(
169  '$options[\'logger\'] must be an instance of LoggerInterface'
170  );
171  }
172  $this->setLogger( $options['logger'] );
173  } else {
174  $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
175  }
176 
177  if ( isset( $options['hookContainer'] ) ) {
178  $this->setHookContainer( $options['hookContainer'] );
179  } else {
180  $this->setHookContainer( MediaWikiServices::getInstance()->getHookContainer() );
181  }
182 
183  if ( isset( $options['store'] ) ) {
184  if ( !$options['store'] instanceof BagOStuff ) {
185  throw new \InvalidArgumentException(
186  '$options[\'store\'] must be an instance of BagOStuff'
187  );
188  }
189  $store = $options['store'];
190  } else {
191  $store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
192  }
193 
194  $this->logger->debug( 'SessionManager using store ' . get_class( $store ) );
195  $this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
196 
197  register_shutdown_function( [ $this, 'shutdown' ] );
198  }
199 
200  public function setLogger( LoggerInterface $logger ) {
201  $this->logger = $logger;
202  }
203 
209  $this->hookContainer = $hookContainer;
210  $this->hookRunner = new HookRunner( $hookContainer );
211  }
212 
213  public function getSessionForRequest( WebRequest $request ) {
214  $info = $this->getSessionInfoForRequest( $request );
215 
216  if ( !$info ) {
217  $session = $this->getEmptySession( $request );
218  } else {
219  $session = $this->getSessionFromInfo( $info, $request );
220  }
221  return $session;
222  }
223 
224  public function getSessionById( $id, $create = false, WebRequest $request = null ) {
225  if ( !self::validateSessionId( $id ) ) {
226  throw new \InvalidArgumentException( 'Invalid session ID' );
227  }
228  if ( !$request ) {
229  $request = new FauxRequest;
230  }
231 
232  $session = null;
233  $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'idIsSafe' => true ] );
234 
235  // If we already have the backend loaded, use it directly
236  if ( isset( $this->allSessionBackends[$id] ) ) {
237  return $this->getSessionFromInfo( $info, $request );
238  }
239 
240  // Test if the session is in storage, and if so try to load it.
241  $key = $this->store->makeKey( 'MWSession', $id );
242  if ( is_array( $this->store->get( $key ) ) ) {
243  $create = false; // If loading fails, don't bother creating because it probably will fail too.
244  if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
245  $session = $this->getSessionFromInfo( $info, $request );
246  }
247  }
248 
249  if ( $create && $session === null ) {
250  $ex = null;
251  try {
252  $session = $this->getEmptySessionInternal( $request, $id );
253  } catch ( \Exception $ex ) {
254  $this->logger->error( 'Failed to create empty session: {exception}',
255  [
256  'method' => __METHOD__,
257  'exception' => $ex,
258  ] );
259  $session = null;
260  }
261  }
262 
263  return $session;
264  }
265 
266  public function getEmptySession( WebRequest $request = null ) {
267  return $this->getEmptySessionInternal( $request );
268  }
269 
276  private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
277  if ( $id !== null ) {
278  if ( !self::validateSessionId( $id ) ) {
279  throw new \InvalidArgumentException( 'Invalid session ID' );
280  }
281 
282  $key = $this->store->makeKey( 'MWSession', $id );
283  if ( is_array( $this->store->get( $key ) ) ) {
284  throw new \InvalidArgumentException( 'Session ID already exists' );
285  }
286  }
287  if ( !$request ) {
288  $request = new FauxRequest;
289  }
290 
291  $infos = [];
292  foreach ( $this->getProviders() as $provider ) {
293  $info = $provider->newSessionInfo( $id );
294  if ( !$info ) {
295  continue;
296  }
297  if ( $info->getProvider() !== $provider ) {
298  throw new \UnexpectedValueException(
299  "$provider returned an empty session info for a different provider: $info"
300  );
301  }
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
306  );
307  }
308  if ( !$info->isIdSafe() ) {
309  throw new \UnexpectedValueException(
310  "$provider returned empty session info with id flagged unsafe"
311  );
312  }
313  $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
314  if ( $compare > 0 ) {
315  continue;
316  }
317  if ( $compare === 0 ) {
318  $infos[] = $info;
319  } else {
320  $infos = [ $info ];
321  }
322  }
323 
324  // Make sure there's exactly one
325  if ( count( $infos ) > 1 ) {
326  throw new \UnexpectedValueException(
327  'Multiple empty sessions tied for top priority: ' . implode( ', ', $infos )
328  );
329  } elseif ( count( $infos ) < 1 ) {
330  throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
331  }
332 
333  return $this->getSessionFromInfo( $infos[0], $request );
334  }
335 
336  public function invalidateSessionsForUser( User $user ) {
337  $user->setToken();
338  $user->saveSettings();
339 
340  foreach ( $this->getProviders() as $provider ) {
341  $provider->invalidateSessionsForUser( $user );
342  }
343  }
344 
345  public function getVaryHeaders() {
346  // @codeCoverageIgnoreStart
347  if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
348  return [];
349  }
350  // @codeCoverageIgnoreEnd
351  if ( $this->varyHeaders === null ) {
352  $headers = [];
353  foreach ( $this->getProviders() as $provider ) {
354  foreach ( $provider->getVaryHeaders() as $header => $options ) {
355  # Note that the $options value returned has been deprecated
356  # and is ignored.
357  $headers[$header] = null;
358  }
359  }
360  $this->varyHeaders = $headers;
361  }
362  return $this->varyHeaders;
363  }
364 
365  public function getVaryCookies() {
366  // @codeCoverageIgnoreStart
367  if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
368  return [];
369  }
370  // @codeCoverageIgnoreEnd
371  if ( $this->varyCookies === null ) {
372  $cookies = [];
373  foreach ( $this->getProviders() as $provider ) {
374  $cookies = array_merge( $cookies, $provider->getVaryCookies() );
375  }
376  $this->varyCookies = array_values( array_unique( $cookies ) );
377  }
378  return $this->varyCookies;
379  }
380 
386  public static function validateSessionId( $id ) {
387  return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
388  }
389 
390  /***************************************************************************/
391  // region Internal methods
403  public function preventSessionsForUser( $username ) {
404  $this->preventUsers[$username] = true;
405 
406  // Instruct the session providers to kill any other sessions too.
407  foreach ( $this->getProviders() as $provider ) {
408  $provider->preventSessionsForUser( $username );
409  }
410  }
411 
418  public function isUserSessionPrevented( $username ) {
419  return !empty( $this->preventUsers[$username] );
420  }
421 
426  protected function getProviders() {
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] ) ) {
437  // @phan-suppress-next-line PhanTypeSuspiciousStringExpression
438  throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
439  }
440  $this->sessionProviders[(string)$provider] = $provider;
441  }
442  }
444  }
445 
456  public function getProvider( $name ) {
457  $providers = $this->getProviders();
458  return $providers[$name] ?? null;
459  }
460 
465  public function shutdown() {
466  if ( $this->allSessionBackends ) {
467  $this->logger->debug( 'Saving all sessions on shutdown' );
468  if ( session_id() !== '' ) {
469  // @codeCoverageIgnoreStart
470  session_write_close();
471  }
472  // @codeCoverageIgnoreEnd
473  foreach ( $this->allSessionBackends as $backend ) {
474  $backend->shutdown();
475  }
476  }
477  }
478 
484  private function getSessionInfoForRequest( WebRequest $request ) {
485  // Call all providers to fetch "the" session
486  $infos = [];
487  foreach ( $this->getProviders() as $provider ) {
488  $info = $provider->provideSessionInfo( $request );
489  if ( !$info ) {
490  continue;
491  }
492  if ( $info->getProvider() !== $provider ) {
493  throw new \UnexpectedValueException(
494  "$provider returned session info for a different provider: $info"
495  );
496  }
497  $infos[] = $info;
498  }
499 
500  // Sort the SessionInfos. Then find the first one that can be
501  // successfully loaded, and then all the ones after it with the same
502  // priority.
503  usort( $infos, [ SessionInfo::class, 'compare' ] );
504  $retInfos = [];
505  while ( $infos ) {
506  $info = array_pop( $infos );
507  if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
508  $retInfos[] = $info;
509  while ( $infos ) {
511  $info = array_pop( $infos );
512  if ( SessionInfo::compare( $retInfos[0], $info ) ) {
513  // We hit a lower priority, stop checking.
514  break;
515  }
516  if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
517  // This is going to error out below, but we want to
518  // provide a complete list.
519  $retInfos[] = $info;
520  } else {
521  // Session load failed, so unpersist it from this request
522  $this->logUnpersist( $info, $request );
523  $info->getProvider()->unpersistSession( $request );
524  }
525  }
526  } else {
527  // Session load failed, so unpersist it from this request
528  $this->logUnpersist( $info, $request );
529  $info->getProvider()->unpersistSession( $request );
530  }
531  }
532 
533  if ( count( $retInfos ) > 1 ) {
534  throw new SessionOverflowException(
535  $retInfos,
536  'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
537  );
538  }
539 
540  return $retInfos ? $retInfos[0] : null;
541  }
542 
550  private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
551  $key = $this->store->makeKey( 'MWSession', $info->getId() );
552  $blob = $this->store->get( $key );
553 
554  // If we got data from the store and the SessionInfo says to force use,
555  // "fail" means to delete the data from the store and retry. Otherwise,
556  // "fail" is just return false.
557  if ( $info->forceUse() && $blob !== false ) {
558  $failHandler = function () use ( $key, &$info, $request ) {
559  $this->store->delete( $key );
560  return $this->loadSessionInfoFromStore( $info, $request );
561  };
562  } else {
563  $failHandler = static function () {
564  return false;
565  };
566  }
567 
568  $newParams = [];
569 
570  if ( $blob !== false ) {
571  // Sanity check: blob must be an array, if it's saved at all
572  if ( !is_array( $blob ) ) {
573  $this->logger->warning( 'Session "{session}": Bad data', [
574  'session' => $info->__toString(),
575  ] );
576  $this->store->delete( $key );
577  return $failHandler();
578  }
579 
580  // Sanity check: blob has data and metadata arrays
581  if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
582  !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
583  ) {
584  $this->logger->warning( 'Session "{session}": Bad data structure', [
585  'session' => $info->__toString(),
586  ] );
587  $this->store->delete( $key );
588  return $failHandler();
589  }
590 
591  $data = $blob['data'];
592  $metadata = $blob['metadata'];
593 
594  // Sanity check: metadata must be an array and must contain certain
595  // keys, if it's saved at all
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 )
600  ) {
601  $this->logger->warning( 'Session "{session}": Bad metadata', [
602  'session' => $info->__toString(),
603  ] );
604  $this->store->delete( $key );
605  return $failHandler();
606  }
607 
608  // First, load the provider from metadata, or validate it against the metadata.
609  $provider = $info->getProvider();
610  if ( $provider === null ) {
611  $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
612  if ( !$provider ) {
613  $this->logger->warning(
614  'Session "{session}": Unknown provider ' . $metadata['provider'],
615  [
616  'session' => $info->__toString(),
617  ]
618  );
619  $this->store->delete( $key );
620  return $failHandler();
621  }
622  } elseif ( $metadata['provider'] !== (string)$provider ) {
623  $this->logger->warning( 'Session "{session}": Wrong provider ' .
624  $metadata['provider'] . ' !== ' . $provider,
625  [
626  'session' => $info->__toString(),
627  ] );
628  return $failHandler();
629  }
630 
631  // Load provider metadata from metadata, or validate it against the metadata
632  $providerMetadata = $info->getProviderMetadata();
633  if ( isset( $metadata['providerMetadata'] ) ) {
634  if ( $providerMetadata === null ) {
635  $newParams['metadata'] = $metadata['providerMetadata'];
636  } else {
637  try {
638  $newProviderMetadata = $provider->mergeMetadata(
639  $metadata['providerMetadata'], $providerMetadata
640  );
641  if ( $newProviderMetadata !== $providerMetadata ) {
642  $newParams['metadata'] = $newProviderMetadata;
643  }
644  } catch ( MetadataMergeException $ex ) {
645  $this->logger->warning(
646  'Session "{session}": Metadata merge failed: {exception}',
647  [
648  'session' => $info->__toString(),
649  'exception' => $ex,
650  ] + $ex->getContext()
651  );
652  return $failHandler();
653  }
654  }
655  }
656 
657  // Next, load the user from metadata, or validate it against the metadata.
658  $userInfo = $info->getUserInfo();
659  if ( !$userInfo ) {
660  // For loading, id is preferred to name.
661  try {
662  if ( $metadata['userId'] ) {
663  $userInfo = UserInfo::newFromId( $metadata['userId'] );
664  } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
665  $userInfo = UserInfo::newFromName( $metadata['userName'] );
666  } else {
667  $userInfo = UserInfo::newAnonymous();
668  }
669  } catch ( \InvalidArgumentException $ex ) {
670  $this->logger->error( 'Session "{session}": {exception}', [
671  'session' => $info->__toString(),
672  'exception' => $ex,
673  ] );
674  return $failHandler();
675  }
676  $newParams['userInfo'] = $userInfo;
677  } else {
678  // User validation passes if user ID matches, or if there
679  // is no saved ID and the names match.
680  if ( $metadata['userId'] ) {
681  if ( $metadata['userId'] !== $userInfo->getId() ) {
682  $this->logger->warning(
683  'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
684  [
685  'session' => $info->__toString(),
686  'uid_a' => $metadata['userId'],
687  'uid_b' => $userInfo->getId(),
688  ] );
689  return $failHandler();
690  }
691 
692  // If the user was renamed, probably best to fail here.
693  if ( $metadata['userName'] !== null &&
694  $userInfo->getName() !== $metadata['userName']
695  ) {
696  $this->logger->warning(
697  'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
698  [
699  'session' => $info->__toString(),
700  'uname_a' => $metadata['userName'],
701  'uname_b' => $userInfo->getName(),
702  ] );
703  return $failHandler();
704  }
705 
706  } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
707  if ( $metadata['userName'] !== $userInfo->getName() ) {
708  $this->logger->warning(
709  'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
710  [
711  'session' => $info->__toString(),
712  'uname_a' => $metadata['userName'],
713  'uname_b' => $userInfo->getName(),
714  ] );
715  return $failHandler();
716  }
717  } elseif ( !$userInfo->isAnon() ) {
718  // Metadata specifies an anonymous user, but the passed-in
719  // user isn't anonymous.
720  $this->logger->warning(
721  'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
722  [
723  'session' => $info->__toString(),
724  ] );
725  return $failHandler();
726  }
727  }
728 
729  // And if we have a token in the metadata, it must match the loaded/provided user.
730  if ( $metadata['userToken'] !== null &&
731  $userInfo->getToken() !== $metadata['userToken']
732  ) {
733  $this->logger->warning( 'Session "{session}": User token mismatch', [
734  'session' => $info->__toString(),
735  ] );
736  return $failHandler();
737  }
738  if ( !$userInfo->isVerified() ) {
739  $newParams['userInfo'] = $userInfo->verified();
740  }
741 
742  if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
743  $newParams['remembered'] = true;
744  }
745  if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
746  $newParams['forceHTTPS'] = true;
747  }
748  if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
749  $newParams['persisted'] = true;
750  }
751 
752  if ( !$info->isIdSafe() ) {
753  $newParams['idIsSafe'] = true;
754  }
755  } else {
756  // No metadata, so we can't load the provider if one wasn't given.
757  if ( $info->getProvider() === null ) {
758  $this->logger->warning(
759  'Session "{session}": Null provider and no metadata',
760  [
761  'session' => $info->__toString(),
762  ] );
763  return $failHandler();
764  }
765 
766  // If no user was provided and no metadata, it must be anon.
767  if ( !$info->getUserInfo() ) {
768  if ( $info->getProvider()->canChangeUser() ) {
769  $newParams['userInfo'] = UserInfo::newAnonymous();
770  } else {
771  $this->logger->info(
772  'Session "{session}": No user provided and provider cannot set user',
773  [
774  'session' => $info->__toString(),
775  ] );
776  return $failHandler();
777  }
778  } elseif ( !$info->getUserInfo()->isVerified() ) {
779  // probably just a session timeout
780  $this->logger->info(
781  'Session "{session}": Unverified user provided and no metadata to auth it',
782  [
783  'session' => $info->__toString(),
784  ] );
785  return $failHandler();
786  }
787 
788  $data = false;
789  $metadata = false;
790 
791  if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
792  // The ID doesn't come from the user, so it should be safe
793  // (and if not, nothing we can do about it anyway)
794  $newParams['idIsSafe'] = true;
795  }
796  }
797 
798  // Construct the replacement SessionInfo, if necessary
799  if ( $newParams ) {
800  $newParams['copyFrom'] = $info;
801  $info = new SessionInfo( $info->getPriority(), $newParams );
802  }
803 
804  // Allow the provider to check the loaded SessionInfo
805  $providerMetadata = $info->getProviderMetadata();
806  if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
807  return $failHandler();
808  }
809  if ( $providerMetadata !== $info->getProviderMetadata() ) {
810  $info = new SessionInfo( $info->getPriority(), [
811  'metadata' => $providerMetadata,
812  'copyFrom' => $info,
813  ] );
814  }
815 
816  // Give hooks a chance to abort. Combined with the SessionMetadata
817  // hook, this can allow for tying a session to an IP address or the
818  // like.
819  $reason = 'Hook aborted';
820  if ( !$this->hookRunner->onSessionCheckInfo(
821  $reason, $info, $request, $metadata, $data )
822  ) {
823  $this->logger->warning( 'Session "{session}": ' . $reason, [
824  'session' => $info->__toString(),
825  ] );
826  return $failHandler();
827  }
828 
829  return true;
830  }
831 
840  public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
841  // @codeCoverageIgnoreStart
842  if ( defined( 'MW_NO_SESSION' ) ) {
843  if ( MW_NO_SESSION === 'warn' ) {
844  // Undocumented safety case for converting existing entry points
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' ),
847  ] );
848  } else {
849  throw new \BadMethodCallException( 'Sessions are disabled for this entry point' );
850  }
851  }
852  // @codeCoverageIgnoreEnd
853 
854  $id = $info->getId();
855 
856  if ( !isset( $this->allSessionBackends[$id] ) ) {
857  if ( !isset( $this->allSessionIds[$id] ) ) {
858  $this->allSessionIds[$id] = new SessionId( $id );
859  }
860  $backend = new SessionBackend(
861  $this->allSessionIds[$id],
862  $info,
863  $this->store,
864  $this->logger,
865  $this->hookContainer,
866  $this->config->get( 'ObjectCacheSessionExpiry' )
867  );
868  $this->allSessionBackends[$id] = $backend;
869  $delay = $backend->delaySave();
870  } else {
871  $backend = $this->allSessionBackends[$id];
872  $delay = $backend->delaySave();
873  if ( $info->wasPersisted() ) {
874  $backend->persist();
875  }
876  if ( $info->wasRemembered() ) {
877  $backend->setRememberUser( true );
878  }
879  }
880 
881  $request->setSessionId( $backend->getSessionId() );
882  $session = $backend->getSession( $request );
883 
884  if ( !$info->isIdSafe() ) {
885  $session->resetId();
886  }
887 
888  \Wikimedia\ScopedCallback::consume( $delay );
889  return $session;
890  }
891 
897  public function deregisterSessionBackend( SessionBackend $backend ) {
898  $id = $backend->getId();
899  if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
900  $this->allSessionBackends[$id] !== $backend ||
901  $this->allSessionIds[$id] !== $backend->getSessionId()
902  ) {
903  throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
904  }
905 
906  unset( $this->allSessionBackends[$id] );
907  // Explicitly do not unset $this->allSessionIds[$id]
908  }
909 
915  public function changeBackendId( SessionBackend $backend ) {
916  $sessionId = $backend->getSessionId();
917  $oldId = (string)$sessionId;
918  if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
919  $this->allSessionBackends[$oldId] !== $backend ||
920  $this->allSessionIds[$oldId] !== $sessionId
921  ) {
922  throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
923  }
924 
925  $newId = $this->generateSessionId();
926 
927  unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
928  $sessionId->setId( $newId );
929  $this->allSessionBackends[$newId] = $backend;
930  $this->allSessionIds[$newId] = $sessionId;
931  }
932 
937  public function generateSessionId() {
938  do {
939  $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
940  $key = $this->store->makeKey( 'MWSession', $id );
941  } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
942  return $id;
943  }
944 
950  public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
951  $handler->setManager( $this, $this->store, $this->logger );
952  }
953 
959  public static function resetCache() {
960  if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
961  // @codeCoverageIgnoreStart
962  throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
963  // @codeCoverageIgnoreEnd
964  }
965 
966  self::$globalSession = null;
967  self::$globalSessionRequest = null;
968  }
969 
970  private function logUnpersist( SessionInfo $info, WebRequest $request ) {
971  $logData = [
972  'id' => $info->getId(),
973  'provider' => get_class( $info->getProvider() ),
974  'user' => '<anon>',
975  'clientip' => $request->getIP(),
976  'userAgent' => $request->getHeader( 'user-agent' ),
977  ];
978  if ( $info->getUserInfo() ) {
979  if ( !$info->getUserInfo()->isAnon() ) {
980  $logData['user'] = $info->getUserInfo()->getName();
981  }
982  $logData['userVerified'] = $info->getUserInfo()->isVerified();
983  }
984  $this->logger->info( 'Failed to load session, unpersisting', $logData );
985  }
986 
997  public function logPotentialSessionLeakage( Session $session = null ) {
998  $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
999  $session = $session ?: self::getGlobalSession();
1000  $suspiciousIpExpiry = $this->config->get( 'SuspiciousIpExpiry' );
1001 
1002  if ( $suspiciousIpExpiry === false
1003  // We only care about logged-in users.
1004  || !$session->isPersistent() || $session->getUser()->isAnon()
1005  // We only care about cookie-based sessions.
1006  || !( $session->getProvider() instanceof CookieSessionProvider )
1007  ) {
1008  return;
1009  }
1010  try {
1011  $ip = $session->getRequest()->getIP();
1012  } catch ( \MWException $e ) {
1013  return;
1014  }
1015  if ( $ip === '127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1016  return;
1017  }
1018  $mwuser = $session->getRequest()->getCookie( 'mwuser-sessionId' );
1019  $now = \MWTimestamp::now( TS_UNIX );
1020 
1021  // Record (and possibly log) that the IP is using the current session.
1022  // Don't touch the stored data unless we are changing the IP or re-adding an expired one.
1023  // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
1024  // extended) but that shouldn't make much difference and limits the session write frequency.
1025  $data = $session->get( 'SessionManager-logPotentialSessionLeakage', [] )
1026  + [ 'ip' => null, 'mwuser' => null, 'timestamp' => 0 ];
1027  // Ignore old IP records; users change networks over time. mwuser is a session cookie and the
1028  // SessionManager session id is also a session cookie so there shouldn't be any problem there.
1029  if ( $data['ip'] &&
1030  ( $now - $data['timestamp'] > $suspiciousIpExpiry )
1031  ) {
1032  $data['ip'] = $data['timestamp'] = null;
1033  }
1034 
1035  if ( $data['ip'] !== $ip || $data['mwuser'] !== $mwuser ) {
1036  $session->set( 'SessionManager-logPotentialSessionLeakage',
1037  [ 'ip' => $ip, 'mwuser' => $mwuser, 'timestamp' => $now ] );
1038  }
1039 
1040  $ipChanged = ( $data['ip'] && $data['ip'] !== $ip );
1041  $mwuserChanged = ( $data['mwuser'] && $data['mwuser'] !== $mwuser );
1042  $logLevel = $message = null;
1043  $logData = [];
1044  // IPs change all the time. mwuser is a session cookie that's only set when missing,
1045  // so it should only change when the browser session ends which ends the SessionManager
1046  // session as well. Unless we are dealing with a very weird client, such as a bot that
1047  //manipulates cookies and can run Javascript, it should not change.
1048  // IP and mwuser changing at the same time would be *very* suspicious.
1049  if ( $ipChanged ) {
1050  $logLevel = LogLevel::INFO;
1051  $message = 'IP change within the same session';
1052  $logData += [
1053  'oldIp' => $data['ip'],
1054  'oldIpRecorded' => $data['timestamp'],
1055  ];
1056  }
1057  if ( $mwuserChanged ) {
1058  $logLevel = LogLevel::NOTICE;
1059  $message = 'mwuser change within the same session';
1060  $logData += [
1061  'oldMwuser' => $data['mwuser'],
1062  'newMwuser' => $mwuser,
1063  ];
1064  }
1065  if ( $ipChanged && $mwuserChanged ) {
1066  $logLevel = LogLevel::WARNING;
1067  $message = 'IP and mwuser change within the same session';
1068  }
1069  if ( $logLevel ) {
1070  $logData += [
1071  'session' => $session->getId(),
1072  'user' => $session->getUser()->getName(),
1073  'clientip' => $ip,
1074  'userAgent' => $session->getRequest()->getHeader( 'user-agent' ),
1075  ];
1077  $logger->log( $logLevel, $message, $logData );
1078  }
1079  }
1080 
1081  // endregion -- end of Internal methods
1082 
1083 }
MediaWiki\Session\SessionManager\isUserSessionPrevented
isUserSessionPrevented( $username)
Test if a user is prevented.
Definition: SessionManager.php:418
MediaWiki\Session\UserInfo\newAnonymous
static newAnonymous()
Create an instance for an anonymous (i.e.
Definition: UserInfo.php:77
MediaWiki\Session\SessionManager\getEmptySessionInternal
getEmptySessionInternal(WebRequest $request=null, $id=null)
Definition: SessionManager.php:276
FauxRequest
WebRequest clone which takes values from a provided array.
Definition: FauxRequest.php:35
MediaWiki\Session\SessionInfo\forceHTTPS
forceHTTPS()
Whether this session should only be used over HTTPS.
Definition: SessionInfo.php:285
MediaWiki\Session\SessionManager\loadSessionInfoFromStore
loadSessionInfoFromStore(SessionInfo &$info, WebRequest $request)
Load and verify the session info against the store.
Definition: SessionManager.php:550
MediaWiki\Session\SessionManager\$hookContainer
HookContainer $hookContainer
Definition: SessionManager.php:67
MW_NO_SESSION
const MW_NO_SESSION
Definition: load.php:32
MediaWiki\Session\SessionManager\generateSessionId
generateSessionId()
Generate a new random session ID.
Definition: SessionManager.php:937
MediaWiki\Session\SessionManager\getVaryCookies
getVaryCookies()
Return the list of cookies that need varying on.
Definition: SessionManager.php:365
MediaWiki\Session\SessionProvider\setLogger
setLogger(LoggerInterface $logger)
Definition: SessionProvider.php:114
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:172
MediaWiki\Session\SessionManager\$globalSessionRequest
static WebRequest null $globalSessionRequest
Definition: SessionManager.php:61
MediaWiki\Session\SessionManager\getProviders
getProviders()
Get the available SessionProviders.
Definition: SessionManager.php:426
MediaWiki\Logger\LoggerFactory\getInstance
static getInstance( $channel)
Get a named logger instance from the currently configured logger factory.
Definition: LoggerFactory.php:92
MediaWiki\Session\SessionBackend\getId
getId()
Returns the session ID.
Definition: SessionBackend.php:251
MediaWiki\Session\SessionManager\setupPHPSessionHandler
setupPHPSessionHandler(PHPSessionHandler $handler)
Call setters on a PHPSessionHandler.
Definition: SessionManager.php:950
MediaWiki\Session\SessionInfo\compare
static compare( $a, $b)
Compare two SessionInfo objects by priority.
Definition: SessionInfo.php:301
WebRequest\setSessionId
setSessionId(SessionId $sessionId)
Set the session for this request.
Definition: WebRequest.php:837
MediaWiki\Session\SessionManager\$varyHeaders
array $varyHeaders
Definition: SessionManager.php:85
MediaWiki\Session\SessionManager\preventSessionsForUser
preventSessionsForUser( $username)
Prevent future sessions for the user.
Definition: SessionManager.php:403
MediaWiki\Session\SessionManager\$instance
static SessionManager null $instance
Definition: SessionManager.php:55
MediaWiki\Session\MetadataMergeException
Subclass of UnexpectedValueException that can be annotated with additional data for debug logging.
Definition: MetadataMergeException.php:36
MediaWiki\Session\SessionManager\getSessionById
getSessionById( $id, $create=false, WebRequest $request=null)
Fetch a session by ID.
Definition: SessionManager.php:224
MediaWiki\Session\PHPSessionHandler\isEnabled
static isEnabled()
Test whether the handler is installed and enabled.
Definition: PHPSessionHandler.php:103
MediaWiki\Session\SessionInfo\getPriority
getPriority()
Return the priority.
Definition: SessionInfo.php:232
MediaWiki\Session\SessionInfo\getId
getId()
Return the session ID.
Definition: SessionInfo.php:193
MediaWiki\Session\SessionManager\$allSessionBackends
SessionBackend[] $allSessionBackends
Definition: SessionManager.php:88
MediaWiki\Session\SessionInfo\forceUse
forceUse()
Force use of this SessionInfo if validation fails.
Definition: SessionInfo.php:224
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:86
MediaWiki\Session\SessionOverflowException
OverflowException specific to the SessionManager, used when the request had multiple possible session...
Definition: SessionOverflowException.php:11
MediaWiki\Session\MetadataMergeException\getContext
getContext()
Get context data.
Definition: MetadataMergeException.php:60
MediaWiki\MediaWikiServices\getInstance
static getInstance()
Returns the global default instance of the top level service locator.
Definition: MediaWikiServices.php:233
Config
Interface for configuration instances.
Definition: Config.php:30
MWException
MediaWiki exception.
Definition: MWException.php:29
MediaWiki\Session\SessionManager\$config
Config $config
Definition: SessionManager.php:73
MediaWiki\Session\SessionManager\deregisterSessionBackend
deregisterSessionBackend(SessionBackend $backend)
Deregister a SessionBackend.
Definition: SessionManager.php:897
MediaWiki\Session\SessionManager\validateSessionId
static validateSessionId( $id)
Validate a session ID.
Definition: SessionManager.php:386
MediaWiki\Session\Session
Manages data for an authenticated session.
Definition: Session.php:48
MediaWiki\Session\SessionInfo\__toString
__toString()
Definition: SessionInfo.php:289
MediaWiki\Session\SessionProvider
A SessionProvider provides SessionInfo and support for Session.
Definition: SessionProvider.php:81
MediaWiki\Session\SessionInfo\getProvider
getProvider()
Return the provider.
Definition: SessionInfo.php:185
$blob
$blob
Definition: testCompression.php:70
MediaWiki\Session\SessionManager\invalidateSessionsForUser
invalidateSessionsForUser(User $user)
Invalidate sessions for a user.
Definition: SessionManager.php:336
MediaWiki\Session\SessionProvider\setManager
setManager(SessionManager $manager)
Set the session manager.
Definition: SessionProvider.php:130
MediaWiki\Session\SessionManager\$allSessionIds
SessionId[] $allSessionIds
Definition: SessionManager.php:91
MediaWiki
A helper class for throttling authentication attempts.
MediaWiki\Session\SessionManager\$globalSession
static Session null $globalSession
Definition: SessionManager.php:58
ObjectCache\getInstance
static getInstance( $id)
Get a cached instance of the specified type of cache object.
Definition: ObjectCache.php:74
MediaWiki\Session\SessionManager\singleton
static singleton()
Get the global SessionManager.
Definition: SessionManager.php:100
MediaWiki\Session\SessionManager\shutdown
shutdown()
Save all active sessions on shutdown.
Definition: SessionManager.php:465
MediaWiki\Session\SessionManager\logUnpersist
logUnpersist(SessionInfo $info, WebRequest $request)
Reset the internal caching for unit testing.
Definition: SessionManager.php:970
MediaWiki\Session
Definition: BotPasswordSessionProvider.php:24
MediaWiki\Session\SessionInfo\wasPersisted
wasPersisted()
Return whether the session is persisted.
Definition: SessionInfo.php:248
MediaWiki\Session\SessionManager\resetCache
static resetCache()
Reset the internal caching for unit testing.
Definition: SessionManager.php:959
User\saveSettings
saveSettings()
Save this user's settings into the database.
Definition: User.php:3417
MediaWiki\Session\SessionManager\getVaryHeaders
getVaryHeaders()
Return the HTTP headers that need varying on.
Definition: SessionManager.php:345
MediaWiki\Session\SessionInfo\getProviderMetadata
getProviderMetadata()
Return provider metadata.
Definition: SessionInfo.php:256
MediaWiki\Session\PHPSessionHandler
Adapter for PHP's session handling.
Definition: PHPSessionHandler.php:35
MediaWiki\Session\SessionManager\$sessionProviders
SessionProvider[] $sessionProviders
Definition: SessionManager.php:79
MediaWiki\Session\SessionManager\getGlobalSession
static getGlobalSession()
If PHP's session_id() has been set, returns that session.
Definition: SessionManager.php:113
MediaWiki\Session\SessionManager\getSessionInfoForRequest
getSessionInfoForRequest(WebRequest $request)
Fetch the SessionInfo(s) for a request.
Definition: SessionManager.php:484
MediaWiki\Session\SessionBackend\getSessionId
getSessionId()
Fetch the SessionId object.
Definition: SessionBackend.php:260
MediaWiki\Session\SessionManager\setHookContainer
setHookContainer(HookContainer $hookContainer)
Definition: SessionManager.php:208
MediaWiki\Session\SessionManager\setLogger
setLogger(LoggerInterface $logger)
Definition: SessionManager.php:200
MediaWiki\Session\PHPSessionHandler\setManager
setManager(SessionManagerInterface $manager, BagOStuff $store, LoggerInterface $logger)
Set the manager, store, and logger.
Definition: PHPSessionHandler.php:159
MediaWiki\Session\SessionManager\getEmptySession
getEmptySession(WebRequest $request=null)
Create a new, empty session.
Definition: SessionManager.php:266
$header
$header
Definition: updateCredits.php:37
MediaWiki\Session\UserInfo\newFromName
static newFromName( $name, $verified=false)
Create an instance for a logged-in user by name.
Definition: UserInfo.php:105
MediaWiki\Session\SessionManager
This serves as the entry point to the MediaWiki session handling system.
Definition: SessionManager.php:53
CachedBagOStuff
Wrapper around a BagOStuff that caches data in memory.
Definition: CachedBagOStuff.php:37
MediaWiki\Session\SessionManager\$store
CachedBagOStuff null $store
Definition: SessionManager.php:76
MediaWiki\Session\SessionManager\$preventUsers
string[] $preventUsers
Definition: SessionManager.php:94
MediaWiki\Session\SessionProvider\setConfig
setConfig(Config $config)
Set configuration.
Definition: SessionProvider.php:122
MWCryptRand\generateHex
static generateHex( $chars)
Generate a run of cryptographically random data and return it in hexadecimal string format.
Definition: MWCryptRand.php:36
RequestContext\getMain
static getMain()
Get the RequestContext object associated with the main request.
Definition: RequestContext.php:476
WebRequest
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Definition: WebRequest.php:42
MediaWiki\Session\SessionManager\getSessionFromInfo
getSessionFromInfo(SessionInfo $info, WebRequest $request)
Create a Session corresponding to the passed SessionInfo.
Definition: SessionManager.php:840
WebRequest\getIP
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
Definition: WebRequest.php:1269
MediaWiki\Session\SessionInfo
Value object returned by SessionProvider.
Definition: SessionInfo.php:37
MediaWiki\Session\SessionManagerInterface
This exists to make IDEs happy, so they don't see the internal-but-required-to-be-public methods on S...
Definition: SessionManagerInterface.php:37
User\setToken
setToken( $token=false)
Set the random token (used for persistent authentication) Called from loadDefaults() among other plac...
Definition: User.php:2517
MediaWiki\Session\SessionId
Value object holding the session ID in a manner that can be globally updated.
Definition: SessionId.php:40
MediaWiki\Session\UserInfo\newFromId
static newFromId( $id, $verified=false)
Create an instance for a logged-in user by ID.
Definition: UserInfo.php:87
MediaWiki\Session\SessionProvider\setHookContainer
setHookContainer( $hookContainer)
Definition: SessionProvider.php:146
WebRequest\getHeader
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.
Definition: WebRequest.php:1130
MediaWiki\Session\SessionManager\logPotentialSessionLeakage
logPotentialSessionLeakage(Session $session=null)
If the same session is suddenly used from a different IP, that's potentially due to a session leak,...
Definition: SessionManager.php:997
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\Session\CookieSessionProvider
A CookieSessionProvider persists sessions using cookies.
Definition: CookieSessionProvider.php:36
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:575
MediaWiki\Session\SessionManager\getProvider
getProvider( $name)
Get a session provider by name.
Definition: SessionManager.php:456
MediaWiki\Session\SessionManager\changeBackendId
changeBackendId(SessionBackend $backend)
Change a SessionBackend's ID.
Definition: SessionManager.php:915
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:66
MediaWiki\Session\SessionInfo\MIN_PRIORITY
const MIN_PRIORITY
Minimum allowed priority.
Definition: SessionInfo.php:39
MediaWiki\Session\SessionManager\getSessionForRequest
getSessionForRequest(WebRequest $request)
Fetch the session for a request (or a new empty session if none is attached to it)
Definition: SessionManager.php:213
MediaWiki\Session\SessionManager\$hookRunner
HookRunner $hookRunner
Definition: SessionManager.php:70
MediaWiki\Session\SessionManager\$logger
LoggerInterface $logger
Definition: SessionManager.php:64
MediaWiki\Session\SessionManager\$varyCookies
string[] $varyCookies
Definition: SessionManager.php:82
MediaWiki\Session\SessionInfo\getUserInfo
getUserInfo()
Return the user.
Definition: SessionInfo.php:240
MediaWiki\Session\SessionInfo\wasRemembered
wasRemembered()
Return whether the user was remembered.
Definition: SessionInfo.php:275
MediaWiki\Session\SessionInfo\isIdSafe
isIdSafe()
Indicate whether the ID is "safe".
Definition: SessionInfo.php:209
MediaWiki\Session\SessionManager\__construct
__construct( $options=[])
Definition: SessionManager.php:154
MediaWiki\Session\SessionBackend
This is the actual workhorse for Session.
Definition: SessionBackend.php:52