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;
34 use MWException;
35 use Psr\Log\LoggerInterface;
36 use Psr\Log\LogLevel;
37 use User;
38 use WebRequest;
39 use Wikimedia\ObjectFactory;
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( '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 
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  return $this->getSessionFromInfo( $infos[0], $request );
368  }
369 
370  public function invalidateSessionsForUser( User $user ) {
371  $user->setToken();
372  $user->saveSettings();
373 
374  foreach ( $this->getProviders() as $provider ) {
375  $provider->invalidateSessionsForUser( $user );
376  }
377  }
378 
379  public function getVaryHeaders() {
380  // @codeCoverageIgnoreStart
381  if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
382  return [];
383  }
384  // @codeCoverageIgnoreEnd
385  if ( $this->varyHeaders === null ) {
386  $headers = [];
387  foreach ( $this->getProviders() as $provider ) {
388  foreach ( $provider->getVaryHeaders() as $header => $options ) {
389  # Note that the $options value returned has been deprecated
390  # and is ignored.
391  $headers[$header] = null;
392  }
393  }
394  $this->varyHeaders = $headers;
395  }
396  return $this->varyHeaders;
397  }
398 
399  public function getVaryCookies() {
400  // @codeCoverageIgnoreStart
401  if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
402  return [];
403  }
404  // @codeCoverageIgnoreEnd
405  if ( $this->varyCookies === null ) {
406  $cookies = [];
407  foreach ( $this->getProviders() as $provider ) {
408  $cookies = array_merge( $cookies, $provider->getVaryCookies() );
409  }
410  $this->varyCookies = array_values( array_unique( $cookies ) );
411  }
412  return $this->varyCookies;
413  }
414 
420  public static function validateSessionId( $id ) {
421  return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
422  }
423 
424  /***************************************************************************/
425  // region Internal methods
437  public function preventSessionsForUser( $username ) {
438  $this->preventUsers[$username] = true;
439 
440  // Instruct the session providers to kill any other sessions too.
441  foreach ( $this->getProviders() as $provider ) {
442  $provider->preventSessionsForUser( $username );
443  }
444  }
445 
452  public function isUserSessionPrevented( $username ) {
453  return !empty( $this->preventUsers[$username] );
454  }
455 
460  protected function getProviders() {
461  if ( $this->sessionProviders === null ) {
462  $this->sessionProviders = [];
463  foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
465  $provider = ObjectFactory::getObjectFromSpec( $spec );
466  $provider->init(
467  $this->logger,
468  $this->config,
469  $this,
470  $this->hookContainer,
471  $this->userNameUtils
472  );
473  if ( isset( $this->sessionProviders[(string)$provider] ) ) {
474  // @phan-suppress-next-line PhanTypeSuspiciousStringExpression
475  throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
476  }
477  $this->sessionProviders[(string)$provider] = $provider;
478  }
479  }
481  }
482 
493  public function getProvider( $name ) {
494  $providers = $this->getProviders();
495  return $providers[$name] ?? null;
496  }
497 
502  public function shutdown() {
503  if ( $this->allSessionBackends ) {
504  $this->logger->debug( 'Saving all sessions on shutdown' );
505  if ( session_id() !== '' ) {
506  // @codeCoverageIgnoreStart
507  session_write_close();
508  }
509  // @codeCoverageIgnoreEnd
510  foreach ( $this->allSessionBackends as $backend ) {
511  $backend->shutdown();
512  }
513  }
514  }
515 
521  private function getSessionInfoForRequest( WebRequest $request ) {
522  // Call all providers to fetch "the" session
523  $infos = [];
524  foreach ( $this->getProviders() as $provider ) {
525  $info = $provider->provideSessionInfo( $request );
526  if ( !$info ) {
527  continue;
528  }
529  if ( $info->getProvider() !== $provider ) {
530  throw new \UnexpectedValueException(
531  "$provider returned session info for a different provider: $info"
532  );
533  }
534  $infos[] = $info;
535  }
536 
537  // Sort the SessionInfos. Then find the first one that can be
538  // successfully loaded, and then all the ones after it with the same
539  // priority.
540  usort( $infos, [ SessionInfo::class, 'compare' ] );
541  $retInfos = [];
542  while ( $infos ) {
543  $info = array_pop( $infos );
544  if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
545  $retInfos[] = $info;
546  while ( $infos ) {
548  $info = array_pop( $infos );
549  if ( SessionInfo::compare( $retInfos[0], $info ) ) {
550  // We hit a lower priority, stop checking.
551  break;
552  }
553  if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
554  // This is going to error out below, but we want to
555  // provide a complete list.
556  $retInfos[] = $info;
557  } else {
558  // Session load failed, so unpersist it from this request
559  $this->logUnpersist( $info, $request );
560  $info->getProvider()->unpersistSession( $request );
561  }
562  }
563  } else {
564  // Session load failed, so unpersist it from this request
565  $this->logUnpersist( $info, $request );
566  $info->getProvider()->unpersistSession( $request );
567  }
568  }
569 
570  if ( count( $retInfos ) > 1 ) {
571  throw new SessionOverflowException(
572  $retInfos,
573  'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
574  );
575  }
576 
577  return $retInfos ? $retInfos[0] : null;
578  }
579 
587  private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
588  $key = $this->store->makeKey( 'MWSession', $info->getId() );
589  $blob = $this->store->get( $key );
590 
591  // If we got data from the store and the SessionInfo says to force use,
592  // "fail" means to delete the data from the store and retry. Otherwise,
593  // "fail" is just return false.
594  if ( $info->forceUse() && $blob !== false ) {
595  $failHandler = function () use ( $key, &$info, $request ) {
596  $this->store->delete( $key );
597  return $this->loadSessionInfoFromStore( $info, $request );
598  };
599  } else {
600  $failHandler = static function () {
601  return false;
602  };
603  }
604 
605  $newParams = [];
606 
607  if ( $blob !== false ) {
608  // Sanity check: blob must be an array, if it's saved at all
609  if ( !is_array( $blob ) ) {
610  $this->logger->warning( 'Session "{session}": Bad data', [
611  'session' => $info->__toString(),
612  ] );
613  $this->store->delete( $key );
614  return $failHandler();
615  }
616 
617  // Sanity check: blob has data and metadata arrays
618  if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
619  !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
620  ) {
621  $this->logger->warning( 'Session "{session}": Bad data structure', [
622  'session' => $info->__toString(),
623  ] );
624  $this->store->delete( $key );
625  return $failHandler();
626  }
627 
628  $data = $blob['data'];
629  $metadata = $blob['metadata'];
630 
631  // Sanity check: metadata must be an array and must contain certain
632  // keys, if it's saved at all
633  if ( !array_key_exists( 'userId', $metadata ) ||
634  !array_key_exists( 'userName', $metadata ) ||
635  !array_key_exists( 'userToken', $metadata ) ||
636  !array_key_exists( 'provider', $metadata )
637  ) {
638  $this->logger->warning( 'Session "{session}": Bad metadata', [
639  'session' => $info->__toString(),
640  ] );
641  $this->store->delete( $key );
642  return $failHandler();
643  }
644 
645  // First, load the provider from metadata, or validate it against the metadata.
646  $provider = $info->getProvider();
647  if ( $provider === null ) {
648  $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
649  if ( !$provider ) {
650  $this->logger->warning(
651  'Session "{session}": Unknown provider ' . $metadata['provider'],
652  [
653  'session' => $info->__toString(),
654  ]
655  );
656  $this->store->delete( $key );
657  return $failHandler();
658  }
659  } elseif ( $metadata['provider'] !== (string)$provider ) {
660  $this->logger->warning( 'Session "{session}": Wrong provider ' .
661  $metadata['provider'] . ' !== ' . $provider,
662  [
663  'session' => $info->__toString(),
664  ] );
665  return $failHandler();
666  }
667 
668  // Load provider metadata from metadata, or validate it against the metadata
669  $providerMetadata = $info->getProviderMetadata();
670  if ( isset( $metadata['providerMetadata'] ) ) {
671  if ( $providerMetadata === null ) {
672  $newParams['metadata'] = $metadata['providerMetadata'];
673  } else {
674  try {
675  $newProviderMetadata = $provider->mergeMetadata(
676  $metadata['providerMetadata'], $providerMetadata
677  );
678  if ( $newProviderMetadata !== $providerMetadata ) {
679  $newParams['metadata'] = $newProviderMetadata;
680  }
681  } catch ( MetadataMergeException $ex ) {
682  $this->logger->warning(
683  'Session "{session}": Metadata merge failed: {exception}',
684  [
685  'session' => $info->__toString(),
686  'exception' => $ex,
687  ] + $ex->getContext()
688  );
689  return $failHandler();
690  }
691  }
692  }
693 
694  // Next, load the user from metadata, or validate it against the metadata.
695  $userInfo = $info->getUserInfo();
696  if ( !$userInfo ) {
697  // For loading, id is preferred to name.
698  try {
699  if ( $metadata['userId'] ) {
700  $userInfo = UserInfo::newFromId( $metadata['userId'] );
701  } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
702  $userInfo = UserInfo::newFromName( $metadata['userName'] );
703  } else {
704  $userInfo = UserInfo::newAnonymous();
705  }
706  } catch ( \InvalidArgumentException $ex ) {
707  $this->logger->error( 'Session "{session}": {exception}', [
708  'session' => $info->__toString(),
709  'exception' => $ex,
710  ] );
711  return $failHandler();
712  }
713  $newParams['userInfo'] = $userInfo;
714  } else {
715  // User validation passes if user ID matches, or if there
716  // is no saved ID and the names match.
717  if ( $metadata['userId'] ) {
718  if ( $metadata['userId'] !== $userInfo->getId() ) {
719  $this->logger->warning(
720  'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
721  [
722  'session' => $info->__toString(),
723  'uid_a' => $metadata['userId'],
724  'uid_b' => $userInfo->getId(),
725  ] );
726  return $failHandler();
727  }
728 
729  // If the user was renamed, probably best to fail here.
730  if ( $metadata['userName'] !== null &&
731  $userInfo->getName() !== $metadata['userName']
732  ) {
733  $this->logger->warning(
734  'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
735  [
736  'session' => $info->__toString(),
737  'uname_a' => $metadata['userName'],
738  'uname_b' => $userInfo->getName(),
739  ] );
740  return $failHandler();
741  }
742 
743  } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
744  if ( $metadata['userName'] !== $userInfo->getName() ) {
745  $this->logger->warning(
746  'Session "{session}": User name mismatch, {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  } elseif ( !$userInfo->isAnon() ) {
755  // Metadata specifies an anonymous user, but the passed-in
756  // user isn't anonymous.
757  $this->logger->warning(
758  'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
759  [
760  'session' => $info->__toString(),
761  ] );
762  return $failHandler();
763  }
764  }
765 
766  // And if we have a token in the metadata, it must match the loaded/provided user.
767  if ( $metadata['userToken'] !== null &&
768  $userInfo->getToken() !== $metadata['userToken']
769  ) {
770  $this->logger->warning( 'Session "{session}": User token mismatch', [
771  'session' => $info->__toString(),
772  ] );
773  return $failHandler();
774  }
775  if ( !$userInfo->isVerified() ) {
776  $newParams['userInfo'] = $userInfo->verified();
777  }
778 
779  if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
780  $newParams['remembered'] = true;
781  }
782  if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
783  $newParams['forceHTTPS'] = true;
784  }
785  if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
786  $newParams['persisted'] = true;
787  }
788 
789  if ( !$info->isIdSafe() ) {
790  $newParams['idIsSafe'] = true;
791  }
792  } else {
793  // No metadata, so we can't load the provider if one wasn't given.
794  if ( $info->getProvider() === null ) {
795  $this->logger->warning(
796  'Session "{session}": Null provider and no metadata',
797  [
798  'session' => $info->__toString(),
799  ] );
800  return $failHandler();
801  }
802 
803  // If no user was provided and no metadata, it must be anon.
804  if ( !$info->getUserInfo() ) {
805  if ( $info->getProvider()->canChangeUser() ) {
806  $newParams['userInfo'] = UserInfo::newAnonymous();
807  } else {
808  $this->logger->info(
809  'Session "{session}": No user provided and provider cannot set user',
810  [
811  'session' => $info->__toString(),
812  ] );
813  return $failHandler();
814  }
815  } elseif ( !$info->getUserInfo()->isVerified() ) {
816  // probably just a session timeout
817  $this->logger->info(
818  'Session "{session}": Unverified user provided and no metadata to auth it',
819  [
820  'session' => $info->__toString(),
821  ] );
822  return $failHandler();
823  }
824 
825  $data = false;
826  $metadata = false;
827 
828  if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
829  // The ID doesn't come from the user, so it should be safe
830  // (and if not, nothing we can do about it anyway)
831  $newParams['idIsSafe'] = true;
832  }
833  }
834 
835  // Construct the replacement SessionInfo, if necessary
836  if ( $newParams ) {
837  $newParams['copyFrom'] = $info;
838  $info = new SessionInfo( $info->getPriority(), $newParams );
839  }
840 
841  // Allow the provider to check the loaded SessionInfo
842  $providerMetadata = $info->getProviderMetadata();
843  if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
844  return $failHandler();
845  }
846  if ( $providerMetadata !== $info->getProviderMetadata() ) {
847  $info = new SessionInfo( $info->getPriority(), [
848  'metadata' => $providerMetadata,
849  'copyFrom' => $info,
850  ] );
851  }
852 
853  // Give hooks a chance to abort. Combined with the SessionMetadata
854  // hook, this can allow for tying a session to an IP address or the
855  // like.
856  $reason = 'Hook aborted';
857  if ( !$this->hookRunner->onSessionCheckInfo(
858  $reason, $info, $request, $metadata, $data )
859  ) {
860  $this->logger->warning( 'Session "{session}": ' . $reason, [
861  'session' => $info->__toString(),
862  ] );
863  return $failHandler();
864  }
865 
866  return true;
867  }
868 
877  public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
878  // @codeCoverageIgnoreStart
879  if ( defined( 'MW_NO_SESSION' ) ) {
880  if ( MW_NO_SESSION === 'warn' ) {
881  // Undocumented safety case for converting existing entry points
882  $this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
883  'exception' => new \BadMethodCallException( 'Sessions are disabled for this entry point' ),
884  ] );
885  } else {
886  throw new \BadMethodCallException( 'Sessions are disabled for this entry point' );
887  }
888  }
889  // @codeCoverageIgnoreEnd
890 
891  $id = $info->getId();
892 
893  if ( !isset( $this->allSessionBackends[$id] ) ) {
894  if ( !isset( $this->allSessionIds[$id] ) ) {
895  $this->allSessionIds[$id] = new SessionId( $id );
896  }
897  $backend = new SessionBackend(
898  $this->allSessionIds[$id],
899  $info,
900  $this->store,
901  $this->logger,
902  $this->hookContainer,
903  $this->config->get( 'ObjectCacheSessionExpiry' )
904  );
905  $this->allSessionBackends[$id] = $backend;
906  $delay = $backend->delaySave();
907  } else {
908  $backend = $this->allSessionBackends[$id];
909  $delay = $backend->delaySave();
910  if ( $info->wasPersisted() ) {
911  $backend->persist();
912  }
913  if ( $info->wasRemembered() ) {
914  $backend->setRememberUser( true );
915  }
916  }
917 
918  $request->setSessionId( $backend->getSessionId() );
919  $session = $backend->getSession( $request );
920 
921  if ( !$info->isIdSafe() ) {
922  $session->resetId();
923  }
924 
925  \Wikimedia\ScopedCallback::consume( $delay );
926  return $session;
927  }
928 
934  public function deregisterSessionBackend( SessionBackend $backend ) {
935  $id = $backend->getId();
936  if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
937  $this->allSessionBackends[$id] !== $backend ||
938  $this->allSessionIds[$id] !== $backend->getSessionId()
939  ) {
940  throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
941  }
942 
943  unset( $this->allSessionBackends[$id] );
944  // Explicitly do not unset $this->allSessionIds[$id]
945  }
946 
952  public function changeBackendId( SessionBackend $backend ) {
953  $sessionId = $backend->getSessionId();
954  $oldId = (string)$sessionId;
955  if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
956  $this->allSessionBackends[$oldId] !== $backend ||
957  $this->allSessionIds[$oldId] !== $sessionId
958  ) {
959  throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
960  }
961 
962  $newId = $this->generateSessionId();
963 
964  unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
965  $sessionId->setId( $newId );
966  $this->allSessionBackends[$newId] = $backend;
967  $this->allSessionIds[$newId] = $sessionId;
968  }
969 
974  public function generateSessionId() {
975  do {
976  $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
977  $key = $this->store->makeKey( 'MWSession', $id );
978  } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
979  return $id;
980  }
981 
987  public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
988  $handler->setManager( $this, $this->store, $this->logger );
989  }
990 
996  public static function resetCache() {
997  if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
998  // @codeCoverageIgnoreStart
999  throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
1000  // @codeCoverageIgnoreEnd
1001  }
1002 
1003  self::$globalSession = null;
1004  self::$globalSessionRequest = null;
1005  }
1006 
1007  private function logUnpersist( SessionInfo $info, WebRequest $request ) {
1008  $logData = [
1009  'id' => $info->getId(),
1010  'provider' => get_class( $info->getProvider() ),
1011  'user' => '<anon>',
1012  'clientip' => $request->getIP(),
1013  'userAgent' => $request->getHeader( 'user-agent' ),
1014  ];
1015  if ( $info->getUserInfo() ) {
1016  if ( !$info->getUserInfo()->isAnon() ) {
1017  $logData['user'] = $info->getUserInfo()->getName();
1018  }
1019  $logData['userVerified'] = $info->getUserInfo()->isVerified();
1020  }
1021  $this->logger->info( 'Failed to load session, unpersisting', $logData );
1022  }
1023 
1034  public function logPotentialSessionLeakage( Session $session = null ) {
1035  $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
1036  $session = $session ?: self::getGlobalSession();
1037  $suspiciousIpExpiry = $this->config->get( 'SuspiciousIpExpiry' );
1038 
1039  if ( $suspiciousIpExpiry === false
1040  // We only care about logged-in users.
1041  || !$session->isPersistent() || $session->getUser()->isAnon()
1042  // We only care about cookie-based sessions.
1043  || !( $session->getProvider() instanceof CookieSessionProvider )
1044  ) {
1045  return;
1046  }
1047  try {
1048  $ip = $session->getRequest()->getIP();
1049  } catch ( \MWException $e ) {
1050  return;
1051  }
1052  if ( $ip === '127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1053  return;
1054  }
1055  $mwuser = $session->getRequest()->getCookie( 'mwuser-sessionId' );
1056  $now = \MWTimestamp::now( TS_UNIX );
1057 
1058  // Record (and possibly log) that the IP is using the current session.
1059  // Don't touch the stored data unless we are changing the IP or re-adding an expired one.
1060  // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
1061  // extended) but that shouldn't make much difference and limits the session write frequency.
1062  $data = $session->get( 'SessionManager-logPotentialSessionLeakage', [] )
1063  + [ 'ip' => null, 'mwuser' => null, 'timestamp' => 0 ];
1064  // Ignore old IP records; users change networks over time. mwuser is a session cookie and the
1065  // SessionManager session id is also a session cookie so there shouldn't be any problem there.
1066  if ( $data['ip'] &&
1067  ( $now - $data['timestamp'] > $suspiciousIpExpiry )
1068  ) {
1069  $data['ip'] = $data['timestamp'] = null;
1070  }
1071 
1072  if ( $data['ip'] !== $ip || $data['mwuser'] !== $mwuser ) {
1073  $session->set( 'SessionManager-logPotentialSessionLeakage',
1074  [ 'ip' => $ip, 'mwuser' => $mwuser, 'timestamp' => $now ] );
1075  }
1076 
1077  $ipChanged = ( $data['ip'] && $data['ip'] !== $ip );
1078  $mwuserChanged = ( $data['mwuser'] && $data['mwuser'] !== $mwuser );
1079  $logLevel = $message = null;
1080  $logData = [];
1081  // IPs change all the time. mwuser is a session cookie that's only set when missing,
1082  // so it should only change when the browser session ends which ends the SessionManager
1083  // session as well. Unless we are dealing with a very weird client, such as a bot that
1084  //manipulates cookies and can run Javascript, it should not change.
1085  // IP and mwuser changing at the same time would be *very* suspicious.
1086  if ( $ipChanged ) {
1087  $logLevel = LogLevel::INFO;
1088  $message = 'IP change within the same session';
1089  $logData += [
1090  'oldIp' => $data['ip'],
1091  'oldIpRecorded' => $data['timestamp'],
1092  ];
1093  }
1094  if ( $mwuserChanged ) {
1095  $logLevel = LogLevel::NOTICE;
1096  $message = 'mwuser change within the same session';
1097  $logData += [
1098  'oldMwuser' => $data['mwuser'],
1099  'newMwuser' => $mwuser,
1100  ];
1101  }
1102  if ( $ipChanged && $mwuserChanged ) {
1103  $logLevel = LogLevel::WARNING;
1104  $message = 'IP and mwuser change within the same session';
1105  }
1106  if ( $logLevel ) {
1107  $logData += [
1108  'session' => $session->getId(),
1109  'user' => $session->getUser()->getName(),
1110  'clientip' => $ip,
1111  'userAgent' => $session->getRequest()->getHeader( 'user-agent' ),
1112  ];
1114  $logger->log( $logLevel, $message, $logData );
1115  }
1116  }
1117 
1118  // endregion -- end of Internal methods
1119 
1120 }
MediaWiki\Session\SessionManager\isUserSessionPrevented
isUserSessionPrevented( $username)
Test if a user is prevented.
Definition: SessionManager.php:452
MediaWiki\Session\UserInfo\newAnonymous
static newAnonymous()
Create an instance for an anonymous (i.e.
Definition: UserInfo.php:78
MediaWiki\Session\SessionManager\getEmptySessionInternal
getEmptySessionInternal(WebRequest $request=null, $id=null)
Definition: SessionManager.php:310
MediaWiki\Session\SessionProvider\init
init(LoggerInterface $logger, Config $config, SessionManager $manager, HookContainer $hookContainer, UserNameUtils $userNameUtils)
Initialise with dependencies of a SessionProvider.
Definition: SessionProvider.php:126
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:587
MediaWiki\Session\SessionManager\$hookContainer
HookContainer $hookContainer
Definition: SessionManager.php:97
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:974
MediaWiki\Session\SessionManager\getVaryCookies
getVaryCookies()
Return the list of cookies that need varying on.
Definition: SessionManager.php:399
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:186
MediaWiki\Session\SessionManager\$globalSessionRequest
static WebRequest null $globalSessionRequest
Definition: SessionManager.php:91
MediaWiki\Session\SessionManager\getProviders
getProviders()
Get the available SessionProviders.
Definition: SessionManager.php:460
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:252
MediaWiki\Session\SessionManager\setupPHPSessionHandler
setupPHPSessionHandler(PHPSessionHandler $handler)
Call setters on a PHPSessionHandler.
Definition: SessionManager.php:987
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:834
MediaWiki\Session\SessionManager\$varyHeaders
array $varyHeaders
Definition: SessionManager.php:118
MediaWiki\Session\SessionManager\preventSessionsForUser
preventSessionsForUser( $username)
Prevent future sessions for the user.
Definition: SessionManager.php:437
MediaWiki\Session\SessionManager\$instance
static SessionManager null $instance
Definition: SessionManager.php:85
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:258
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:121
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:247
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:103
MediaWiki\Session\SessionManager\deregisterSessionBackend
deregisterSessionBackend(SessionBackend $backend)
Deregister a SessionBackend.
Definition: SessionManager.php:934
MediaWiki\Session\SessionManager\validateSessionId
static validateSessionId( $id)
Validate a session ID.
Definition: SessionManager.php:420
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:370
MediaWiki\Session\SessionManager\$allSessionIds
SessionId[] $allSessionIds
Definition: SessionManager.php:124
MediaWiki
A helper class for throttling authentication attempts.
MediaWiki\Session\SessionManager\$globalSession
static Session null $globalSession
Definition: SessionManager.php:88
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:133
MediaWiki\Session\SessionManager\shutdown
shutdown()
Save all active sessions on shutdown.
Definition: SessionManager.php:502
MediaWiki\Session\SessionManager\logUnpersist
logUnpersist(SessionInfo $info, WebRequest $request)
Reset the internal caching for unit testing.
Definition: SessionManager.php:1007
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:996
MediaWiki\Session\SessionManager\$userNameUtils
UserNameUtils $userNameUtils
Definition: SessionManager.php:106
User\saveSettings
saveSettings()
Save this user's settings into the database.
Definition: User.php:3289
MediaWiki\Session\SessionManager\getVaryHeaders
getVaryHeaders()
Return the HTTP headers that need varying on.
Definition: SessionManager.php:379
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:112
MediaWiki\Session\SessionManager\getGlobalSession
static getGlobalSession()
If PHP's session_id() has been set, returns that session.
Definition: SessionManager.php:146
MediaWiki\Session\SessionManager\getSessionInfoForRequest
getSessionInfoForRequest(WebRequest $request)
Fetch the SessionInfo(s) for a request.
Definition: SessionManager.php:521
MediaWiki\Session\SessionBackend\getSessionId
getSessionId()
Fetch the SessionId object.
Definition: SessionBackend.php:261
MediaWiki\Session\SessionManager\setHookContainer
setHookContainer(HookContainer $hookContainer)
Definition: SessionManager.php:242
MediaWiki\Session\SessionManager\setLogger
setLogger(LoggerInterface $logger)
Definition: SessionManager.php:234
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:300
$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:106
MediaWiki\Session\SessionManager
This serves as the entry point to the MediaWiki session handling system.
Definition: SessionManager.php:83
CachedBagOStuff
Wrapper around a BagOStuff that caches data in memory.
Definition: CachedBagOStuff.php:37
MediaWiki\Session\SessionManager\$store
CachedBagOStuff null $store
Definition: SessionManager.php:109
MediaWiki\Session\SessionManager\$preventUsers
string[] $preventUsers
Definition: SessionManager.php:127
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:484
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:877
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:1263
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:2412
MediaWiki\Session\SessionId
Value object holding the session ID in a manner that can be globally updated.
Definition: SessionId.php:40
MediaWiki\User\UserNameUtils
UserNameUtils service.
Definition: UserNameUtils.php:42
MediaWiki\Session\UserInfo\newFromId
static newFromId( $id, $verified=false)
Create an instance for a logged-in user by ID.
Definition: UserInfo.php:88
WebRequest\getHeader
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.
Definition: WebRequest.php:1124
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:1034
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:555
MediaWiki\Session\SessionManager\getProvider
getProvider( $name)
Get a session provider by name.
Definition: SessionManager.php:493
MediaWiki\Session\SessionManager\changeBackendId
changeBackendId(SessionBackend $backend)
Change a SessionBackend's ID.
Definition: SessionManager.php:952
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:68
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:247
MediaWiki\Session\SessionManager\$hookRunner
HookRunner $hookRunner
Definition: SessionManager.php:100
MediaWiki\Session\SessionManager\$logger
LoggerInterface $logger
Definition: SessionManager.php:94
MediaWiki\Session\SessionManager\$varyCookies
string[] $varyCookies
Definition: SessionManager.php:115
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:187
MediaWiki\Session\SessionBackend
This is the actual workhorse for Session.
Definition: SessionBackend.php:53