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  // Double 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  // Double 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  // Double 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  $ep = defined( 'MW_ENTRY_POINT' ) ? MW_ENTRY_POINT : 'this';
881 
882  if ( MW_NO_SESSION === 'warn' ) {
883  // Undocumented safety case for converting existing entry points
884  $this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
885  'exception' => new \BadMethodCallException( "Sessions are disabled for $ep entry point" ),
886  ] );
887  } else {
888  throw new \BadMethodCallException( "Sessions are disabled for $ep entry point" );
889  }
890  }
891  // @codeCoverageIgnoreEnd
892 
893  $id = $info->getId();
894 
895  if ( !isset( $this->allSessionBackends[$id] ) ) {
896  if ( !isset( $this->allSessionIds[$id] ) ) {
897  $this->allSessionIds[$id] = new SessionId( $id );
898  }
899  $backend = new SessionBackend(
900  $this->allSessionIds[$id],
901  $info,
902  $this->store,
903  $this->logger,
904  $this->hookContainer,
905  $this->config->get( 'ObjectCacheSessionExpiry' )
906  );
907  $this->allSessionBackends[$id] = $backend;
908  $delay = $backend->delaySave();
909  } else {
910  $backend = $this->allSessionBackends[$id];
911  $delay = $backend->delaySave();
912  if ( $info->wasPersisted() ) {
913  $backend->persist();
914  }
915  if ( $info->wasRemembered() ) {
916  $backend->setRememberUser( true );
917  }
918  }
919 
920  $request->setSessionId( $backend->getSessionId() );
921  $session = $backend->getSession( $request );
922 
923  if ( !$info->isIdSafe() ) {
924  $session->resetId();
925  }
926 
927  \Wikimedia\ScopedCallback::consume( $delay );
928  return $session;
929  }
930 
936  public function deregisterSessionBackend( SessionBackend $backend ) {
937  $id = $backend->getId();
938  if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
939  $this->allSessionBackends[$id] !== $backend ||
940  $this->allSessionIds[$id] !== $backend->getSessionId()
941  ) {
942  throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
943  }
944 
945  unset( $this->allSessionBackends[$id] );
946  // Explicitly do not unset $this->allSessionIds[$id]
947  }
948 
954  public function changeBackendId( SessionBackend $backend ) {
955  $sessionId = $backend->getSessionId();
956  $oldId = (string)$sessionId;
957  if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
958  $this->allSessionBackends[$oldId] !== $backend ||
959  $this->allSessionIds[$oldId] !== $sessionId
960  ) {
961  throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
962  }
963 
964  $newId = $this->generateSessionId();
965 
966  unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
967  $sessionId->setId( $newId );
968  $this->allSessionBackends[$newId] = $backend;
969  $this->allSessionIds[$newId] = $sessionId;
970  }
971 
976  public function generateSessionId() {
977  do {
978  $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
979  $key = $this->store->makeKey( 'MWSession', $id );
980  } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
981  return $id;
982  }
983 
989  public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
990  $handler->setManager( $this, $this->store, $this->logger );
991  }
992 
998  public static function resetCache() {
999  if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
1000  // @codeCoverageIgnoreStart
1001  throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
1002  // @codeCoverageIgnoreEnd
1003  }
1004 
1005  self::$globalSession = null;
1006  self::$globalSessionRequest = null;
1007  }
1008 
1009  private function logUnpersist( SessionInfo $info, WebRequest $request ) {
1010  $logData = [
1011  'id' => $info->getId(),
1012  'provider' => get_class( $info->getProvider() ),
1013  'user' => '<anon>',
1014  'clientip' => $request->getIP(),
1015  'userAgent' => $request->getHeader( 'user-agent' ),
1016  ];
1017  if ( $info->getUserInfo() ) {
1018  if ( !$info->getUserInfo()->isAnon() ) {
1019  $logData['user'] = $info->getUserInfo()->getName();
1020  }
1021  $logData['userVerified'] = $info->getUserInfo()->isVerified();
1022  }
1023  $this->logger->info( 'Failed to load session, unpersisting', $logData );
1024  }
1025 
1036  public function logPotentialSessionLeakage( Session $session = null ) {
1037  $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
1038  $session = $session ?: self::getGlobalSession();
1039  $suspiciousIpExpiry = $this->config->get( 'SuspiciousIpExpiry' );
1040 
1041  if ( $suspiciousIpExpiry === false
1042  // We only care about logged-in users.
1043  || !$session->isPersistent() || $session->getUser()->isAnon()
1044  // We only care about cookie-based sessions.
1045  || !( $session->getProvider() instanceof CookieSessionProvider )
1046  ) {
1047  return;
1048  }
1049  try {
1050  $ip = $session->getRequest()->getIP();
1051  } catch ( \MWException $e ) {
1052  return;
1053  }
1054  if ( $ip === '127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1055  return;
1056  }
1057  $mwuser = $session->getRequest()->getCookie( 'mwuser-sessionId' );
1058  $now = \MWTimestamp::now( TS_UNIX );
1059 
1060  // Record (and possibly log) that the IP is using the current session.
1061  // Don't touch the stored data unless we are changing the IP or re-adding an expired one.
1062  // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
1063  // extended) but that shouldn't make much difference and limits the session write frequency.
1064  $data = $session->get( 'SessionManager-logPotentialSessionLeakage', [] )
1065  + [ 'ip' => null, 'mwuser' => null, 'timestamp' => 0 ];
1066  // Ignore old IP records; users change networks over time. mwuser is a session cookie and the
1067  // SessionManager session id is also a session cookie so there shouldn't be any problem there.
1068  if ( $data['ip'] &&
1069  ( $now - $data['timestamp'] > $suspiciousIpExpiry )
1070  ) {
1071  $data['ip'] = $data['timestamp'] = null;
1072  }
1073 
1074  if ( $data['ip'] !== $ip || $data['mwuser'] !== $mwuser ) {
1075  $session->set( 'SessionManager-logPotentialSessionLeakage',
1076  [ 'ip' => $ip, 'mwuser' => $mwuser, 'timestamp' => $now ] );
1077  }
1078 
1079  $ipChanged = ( $data['ip'] && $data['ip'] !== $ip );
1080  $mwuserChanged = ( $data['mwuser'] && $data['mwuser'] !== $mwuser );
1081  $logLevel = $message = null;
1082  $logData = [];
1083  // IPs change all the time. mwuser is a session cookie that's only set when missing,
1084  // so it should only change when the browser session ends which ends the SessionManager
1085  // session as well. Unless we are dealing with a very weird client, such as a bot that
1086  //manipulates cookies and can run Javascript, it should not change.
1087  // IP and mwuser changing at the same time would be *very* suspicious.
1088  if ( $ipChanged ) {
1089  $logLevel = LogLevel::INFO;
1090  $message = 'IP change within the same session';
1091  $logData += [
1092  'oldIp' => $data['ip'],
1093  'oldIpRecorded' => $data['timestamp'],
1094  ];
1095  }
1096  if ( $mwuserChanged ) {
1097  $logLevel = LogLevel::NOTICE;
1098  $message = 'mwuser change within the same session';
1099  $logData += [
1100  'oldMwuser' => $data['mwuser'],
1101  'newMwuser' => $mwuser,
1102  ];
1103  }
1104  if ( $ipChanged && $mwuserChanged ) {
1105  $logLevel = LogLevel::WARNING;
1106  $message = 'IP and mwuser change within the same session';
1107  }
1108  if ( $logLevel ) {
1109  $logData += [
1110  'session' => $session->getId(),
1111  'user' => $session->getUser()->getName(),
1112  'clientip' => $ip,
1113  'userAgent' => $session->getRequest()->getHeader( 'user-agent' ),
1114  ];
1116  $logger->log( $logLevel, $message, $logData );
1117  }
1118  }
1119 
1120  // endregion -- end of Internal methods
1121 
1122 }
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:36
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:976
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:203
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:989
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:841
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:264
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:936
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:49
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:1009
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:998
MediaWiki\Session\SessionManager\$userNameUtils
UserNameUtils $userNameUtils
Definition: SessionManager.php:106
User\saveSettings
saveSettings()
Save this user's settings into the database.
Definition: User.php:2768
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:43
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:1272
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:2214
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:1133
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:1036
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:557
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:954
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:67
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
MW_ENTRY_POINT
const MW_ENTRY_POINT
Definition: api.php:41
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