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
391 public function getVaryHeaders() {
392 // @codeCoverageIgnoreStart
393 if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
394 return [];
395 }
396 // @codeCoverageIgnoreEnd
397 if ( $this->varyHeaders === null ) {
398 $headers = [];
399 foreach ( $this->getProviders() as $provider ) {
400 foreach ( $provider->getVaryHeaders() as $header => $options ) {
401 # Note that the $options value returned has been deprecated
402 # and is ignored.
403 $headers[$header] = null;
404 }
405 }
406 $this->varyHeaders = $headers;
407 }
408 return $this->varyHeaders;
409 }
410
411 public function getVaryCookies() {
412 // @codeCoverageIgnoreStart
413 if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
414 return [];
415 }
416 // @codeCoverageIgnoreEnd
417 if ( $this->varyCookies === null ) {
418 $cookies = [];
419 foreach ( $this->getProviders() as $provider ) {
420 $cookies = array_merge( $cookies, $provider->getVaryCookies() );
421 }
422 $this->varyCookies = array_values( array_unique( $cookies ) );
423 }
424 return $this->varyCookies;
425 }
426
432 public static function validateSessionId( $id ) {
433 return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
434 }
435
436 /***************************************************************************/
437 // region Internal methods
449 public function preventSessionsForUser( $username ) {
450 $this->preventUsers[$username] = true;
451
452 // Instruct the session providers to kill any other sessions too.
453 foreach ( $this->getProviders() as $provider ) {
454 $provider->preventSessionsForUser( $username );
455 }
456 }
457
464 public function isUserSessionPrevented( $username ) {
465 return !empty( $this->preventUsers[$username] );
466 }
467
472 protected function getProviders() {
473 if ( $this->sessionProviders === null ) {
474 $this->sessionProviders = [];
475 $objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
476 foreach ( $this->config->get( MainConfigNames::SessionProviders ) as $spec ) {
478 $provider = $objectFactory->createObject( $spec );
479 $provider->init(
480 $this->logger,
481 $this->config,
482 $this,
483 $this->hookContainer,
484 $this->userNameUtils
485 );
486 if ( isset( $this->sessionProviders[(string)$provider] ) ) {
487 // @phan-suppress-next-line PhanTypeSuspiciousStringExpression
488 throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
489 }
490 $this->sessionProviders[(string)$provider] = $provider;
491 }
492 }
493 return $this->sessionProviders;
494 }
495
506 public function getProvider( $name ) {
507 $providers = $this->getProviders();
508 return $providers[$name] ?? null;
509 }
510
515 public function shutdown() {
516 if ( $this->allSessionBackends ) {
517 $this->logger->debug( 'Saving all sessions on shutdown' );
518 if ( session_id() !== '' ) {
519 // @codeCoverageIgnoreStart
520 session_write_close();
521 }
522 // @codeCoverageIgnoreEnd
523 foreach ( $this->allSessionBackends as $backend ) {
524 $backend->shutdown();
525 }
526 }
527 }
528
534 private function getSessionInfoForRequest( WebRequest $request ) {
535 // Call all providers to fetch "the" session
536 $infos = [];
537 foreach ( $this->getProviders() as $provider ) {
538 $info = $provider->provideSessionInfo( $request );
539 if ( !$info ) {
540 continue;
541 }
542 if ( $info->getProvider() !== $provider ) {
543 throw new \UnexpectedValueException(
544 "$provider returned session info for a different provider: $info"
545 );
546 }
547 $infos[] = $info;
548 }
549
550 // Sort the SessionInfos. Then find the first one that can be
551 // successfully loaded, and then all the ones after it with the same
552 // priority.
553 usort( $infos, [ SessionInfo::class, 'compare' ] );
554 $retInfos = [];
555 while ( $infos ) {
556 $info = array_pop( $infos );
557 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
558 $retInfos[] = $info;
559 while ( $infos ) {
561 $info = array_pop( $infos );
562 if ( SessionInfo::compare( $retInfos[0], $info ) ) {
563 // We hit a lower priority, stop checking.
564 break;
565 }
566 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
567 // This is going to error out below, but we want to
568 // provide a complete list.
569 $retInfos[] = $info;
570 } else {
571 // Session load failed, so unpersist it from this request
572 $this->logUnpersist( $info, $request );
573 $info->getProvider()->unpersistSession( $request );
574 }
575 }
576 } else {
577 // Session load failed, so unpersist it from this request
578 $this->logUnpersist( $info, $request );
579 $info->getProvider()->unpersistSession( $request );
580 }
581 }
582
583 if ( count( $retInfos ) > 1 ) {
584 throw new SessionOverflowException(
585 $retInfos,
586 'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
587 );
588 }
589
590 return $retInfos[0] ?? null;
591 }
592
600 private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
601 $key = $this->store->makeKey( 'MWSession', $info->getId() );
602 $blob = $this->store->get( $key );
603
604 // If we got data from the store and the SessionInfo says to force use,
605 // "fail" means to delete the data from the store and retry. Otherwise,
606 // "fail" is just return false.
607 if ( $info->forceUse() && $blob !== false ) {
608 $failHandler = function () use ( $key, &$info, $request ) {
609 $this->store->delete( $key );
610 return $this->loadSessionInfoFromStore( $info, $request );
611 };
612 } else {
613 $failHandler = static function () {
614 return false;
615 };
616 }
617
618 $newParams = [];
619
620 if ( $blob !== false ) {
621 // Double check: blob must be an array, if it's saved at all
622 if ( !is_array( $blob ) ) {
623 $this->logger->warning( 'Session "{session}": Bad data', [
624 'session' => $info->__toString(),
625 ] );
626 $this->store->delete( $key );
627 return $failHandler();
628 }
629
630 // Double check: blob has data and metadata arrays
631 if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
632 !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
633 ) {
634 $this->logger->warning( 'Session "{session}": Bad data structure', [
635 'session' => $info->__toString(),
636 ] );
637 $this->store->delete( $key );
638 return $failHandler();
639 }
640
641 $data = $blob['data'];
642 $metadata = $blob['metadata'];
643
644 // Double check: metadata must be an array and must contain certain
645 // keys, if it's saved at all
646 if ( !array_key_exists( 'userId', $metadata ) ||
647 !array_key_exists( 'userName', $metadata ) ||
648 !array_key_exists( 'userToken', $metadata ) ||
649 !array_key_exists( 'provider', $metadata )
650 ) {
651 $this->logger->warning( 'Session "{session}": Bad metadata', [
652 'session' => $info->__toString(),
653 ] );
654 $this->store->delete( $key );
655 return $failHandler();
656 }
657
658 // First, load the provider from metadata, or validate it against the metadata.
659 $provider = $info->getProvider();
660 if ( $provider === null ) {
661 $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
662 if ( !$provider ) {
663 $this->logger->warning(
664 'Session "{session}": Unknown provider ' . $metadata['provider'],
665 [
666 'session' => $info->__toString(),
667 ]
668 );
669 $this->store->delete( $key );
670 return $failHandler();
671 }
672 } elseif ( $metadata['provider'] !== (string)$provider ) {
673 $this->logger->warning( 'Session "{session}": Wrong provider ' .
674 $metadata['provider'] . ' !== ' . $provider,
675 [
676 'session' => $info->__toString(),
677 ] );
678 return $failHandler();
679 }
680
681 // Load provider metadata from metadata, or validate it against the metadata
682 $providerMetadata = $info->getProviderMetadata();
683 if ( isset( $metadata['providerMetadata'] ) ) {
684 if ( $providerMetadata === null ) {
685 $newParams['metadata'] = $metadata['providerMetadata'];
686 } else {
687 try {
688 $newProviderMetadata = $provider->mergeMetadata(
689 $metadata['providerMetadata'], $providerMetadata
690 );
691 if ( $newProviderMetadata !== $providerMetadata ) {
692 $newParams['metadata'] = $newProviderMetadata;
693 }
694 } catch ( MetadataMergeException $ex ) {
695 $this->logger->warning(
696 'Session "{session}": Metadata merge failed: {exception}',
697 [
698 'session' => $info->__toString(),
699 'exception' => $ex,
700 ] + $ex->getContext()
701 );
702 return $failHandler();
703 }
704 }
705 }
706
707 // Next, load the user from metadata, or validate it against the metadata.
708 $userInfo = $info->getUserInfo();
709 if ( !$userInfo ) {
710 // For loading, id is preferred to name.
711 try {
712 if ( $metadata['userId'] ) {
713 $userInfo = UserInfo::newFromId( $metadata['userId'] );
714 } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
715 $userInfo = UserInfo::newFromName( $metadata['userName'] );
716 } else {
717 $userInfo = UserInfo::newAnonymous();
718 }
719 } catch ( \InvalidArgumentException $ex ) {
720 $this->logger->error( 'Session "{session}": {exception}', [
721 'session' => $info->__toString(),
722 'exception' => $ex,
723 ] );
724 return $failHandler();
725 }
726 $newParams['userInfo'] = $userInfo;
727 } else {
728 // User validation passes if user ID matches, or if there
729 // is no saved ID and the names match.
730 if ( $metadata['userId'] ) {
731 if ( $metadata['userId'] !== $userInfo->getId() ) {
732 $this->logger->warning(
733 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
734 [
735 'session' => $info->__toString(),
736 'uid_a' => $metadata['userId'],
737 'uid_b' => $userInfo->getId(),
738 ] );
739 return $failHandler();
740 }
741
742 // If the user was renamed, probably best to fail here.
743 if ( $metadata['userName'] !== null &&
744 $userInfo->getName() !== $metadata['userName']
745 ) {
746 $this->logger->warning(
747 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
748 [
749 'session' => $info->__toString(),
750 'uname_a' => $metadata['userName'],
751 'uname_b' => $userInfo->getName(),
752 ] );
753 return $failHandler();
754 }
755
756 } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
757 if ( $metadata['userName'] !== $userInfo->getName() ) {
758 $this->logger->warning(
759 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
760 [
761 'session' => $info->__toString(),
762 'uname_a' => $metadata['userName'],
763 'uname_b' => $userInfo->getName(),
764 ] );
765 return $failHandler();
766 }
767 } elseif ( !$userInfo->isAnon() ) {
768 // Metadata specifies an anonymous user, but the passed-in
769 // user isn't anonymous.
770 $this->logger->warning(
771 'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
772 [
773 'session' => $info->__toString(),
774 ] );
775 return $failHandler();
776 }
777 }
778
779 // And if we have a token in the metadata, it must match the loaded/provided user.
780 if ( $metadata['userToken'] !== null &&
781 $userInfo->getToken() !== $metadata['userToken']
782 ) {
783 $this->logger->warning( 'Session "{session}": User token mismatch', [
784 'session' => $info->__toString(),
785 ] );
786 return $failHandler();
787 }
788 if ( !$userInfo->isVerified() ) {
789 $newParams['userInfo'] = $userInfo->verified();
790 }
791
792 if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
793 $newParams['remembered'] = true;
794 }
795 if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
796 $newParams['forceHTTPS'] = true;
797 }
798 if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
799 $newParams['persisted'] = true;
800 }
801
802 if ( !$info->isIdSafe() ) {
803 $newParams['idIsSafe'] = true;
804 }
805 } else {
806 // No metadata, so we can't load the provider if one wasn't given.
807 if ( $info->getProvider() === null ) {
808 $this->logger->warning(
809 'Session "{session}": Null provider and no metadata',
810 [
811 'session' => $info->__toString(),
812 ] );
813 return $failHandler();
814 }
815
816 // If no user was provided and no metadata, it must be anon.
817 if ( !$info->getUserInfo() ) {
818 if ( $info->getProvider()->canChangeUser() ) {
819 $newParams['userInfo'] = UserInfo::newAnonymous();
820 } else {
821 $this->logger->info(
822 'Session "{session}": No user provided and provider cannot set user',
823 [
824 'session' => $info->__toString(),
825 ] );
826 return $failHandler();
827 }
828 } elseif ( !$info->getUserInfo()->isVerified() ) {
829 // probably just a session timeout
830 $this->logger->info(
831 'Session "{session}": Unverified user provided and no metadata to auth it',
832 [
833 'session' => $info->__toString(),
834 ] );
835 return $failHandler();
836 }
837
838 $data = false;
839 $metadata = false;
840
841 if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
842 // The ID doesn't come from the user, so it should be safe
843 // (and if not, nothing we can do about it anyway)
844 $newParams['idIsSafe'] = true;
845 }
846 }
847
848 // Construct the replacement SessionInfo, if necessary
849 if ( $newParams ) {
850 $newParams['copyFrom'] = $info;
851 $info = new SessionInfo( $info->getPriority(), $newParams );
852 }
853
854 // Allow the provider to check the loaded SessionInfo
855 $providerMetadata = $info->getProviderMetadata();
856 if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
857 return $failHandler();
858 }
859 if ( $providerMetadata !== $info->getProviderMetadata() ) {
860 $info = new SessionInfo( $info->getPriority(), [
861 'metadata' => $providerMetadata,
862 'copyFrom' => $info,
863 ] );
864 }
865
866 // Give hooks a chance to abort. Combined with the SessionMetadata
867 // hook, this can allow for tying a session to an IP address or the
868 // like.
869 $reason = 'Hook aborted';
870 if ( !$this->hookRunner->onSessionCheckInfo(
871 $reason, $info, $request, $metadata, $data )
872 ) {
873 $this->logger->warning( 'Session "{session}": ' . $reason, [
874 'session' => $info->__toString(),
875 ] );
876 return $failHandler();
877 }
878
879 return true;
880 }
881
890 public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
891 // @codeCoverageIgnoreStart
892 if ( defined( 'MW_NO_SESSION' ) ) {
893 $ep = defined( 'MW_ENTRY_POINT' ) ? MW_ENTRY_POINT : 'this';
894
895 if ( MW_NO_SESSION === 'warn' ) {
896 // Undocumented safety case for converting existing entry points
897 $this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
898 'exception' => new \BadMethodCallException( "Sessions are disabled for $ep entry point" ),
899 ] );
900 } else {
901 throw new \BadMethodCallException( "Sessions are disabled for $ep entry point" );
902 }
903 }
904 // @codeCoverageIgnoreEnd
905
906 $id = $info->getId();
907
908 if ( !isset( $this->allSessionBackends[$id] ) ) {
909 if ( !isset( $this->allSessionIds[$id] ) ) {
910 $this->allSessionIds[$id] = new SessionId( $id );
911 }
912 $backend = new SessionBackend(
913 $this->allSessionIds[$id],
914 $info,
915 $this->store,
916 $this->logger,
917 $this->hookContainer,
919 );
920 $this->allSessionBackends[$id] = $backend;
921 $delay = $backend->delaySave();
922 } else {
923 $backend = $this->allSessionBackends[$id];
924 $delay = $backend->delaySave();
925 if ( $info->wasPersisted() ) {
926 $backend->persist();
927 }
928 if ( $info->wasRemembered() ) {
929 $backend->setRememberUser( true );
930 }
931 }
932
933 $request->setSessionId( $backend->getSessionId() );
934 $session = $backend->getSession( $request );
935
936 if ( !$info->isIdSafe() ) {
937 $session->resetId();
938 }
939
940 \Wikimedia\ScopedCallback::consume( $delay );
941 return $session;
942 }
943
949 public function deregisterSessionBackend( SessionBackend $backend ) {
950 $id = $backend->getId();
951 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
952 $this->allSessionBackends[$id] !== $backend ||
953 $this->allSessionIds[$id] !== $backend->getSessionId()
954 ) {
955 throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
956 }
957
958 unset( $this->allSessionBackends[$id] );
959 // Explicitly do not unset $this->allSessionIds[$id]
960 }
961
967 public function changeBackendId( SessionBackend $backend ) {
968 $sessionId = $backend->getSessionId();
969 $oldId = (string)$sessionId;
970 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
971 $this->allSessionBackends[$oldId] !== $backend ||
972 $this->allSessionIds[$oldId] !== $sessionId
973 ) {
974 throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
975 }
976
977 $newId = $this->generateSessionId();
978
979 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
980 $sessionId->setId( $newId );
981 $this->allSessionBackends[$newId] = $backend;
982 $this->allSessionIds[$newId] = $sessionId;
983 }
984
989 public function generateSessionId() {
990 $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
991 // Cache non-existence to avoid a later fetch
992 $key = $this->store->makeKey( 'MWSession', $id );
993 $this->store->set( $key, false, 0, BagOStuff::WRITE_CACHE_ONLY );
994 return $id;
995 }
996
1002 public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
1003 $handler->setManager( $this, $this->store, $this->logger );
1004 }
1005
1011 public static function resetCache() {
1012 if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
1013 // @codeCoverageIgnoreStart
1014 throw new LogicException( __METHOD__ . ' may only be called from unit tests!' );
1015 // @codeCoverageIgnoreEnd
1016 }
1017
1018 self::$globalSession = null;
1019 self::$globalSessionRequest = null;
1020 }
1021
1022 private function logUnpersist( SessionInfo $info, WebRequest $request ) {
1023 $logData = [
1024 'id' => $info->getId(),
1025 'provider' => get_class( $info->getProvider() ),
1026 'user' => '<anon>',
1027 'clientip' => $request->getIP(),
1028 'userAgent' => $request->getHeader( 'user-agent' ),
1029 ];
1030 if ( $info->getUserInfo() ) {
1031 if ( !$info->getUserInfo()->isAnon() ) {
1032 $logData['user'] = $info->getUserInfo()->getName();
1033 }
1034 $logData['userVerified'] = $info->getUserInfo()->isVerified();
1035 }
1036 $this->logger->info( 'Failed to load session, unpersisting', $logData );
1037 }
1038
1049 public function logPotentialSessionLeakage( Session $session = null ) {
1050 $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
1051 $session = $session ?: self::getGlobalSession();
1052 $suspiciousIpExpiry = $this->config->get( MainConfigNames::SuspiciousIpExpiry );
1053
1054 if ( $suspiciousIpExpiry === false
1055 // We only care about logged-in users.
1056 || !$session->isPersistent() || $session->getUser()->isAnon()
1057 // We only care about cookie-based sessions.
1058 || !( $session->getProvider() instanceof CookieSessionProvider )
1059 ) {
1060 return;
1061 }
1062 try {
1063 $ip = $session->getRequest()->getIP();
1064 } catch ( MWException $e ) {
1065 return;
1066 }
1067 if ( $ip === '127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1068 return;
1069 }
1070 $mwuser = $session->getRequest()->getCookie( 'mwuser-sessionId' );
1071 $now = (int)\MediaWiki\Utils\MWTimestamp::now( TS_UNIX );
1072
1073 // Record (and possibly log) that the IP is using the current session.
1074 // Don't touch the stored data unless we are changing the IP or re-adding an expired one.
1075 // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
1076 // extended) but that shouldn't make much difference and limits the session write frequency.
1077 $data = $session->get( 'SessionManager-logPotentialSessionLeakage', [] )
1078 + [ 'ip' => null, 'mwuser' => null, 'timestamp' => 0 ];
1079 // Ignore old IP records; users change networks over time. mwuser is a session cookie and the
1080 // SessionManager session id is also a session cookie so there shouldn't be any problem there.
1081 if ( $data['ip'] &&
1082 ( $now - $data['timestamp'] > $suspiciousIpExpiry )
1083 ) {
1084 $data['ip'] = $data['timestamp'] = null;
1085 }
1086
1087 if ( $data['ip'] !== $ip || $data['mwuser'] !== $mwuser ) {
1088 $session->set( 'SessionManager-logPotentialSessionLeakage',
1089 [ 'ip' => $ip, 'mwuser' => $mwuser, 'timestamp' => $now ] );
1090 }
1091
1092 $ipChanged = ( $data['ip'] && $data['ip'] !== $ip );
1093 $mwuserChanged = ( $data['mwuser'] && $data['mwuser'] !== $mwuser );
1094 $logLevel = $message = null;
1095 $logData = [];
1096 // IPs change all the time. mwuser is a session cookie that's only set when missing,
1097 // so it should only change when the browser session ends which ends the SessionManager
1098 // session as well. Unless we are dealing with a very weird client, such as a bot that
1099 //manipulates cookies and can run Javascript, it should not change.
1100 // IP and mwuser changing at the same time would be *very* suspicious.
1101 if ( $ipChanged ) {
1102 $logLevel = LogLevel::INFO;
1103 $message = 'IP change within the same session';
1104 $logData += [
1105 'oldIp' => $data['ip'],
1106 'oldIpRecorded' => $data['timestamp'],
1107 ];
1108 }
1109 if ( $mwuserChanged ) {
1110 $logLevel = LogLevel::NOTICE;
1111 $message = 'mwuser change within the same session';
1112 $logData += [
1113 'oldMwuser' => $data['mwuser'],
1114 'newMwuser' => $mwuser,
1115 ];
1116 }
1117 if ( $ipChanged && $mwuserChanged ) {
1118 $logLevel = LogLevel::WARNING;
1119 $message = 'IP and mwuser change within the same session';
1120 }
1121 if ( $logLevel ) {
1122 $logData += [
1123 'session' => $session->getId(),
1124 'user' => $session->getUser()->getName(),
1125 'clientip' => $ip,
1126 'userAgent' => $session->getRequest()->getHeader( 'user-agent' ),
1127 ];
1128 $logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'session-ip' );
1129 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable message is set when used here
1130 $logger->log( $logLevel, $message, $logData );
1131 }
1132 }
1133
1134 // endregion -- end of Internal methods
1135
1136}
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.
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.
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