MediaWiki master
SessionManager.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Session;
25
26use InvalidArgumentException;
27use LogicException;
38use MWException;
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 ) {
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->store->delete( $key );
564 return $this->loadSessionInfoFromStore( $info, $request );
565 };
566 } else {
567 $failHandler = static function () {
568 return false;
569 };
570 }
571
572 $newParams = [];
573
574 if ( $blob !== false ) {
575 // Double check: blob must be an array, if it's saved at all
576 if ( !is_array( $blob ) ) {
577 $this->logger->warning( 'Session "{session}": Bad data', [
578 'session' => $info->__toString(),
579 ] );
580 $this->store->delete( $key );
581 return $failHandler();
582 }
583
584 // Double check: blob has data and metadata arrays
585 if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
586 !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
587 ) {
588 $this->logger->warning( 'Session "{session}": Bad data structure', [
589 'session' => $info->__toString(),
590 ] );
591 $this->store->delete( $key );
592 return $failHandler();
593 }
594
595 $data = $blob['data'];
596 $metadata = $blob['metadata'];
597
598 // Double check: metadata must be an array and must contain certain
599 // keys, if it's saved at all
600 if ( !array_key_exists( 'userId', $metadata ) ||
601 !array_key_exists( 'userName', $metadata ) ||
602 !array_key_exists( 'userToken', $metadata ) ||
603 !array_key_exists( 'provider', $metadata )
604 ) {
605 $this->logger->warning( 'Session "{session}": Bad metadata', [
606 'session' => $info->__toString(),
607 ] );
608 $this->store->delete( $key );
609 return $failHandler();
610 }
611
612 // First, load the provider from metadata, or validate it against the metadata.
613 $provider = $info->getProvider();
614 if ( $provider === null ) {
615 $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
616 if ( !$provider ) {
617 $this->logger->warning(
618 'Session "{session}": Unknown provider ' . $metadata['provider'],
619 [
620 'session' => $info->__toString(),
621 ]
622 );
623 $this->store->delete( $key );
624 return $failHandler();
625 }
626 } elseif ( $metadata['provider'] !== (string)$provider ) {
627 $this->logger->warning( 'Session "{session}": Wrong provider ' .
628 $metadata['provider'] . ' !== ' . $provider,
629 [
630 'session' => $info->__toString(),
631 ] );
632 return $failHandler();
633 }
634
635 // Load provider metadata from metadata, or validate it against the metadata
636 $providerMetadata = $info->getProviderMetadata();
637 if ( isset( $metadata['providerMetadata'] ) ) {
638 if ( $providerMetadata === null ) {
639 $newParams['metadata'] = $metadata['providerMetadata'];
640 } else {
641 try {
642 $newProviderMetadata = $provider->mergeMetadata(
643 $metadata['providerMetadata'], $providerMetadata
644 );
645 if ( $newProviderMetadata !== $providerMetadata ) {
646 $newParams['metadata'] = $newProviderMetadata;
647 }
648 } catch ( MetadataMergeException $ex ) {
649 $this->logger->warning(
650 'Session "{session}": Metadata merge failed: {exception}',
651 [
652 'session' => $info->__toString(),
653 'exception' => $ex,
654 ] + $ex->getContext()
655 );
656 return $failHandler();
657 }
658 }
659 }
660
661 // Next, load the user from metadata, or validate it against the metadata.
662 $userInfo = $info->getUserInfo();
663 if ( !$userInfo ) {
664 // For loading, id is preferred to name.
665 try {
666 if ( $metadata['userId'] ) {
667 $userInfo = UserInfo::newFromId( $metadata['userId'] );
668 } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
669 $userInfo = UserInfo::newFromName( $metadata['userName'] );
670 } else {
671 $userInfo = UserInfo::newAnonymous();
672 }
673 } catch ( InvalidArgumentException $ex ) {
674 $this->logger->error( 'Session "{session}": {exception}', [
675 'session' => $info->__toString(),
676 'exception' => $ex,
677 ] );
678 return $failHandler();
679 }
680 $newParams['userInfo'] = $userInfo;
681 } else {
682 // User validation passes if user ID matches, or if there
683 // is no saved ID and the names match.
684 if ( $metadata['userId'] ) {
685 if ( $metadata['userId'] !== $userInfo->getId() ) {
686 // Maybe something like UserMerge changed the user ID. Or it's manual tampering.
687 $this->logger->warning(
688 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
689 [
690 'session' => $info->__toString(),
691 'uid_a' => $metadata['userId'],
692 'uid_b' => $userInfo->getId(),
693 'uname_a' => $metadata['userName'] ?? '<null>',
694 'uname_b' => $userInfo->getName() ?? '<null>',
695 ] );
696 return $failHandler();
697 }
698
699 // If the user was renamed, probably best to fail here.
700 if ( $metadata['userName'] !== null &&
701 $userInfo->getName() !== $metadata['userName']
702 ) {
703 $this->logger->warning(
704 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
705 [
706 'session' => $info->__toString(),
707 'uname_a' => $metadata['userName'],
708 'uname_b' => $userInfo->getName(),
709 ] );
710 return $failHandler();
711 }
712
713 } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
714 if ( $metadata['userName'] !== $userInfo->getName() ) {
715 $this->logger->warning(
716 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
717 [
718 'session' => $info->__toString(),
719 'uname_a' => $metadata['userName'],
720 'uname_b' => $userInfo->getName(),
721 ] );
722 return $failHandler();
723 }
724 } elseif ( !$userInfo->isAnon() ) {
725 // The metadata in the session store entry indicates this is an anonymous session,
726 // but the request metadata (e.g. the username cookie) says otherwise. Maybe the
727 // user logged out but unsetting the cookies failed?
728 $this->logger->warning(
729 'Session "{session}": the session store entry is for an anonymous user, '
730 . 'but the session metadata indicates a non-anonynmous user',
731 [
732 'session' => $info->__toString(),
733 ] );
734 return $failHandler();
735 }
736 }
737
738 // And if we have a token in the metadata, it must match the loaded/provided user.
739 // A mismatch probably means the session was invalidated.
740 if ( $metadata['userToken'] !== null &&
741 $userInfo->getToken() !== $metadata['userToken']
742 ) {
743 $this->logger->warning( 'Session "{session}": User token mismatch', [
744 'session' => $info->__toString(),
745 ] );
746 return $failHandler();
747 }
748 if ( !$userInfo->isVerified() ) {
749 $newParams['userInfo'] = $userInfo->verified();
750 }
751
752 if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
753 $newParams['remembered'] = true;
754 }
755 if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
756 $newParams['forceHTTPS'] = true;
757 }
758 if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
759 $newParams['persisted'] = true;
760 }
761
762 if ( !$info->isIdSafe() ) {
763 $newParams['idIsSafe'] = true;
764 }
765 } else {
766 // No metadata, so we can't load the provider if one wasn't given.
767 if ( $info->getProvider() === null ) {
768 $this->logger->warning(
769 'Session "{session}": Null provider and no metadata',
770 [
771 'session' => $info->__toString(),
772 ] );
773 return $failHandler();
774 }
775
776 // If no user was provided and no metadata, it must be anon.
777 if ( !$info->getUserInfo() ) {
778 if ( $info->getProvider()->canChangeUser() ) {
779 $newParams['userInfo'] = UserInfo::newAnonymous();
780 } else {
781 // This is a session provider bug - providers with canChangeUser() === false
782 // should never return an anonymous SessionInfo.
783 $this->logger->info(
784 'Session "{session}": No user provided and provider cannot set user',
785 [
786 'session' => $info->__toString(),
787 ] );
788 return $failHandler();
789 }
790 } elseif ( !$info->getUserInfo()->isVerified() ) {
791 // The session was not found in the session store, and the request contains no
792 // information beyond the session ID that could be used to verify it.
793 // Probably just a session timeout.
794 $this->logger->info(
795 'Session "{session}": Unverified user provided and no metadata to auth it',
796 [
797 'session' => $info->__toString(),
798 ] );
799 return $failHandler();
800 }
801
802 $data = false;
803 $metadata = false;
804
805 if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
806 // The ID doesn't come from the user, so it should be safe
807 // (and if not, nothing we can do about it anyway)
808 $newParams['idIsSafe'] = true;
809 }
810 }
811
812 // Construct the replacement SessionInfo, if necessary
813 if ( $newParams ) {
814 $newParams['copyFrom'] = $info;
815 $info = new SessionInfo( $info->getPriority(), $newParams );
816 }
817
818 // Allow the provider to check the loaded SessionInfo
819 $providerMetadata = $info->getProviderMetadata();
820 if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
821 return $failHandler();
822 }
823 if ( $providerMetadata !== $info->getProviderMetadata() ) {
824 $info = new SessionInfo( $info->getPriority(), [
825 'metadata' => $providerMetadata,
826 'copyFrom' => $info,
827 ] );
828 }
829
830 // Give hooks a chance to abort. Combined with the SessionMetadata
831 // hook, this can allow for tying a session to an IP address or the
832 // like.
833 $reason = 'Hook aborted';
834 if ( !$this->hookRunner->onSessionCheckInfo(
835 $reason, $info, $request, $metadata, $data )
836 ) {
837 $this->logger->warning( 'Session "{session}": ' . $reason, [
838 'session' => $info->__toString(),
839 ] );
840 return $failHandler();
841 }
842
843 return true;
844 }
845
854 public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
855 // @codeCoverageIgnoreStart
856 if ( defined( 'MW_NO_SESSION' ) ) {
857 $ep = defined( 'MW_ENTRY_POINT' ) ? MW_ENTRY_POINT : 'this';
858
859 if ( MW_NO_SESSION === 'warn' ) {
860 // Undocumented safety case for converting existing entry points
861 $this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
862 'exception' => new \BadMethodCallException( "Sessions are disabled for $ep entry point" ),
863 ] );
864 } else {
865 throw new \BadMethodCallException( "Sessions are disabled for $ep entry point" );
866 }
867 }
868 // @codeCoverageIgnoreEnd
869
870 $id = $info->getId();
871
872 if ( !isset( $this->allSessionBackends[$id] ) ) {
873 if ( !isset( $this->allSessionIds[$id] ) ) {
874 $this->allSessionIds[$id] = new SessionId( $id );
875 }
876 $backend = new SessionBackend(
877 $this->allSessionIds[$id],
878 $info,
879 $this->store,
880 $this->logger,
881 $this->hookContainer,
883 );
884 $this->allSessionBackends[$id] = $backend;
885 $delay = $backend->delaySave();
886 } else {
887 $backend = $this->allSessionBackends[$id];
888 $delay = $backend->delaySave();
889 if ( $info->wasPersisted() ) {
890 $backend->persist();
891 }
892 if ( $info->wasRemembered() ) {
893 $backend->setRememberUser( true );
894 }
895 }
896
897 $request->setSessionId( $backend->getSessionId() );
898 $session = $backend->getSession( $request );
899
900 if ( !$info->isIdSafe() ) {
901 $session->resetId();
902 }
903
904 \Wikimedia\ScopedCallback::consume( $delay );
905 return $session;
906 }
907
913 public function deregisterSessionBackend( SessionBackend $backend ) {
914 $id = $backend->getId();
915 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
916 $this->allSessionBackends[$id] !== $backend ||
917 $this->allSessionIds[$id] !== $backend->getSessionId()
918 ) {
919 throw new InvalidArgumentException( 'Backend was not registered with this SessionManager' );
920 }
921
922 unset( $this->allSessionBackends[$id] );
923 // Explicitly do not unset $this->allSessionIds[$id]
924 }
925
931 public function changeBackendId( SessionBackend $backend ) {
932 $sessionId = $backend->getSessionId();
933 $oldId = (string)$sessionId;
934 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
935 $this->allSessionBackends[$oldId] !== $backend ||
936 $this->allSessionIds[$oldId] !== $sessionId
937 ) {
938 throw new InvalidArgumentException( 'Backend was not registered with this SessionManager' );
939 }
940
941 $newId = $this->generateSessionId();
942
943 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
944 $sessionId->setId( $newId );
945 $this->allSessionBackends[$newId] = $backend;
946 $this->allSessionIds[$newId] = $sessionId;
947 }
948
953 public function generateSessionId() {
954 $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
955 // Cache non-existence to avoid a later fetch
956 $key = $this->store->makeKey( 'MWSession', $id );
957 $this->store->set( $key, false, 0, BagOStuff::WRITE_CACHE_ONLY );
958 return $id;
959 }
960
966 public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
967 $handler->setManager( $this, $this->store, $this->logger );
968 }
969
975 public static function resetCache() {
976 if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
977 // @codeCoverageIgnoreStart
978 throw new LogicException( __METHOD__ . ' may only be called from unit tests!' );
979 // @codeCoverageIgnoreEnd
980 }
981
982 self::$globalSession = null;
983 self::$globalSessionRequest = null;
984 }
985
986 private function logUnpersist( SessionInfo $info, WebRequest $request ) {
987 $logData = [
988 'id' => $info->getId(),
989 'provider' => get_class( $info->getProvider() ),
990 'user' => '<anon>',
991 'supposedUser' => $info->getUserInfo() ? $info->getUserInfo()->getName() : null,
992 'clientip' => $request->getIP(),
993 'userAgent' => $request->getHeader( 'user-agent' ),
994 ];
995 if ( $info->getUserInfo() ) {
996 if ( !$info->getUserInfo()->isAnon() ) {
997 $logData['user'] = $info->getUserInfo()->getName();
998 }
999 $logData['userVerified'] = $info->getUserInfo()->isVerified();
1000 }
1001 $this->logger->info( 'Failed to load session, unpersisting', $logData );
1002 }
1003
1014 public function logPotentialSessionLeakage( ?Session $session = null ) {
1015 $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
1016 $session = $session ?: self::getGlobalSession();
1017 $suspiciousIpExpiry = $this->config->get( MainConfigNames::SuspiciousIpExpiry );
1018
1019 if ( $suspiciousIpExpiry === false
1020 // We only care about logged-in users.
1021 || !$session->isPersistent() || $session->getUser()->isAnon()
1022 // We only care about cookie-based sessions.
1023 || !( $session->getProvider() instanceof CookieSessionProvider )
1024 ) {
1025 return;
1026 }
1027 try {
1028 $ip = $session->getRequest()->getIP();
1029 } catch ( MWException $e ) {
1030 return;
1031 }
1032 if ( $ip === '127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1033 return;
1034 }
1035 $mwuser = $session->getRequest()->getCookie( 'mwuser-sessionId' );
1036 $now = (int)\MediaWiki\Utils\MWTimestamp::now( TS_UNIX );
1037
1038 // Record (and possibly log) that the IP is using the current session.
1039 // Don't touch the stored data unless we are changing the IP or re-adding an expired one.
1040 // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
1041 // extended) but that shouldn't make much difference and limits the session write frequency.
1042 $data = $session->get( 'SessionManager-logPotentialSessionLeakage', [] )
1043 + [ 'ip' => null, 'mwuser' => null, 'timestamp' => 0 ];
1044 // Ignore old IP records; users change networks over time. mwuser is a session cookie and the
1045 // SessionManager session id is also a session cookie so there shouldn't be any problem there.
1046 if ( $data['ip'] &&
1047 ( $now - $data['timestamp'] > $suspiciousIpExpiry )
1048 ) {
1049 $data['ip'] = $data['timestamp'] = null;
1050 }
1051
1052 if ( $data['ip'] !== $ip || $data['mwuser'] !== $mwuser ) {
1053 $session->set( 'SessionManager-logPotentialSessionLeakage',
1054 [ 'ip' => $ip, 'mwuser' => $mwuser, 'timestamp' => $now ] );
1055 }
1056
1057 $ipChanged = ( $data['ip'] && $data['ip'] !== $ip );
1058 $mwuserChanged = ( $data['mwuser'] && $data['mwuser'] !== $mwuser );
1059 $logLevel = $message = null;
1060 $logData = [];
1061 // IPs change all the time. mwuser is a session cookie that's only set when missing,
1062 // so it should only change when the browser session ends which ends the SessionManager
1063 // session as well. Unless we are dealing with a very weird client, such as a bot that
1064 //manipulates cookies and can run Javascript, it should not change.
1065 // IP and mwuser changing at the same time would be *very* suspicious.
1066 if ( $ipChanged ) {
1067 $logLevel = LogLevel::INFO;
1068 $message = 'IP change within the same session';
1069 $logData += [
1070 'oldIp' => $data['ip'],
1071 'oldIpRecorded' => $data['timestamp'],
1072 ];
1073 }
1074 if ( $mwuserChanged ) {
1075 $logLevel = LogLevel::NOTICE;
1076 $message = 'mwuser change within the same session';
1077 $logData += [
1078 'oldMwuser' => $data['mwuser'],
1079 'newMwuser' => $mwuser,
1080 ];
1081 }
1082 if ( $ipChanged && $mwuserChanged ) {
1083 $logLevel = LogLevel::WARNING;
1084 $message = 'IP and mwuser change within the same session';
1085 }
1086 if ( $logLevel ) {
1087 $logData += [
1088 'session' => $session->getId(),
1089 'user' => $session->getUser()->getName(),
1090 'clientip' => $ip,
1091 'userAgent' => $session->getRequest()->getHeader( 'user-agent' ),
1092 ];
1093 $logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'session-ip' );
1094 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable message is set when used here
1095 $logger->log( $logLevel, $message, $logData );
1096 }
1097 }
1098
1099 // endregion -- end of Internal methods
1100
1101}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
const MW_ENTRY_POINT
Definition api.php:35
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.
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
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.
User class for the MediaWiki software.
Definition User.php:119
setToken( $token=false)
Set the random token (used for persistent authentication) Called from loadDefaults() among other plac...
Definition User.php:1893
saveSettings()
Save this user's settings into the database.
Definition User.php:2381
Abstract class for any ephemeral data store.
Definition BagOStuff.php:88
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