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