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