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