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