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