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