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