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