MediaWiki REL1_37
SessionManager.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Session;
25
26use BagOStuff;
28use Config;
29use FauxRequest;
34use MWException;
35use Psr\Log\LoggerInterface;
36use Psr\Log\LogLevel;
37use User;
38use WebRequest;
39use Wikimedia\ObjectFactory;
40
85 private static $instance = null;
86
88 private static $globalSession = null;
89
91 private static $globalSessionRequest = null;
92
94 private $logger;
95
98
100 private $hookRunner;
101
103 private $config;
104
107
109 private $store;
110
112 private $sessionProviders = null;
113
115 private $varyCookies = null;
116
118 private $varyHeaders = null;
119
122
124 private $allSessionIds = [];
125
127 private $preventUsers = [];
128
133 public static function singleton() {
134 if ( self::$instance === null ) {
135 self::$instance = new self();
136 }
137 return self::$instance;
138 }
139
146 public static function getGlobalSession(): Session {
147 if ( !PHPSessionHandler::isEnabled() ) {
148 $id = '';
149 } else {
150 $id = session_id();
151 }
152
153 $request = \RequestContext::getMain()->getRequest();
154 if (
155 !self::$globalSession // No global session is set up yet
156 || self::$globalSessionRequest !== $request // The global WebRequest changed
157 || $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
158 ) {
159 self::$globalSessionRequest = $request;
160 if ( $id === '' ) {
161 // session_id() wasn't used, so fetch the Session from the WebRequest.
162 // We use $request->getSession() instead of $singleton->getSessionForRequest()
163 // because doing the latter would require a public
164 // "$request->getSessionId()" method that would confuse end
165 // users by returning SessionId|null where they'd expect it to
166 // be short for $request->getSession()->getId(), and would
167 // wind up being a duplicate of the code in
168 // $request->getSession() anyway.
169 self::$globalSession = $request->getSession();
170 } else {
171 // Someone used session_id(), so we need to follow suit.
172 // Note this overwrites whatever session might already be
173 // associated with $request with the one for $id.
174 self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
175 ?: $request->getSession();
176 }
177 }
179 }
180
187 public function __construct( $options = [] ) {
188 if ( isset( $options['config'] ) ) {
189 $this->config = $options['config'];
190 if ( !$this->config instanceof Config ) {
191 throw new \InvalidArgumentException(
192 '$options[\'config\'] must be an instance of Config'
193 );
194 }
195 } else {
196 $this->config = MediaWikiServices::getInstance()->getMainConfig();
197 }
198
199 if ( isset( $options['logger'] ) ) {
200 if ( !$options['logger'] instanceof LoggerInterface ) {
201 throw new \InvalidArgumentException(
202 '$options[\'logger\'] must be an instance of LoggerInterface'
203 );
204 }
205 $this->setLogger( $options['logger'] );
206 } else {
207 $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
208 }
209
210 if ( isset( $options['hookContainer'] ) ) {
211 $this->setHookContainer( $options['hookContainer'] );
212 } else {
213 $this->setHookContainer( MediaWikiServices::getInstance()->getHookContainer() );
214 }
215
216 if ( isset( $options['store'] ) ) {
217 if ( !$options['store'] instanceof BagOStuff ) {
218 throw new \InvalidArgumentException(
219 '$options[\'store\'] must be an instance of BagOStuff'
220 );
221 }
222 $store = $options['store'];
223 } else {
224 $store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
225 }
226
227 $this->logger->debug( 'SessionManager using store ' . get_class( $store ) );
228 $this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
229 $this->userNameUtils = MediawikiServices::getInstance()->getUserNameUtils();
230
231 register_shutdown_function( [ $this, 'shutdown' ] );
232 }
233
234 public function setLogger( LoggerInterface $logger ) {
235 $this->logger = $logger;
236 }
237
242 public function setHookContainer( HookContainer $hookContainer ) {
243 $this->hookContainer = $hookContainer;
244 $this->hookRunner = new HookRunner( $hookContainer );
245 }
246
247 public function getSessionForRequest( WebRequest $request ) {
248 $info = $this->getSessionInfoForRequest( $request );
249
250 if ( !$info ) {
251 $session = $this->getEmptySession( $request );
252 } else {
253 $session = $this->getSessionFromInfo( $info, $request );
254 }
255 return $session;
256 }
257
258 public function getSessionById( $id, $create = false, WebRequest $request = null ) {
259 if ( !self::validateSessionId( $id ) ) {
260 throw new \InvalidArgumentException( 'Invalid session ID' );
261 }
262 if ( !$request ) {
263 $request = new FauxRequest;
264 }
265
266 $session = null;
267 $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => $id, 'idIsSafe' => true ] );
268
269 // If we already have the backend loaded, use it directly
270 if ( isset( $this->allSessionBackends[$id] ) ) {
271 return $this->getSessionFromInfo( $info, $request );
272 }
273
274 // Test if the session is in storage, and if so try to load it.
275 $key = $this->store->makeKey( 'MWSession', $id );
276 if ( is_array( $this->store->get( $key ) ) ) {
277 $create = false; // If loading fails, don't bother creating because it probably will fail too.
278 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
279 $session = $this->getSessionFromInfo( $info, $request );
280 }
281 }
282
283 if ( $create && $session === null ) {
284 $ex = null;
285 try {
286 $session = $this->getEmptySessionInternal( $request, $id );
287 } catch ( \Exception $ex ) {
288 $this->logger->error( 'Failed to create empty session: {exception}',
289 [
290 'method' => __METHOD__,
291 'exception' => $ex,
292 ] );
293 $session = null;
294 }
295 }
296
297 return $session;
298 }
299
300 public function getEmptySession( WebRequest $request = null ) {
301 return $this->getEmptySessionInternal( $request );
302 }
303
310 private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
311 if ( $id !== null ) {
312 if ( !self::validateSessionId( $id ) ) {
313 throw new \InvalidArgumentException( 'Invalid session ID' );
314 }
315
316 $key = $this->store->makeKey( 'MWSession', $id );
317 if ( is_array( $this->store->get( $key ) ) ) {
318 throw new \InvalidArgumentException( 'Session ID already exists' );
319 }
320 }
321 if ( !$request ) {
322 $request = new FauxRequest;
323 }
324
325 $infos = [];
326 foreach ( $this->getProviders() as $provider ) {
327 $info = $provider->newSessionInfo( $id );
328 if ( !$info ) {
329 continue;
330 }
331 if ( $info->getProvider() !== $provider ) {
332 throw new \UnexpectedValueException(
333 "$provider returned an empty session info for a different provider: $info"
334 );
335 }
336 if ( $id !== null && $info->getId() !== $id ) {
337 throw new \UnexpectedValueException(
338 "$provider returned empty session info with a wrong id: " .
339 $info->getId() . ' != ' . $id
340 );
341 }
342 if ( !$info->isIdSafe() ) {
343 throw new \UnexpectedValueException(
344 "$provider returned empty session info with id flagged unsafe"
345 );
346 }
347 $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
348 if ( $compare > 0 ) {
349 continue;
350 }
351 if ( $compare === 0 ) {
352 $infos[] = $info;
353 } else {
354 $infos = [ $info ];
355 }
356 }
357
358 // Make sure there's exactly one
359 if ( count( $infos ) > 1 ) {
360 throw new \UnexpectedValueException(
361 'Multiple empty sessions tied for top priority: ' . implode( ', ', $infos )
362 );
363 } elseif ( count( $infos ) < 1 ) {
364 throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
365 }
366
367 return $this->getSessionFromInfo( $infos[0], $request );
368 }
369
370 public function invalidateSessionsForUser( User $user ) {
371 $user->setToken();
372 $user->saveSettings();
373
374 foreach ( $this->getProviders() as $provider ) {
375 $provider->invalidateSessionsForUser( $user );
376 }
377 }
378
379 public function getVaryHeaders() {
380 // @codeCoverageIgnoreStart
381 if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
382 return [];
383 }
384 // @codeCoverageIgnoreEnd
385 if ( $this->varyHeaders === null ) {
386 $headers = [];
387 foreach ( $this->getProviders() as $provider ) {
388 foreach ( $provider->getVaryHeaders() as $header => $options ) {
389 # Note that the $options value returned has been deprecated
390 # and is ignored.
391 $headers[$header] = null;
392 }
393 }
394 $this->varyHeaders = $headers;
395 }
396 return $this->varyHeaders;
397 }
398
399 public function getVaryCookies() {
400 // @codeCoverageIgnoreStart
401 if ( defined( 'MW_NO_SESSION' ) && MW_NO_SESSION !== 'warn' ) {
402 return [];
403 }
404 // @codeCoverageIgnoreEnd
405 if ( $this->varyCookies === null ) {
406 $cookies = [];
407 foreach ( $this->getProviders() as $provider ) {
408 $cookies = array_merge( $cookies, $provider->getVaryCookies() );
409 }
410 $this->varyCookies = array_values( array_unique( $cookies ) );
411 }
412 return $this->varyCookies;
413 }
414
420 public static function validateSessionId( $id ) {
421 return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
422 }
423
424 /***************************************************************************/
425 // region Internal methods
437 public function preventSessionsForUser( $username ) {
438 $this->preventUsers[$username] = true;
439
440 // Instruct the session providers to kill any other sessions too.
441 foreach ( $this->getProviders() as $provider ) {
442 $provider->preventSessionsForUser( $username );
443 }
444 }
445
452 public function isUserSessionPrevented( $username ) {
453 return !empty( $this->preventUsers[$username] );
454 }
455
460 protected function getProviders() {
461 if ( $this->sessionProviders === null ) {
462 $this->sessionProviders = [];
463 foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
465 $provider = ObjectFactory::getObjectFromSpec( $spec );
466 $provider->init(
467 $this->logger,
468 $this->config,
469 $this,
470 $this->hookContainer,
471 $this->userNameUtils
472 );
473 if ( isset( $this->sessionProviders[(string)$provider] ) ) {
474 // @phan-suppress-next-line PhanTypeSuspiciousStringExpression
475 throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
476 }
477 $this->sessionProviders[(string)$provider] = $provider;
478 }
479 }
480 return $this->sessionProviders;
481 }
482
493 public function getProvider( $name ) {
494 $providers = $this->getProviders();
495 return $providers[$name] ?? null;
496 }
497
502 public function shutdown() {
503 if ( $this->allSessionBackends ) {
504 $this->logger->debug( 'Saving all sessions on shutdown' );
505 if ( session_id() !== '' ) {
506 // @codeCoverageIgnoreStart
507 session_write_close();
508 }
509 // @codeCoverageIgnoreEnd
510 foreach ( $this->allSessionBackends as $backend ) {
511 $backend->shutdown();
512 }
513 }
514 }
515
521 private function getSessionInfoForRequest( WebRequest $request ) {
522 // Call all providers to fetch "the" session
523 $infos = [];
524 foreach ( $this->getProviders() as $provider ) {
525 $info = $provider->provideSessionInfo( $request );
526 if ( !$info ) {
527 continue;
528 }
529 if ( $info->getProvider() !== $provider ) {
530 throw new \UnexpectedValueException(
531 "$provider returned session info for a different provider: $info"
532 );
533 }
534 $infos[] = $info;
535 }
536
537 // Sort the SessionInfos. Then find the first one that can be
538 // successfully loaded, and then all the ones after it with the same
539 // priority.
540 usort( $infos, [ SessionInfo::class, 'compare' ] );
541 $retInfos = [];
542 while ( $infos ) {
543 $info = array_pop( $infos );
544 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
545 $retInfos[] = $info;
546 while ( $infos ) {
548 $info = array_pop( $infos );
549 if ( SessionInfo::compare( $retInfos[0], $info ) ) {
550 // We hit a lower priority, stop checking.
551 break;
552 }
553 if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
554 // This is going to error out below, but we want to
555 // provide a complete list.
556 $retInfos[] = $info;
557 } else {
558 // Session load failed, so unpersist it from this request
559 $this->logUnpersist( $info, $request );
560 $info->getProvider()->unpersistSession( $request );
561 }
562 }
563 } else {
564 // Session load failed, so unpersist it from this request
565 $this->logUnpersist( $info, $request );
566 $info->getProvider()->unpersistSession( $request );
567 }
568 }
569
570 if ( count( $retInfos ) > 1 ) {
571 throw new SessionOverflowException(
572 $retInfos,
573 'Multiple sessions for this request tied for top priority: ' . implode( ', ', $retInfos )
574 );
575 }
576
577 return $retInfos ? $retInfos[0] : null;
578 }
579
587 private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
588 $key = $this->store->makeKey( 'MWSession', $info->getId() );
589 $blob = $this->store->get( $key );
590
591 // If we got data from the store and the SessionInfo says to force use,
592 // "fail" means to delete the data from the store and retry. Otherwise,
593 // "fail" is just return false.
594 if ( $info->forceUse() && $blob !== false ) {
595 $failHandler = function () use ( $key, &$info, $request ) {
596 $this->store->delete( $key );
597 return $this->loadSessionInfoFromStore( $info, $request );
598 };
599 } else {
600 $failHandler = static function () {
601 return false;
602 };
603 }
604
605 $newParams = [];
606
607 if ( $blob !== false ) {
608 // Sanity check: blob must be an array, if it's saved at all
609 if ( !is_array( $blob ) ) {
610 $this->logger->warning( 'Session "{session}": Bad data', [
611 'session' => $info->__toString(),
612 ] );
613 $this->store->delete( $key );
614 return $failHandler();
615 }
616
617 // Sanity check: blob has data and metadata arrays
618 if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
619 !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
620 ) {
621 $this->logger->warning( 'Session "{session}": Bad data structure', [
622 'session' => $info->__toString(),
623 ] );
624 $this->store->delete( $key );
625 return $failHandler();
626 }
627
628 $data = $blob['data'];
629 $metadata = $blob['metadata'];
630
631 // Sanity check: metadata must be an array and must contain certain
632 // keys, if it's saved at all
633 if ( !array_key_exists( 'userId', $metadata ) ||
634 !array_key_exists( 'userName', $metadata ) ||
635 !array_key_exists( 'userToken', $metadata ) ||
636 !array_key_exists( 'provider', $metadata )
637 ) {
638 $this->logger->warning( 'Session "{session}": Bad metadata', [
639 'session' => $info->__toString(),
640 ] );
641 $this->store->delete( $key );
642 return $failHandler();
643 }
644
645 // First, load the provider from metadata, or validate it against the metadata.
646 $provider = $info->getProvider();
647 if ( $provider === null ) {
648 $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
649 if ( !$provider ) {
650 $this->logger->warning(
651 'Session "{session}": Unknown provider ' . $metadata['provider'],
652 [
653 'session' => $info->__toString(),
654 ]
655 );
656 $this->store->delete( $key );
657 return $failHandler();
658 }
659 } elseif ( $metadata['provider'] !== (string)$provider ) {
660 $this->logger->warning( 'Session "{session}": Wrong provider ' .
661 $metadata['provider'] . ' !== ' . $provider,
662 [
663 'session' => $info->__toString(),
664 ] );
665 return $failHandler();
666 }
667
668 // Load provider metadata from metadata, or validate it against the metadata
669 $providerMetadata = $info->getProviderMetadata();
670 if ( isset( $metadata['providerMetadata'] ) ) {
671 if ( $providerMetadata === null ) {
672 $newParams['metadata'] = $metadata['providerMetadata'];
673 } else {
674 try {
675 $newProviderMetadata = $provider->mergeMetadata(
676 $metadata['providerMetadata'], $providerMetadata
677 );
678 if ( $newProviderMetadata !== $providerMetadata ) {
679 $newParams['metadata'] = $newProviderMetadata;
680 }
681 } catch ( MetadataMergeException $ex ) {
682 $this->logger->warning(
683 'Session "{session}": Metadata merge failed: {exception}',
684 [
685 'session' => $info->__toString(),
686 'exception' => $ex,
687 ] + $ex->getContext()
688 );
689 return $failHandler();
690 }
691 }
692 }
693
694 // Next, load the user from metadata, or validate it against the metadata.
695 $userInfo = $info->getUserInfo();
696 if ( !$userInfo ) {
697 // For loading, id is preferred to name.
698 try {
699 if ( $metadata['userId'] ) {
700 $userInfo = UserInfo::newFromId( $metadata['userId'] );
701 } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
702 $userInfo = UserInfo::newFromName( $metadata['userName'] );
703 } else {
704 $userInfo = UserInfo::newAnonymous();
705 }
706 } catch ( \InvalidArgumentException $ex ) {
707 $this->logger->error( 'Session "{session}": {exception}', [
708 'session' => $info->__toString(),
709 'exception' => $ex,
710 ] );
711 return $failHandler();
712 }
713 $newParams['userInfo'] = $userInfo;
714 } else {
715 // User validation passes if user ID matches, or if there
716 // is no saved ID and the names match.
717 if ( $metadata['userId'] ) {
718 if ( $metadata['userId'] !== $userInfo->getId() ) {
719 $this->logger->warning(
720 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
721 [
722 'session' => $info->__toString(),
723 'uid_a' => $metadata['userId'],
724 'uid_b' => $userInfo->getId(),
725 ] );
726 return $failHandler();
727 }
728
729 // If the user was renamed, probably best to fail here.
730 if ( $metadata['userName'] !== null &&
731 $userInfo->getName() !== $metadata['userName']
732 ) {
733 $this->logger->warning(
734 'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
735 [
736 'session' => $info->__toString(),
737 'uname_a' => $metadata['userName'],
738 'uname_b' => $userInfo->getName(),
739 ] );
740 return $failHandler();
741 }
742
743 } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
744 if ( $metadata['userName'] !== $userInfo->getName() ) {
745 $this->logger->warning(
746 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
747 [
748 'session' => $info->__toString(),
749 'uname_a' => $metadata['userName'],
750 'uname_b' => $userInfo->getName(),
751 ] );
752 return $failHandler();
753 }
754 } elseif ( !$userInfo->isAnon() ) {
755 // Metadata specifies an anonymous user, but the passed-in
756 // user isn't anonymous.
757 $this->logger->warning(
758 'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
759 [
760 'session' => $info->__toString(),
761 ] );
762 return $failHandler();
763 }
764 }
765
766 // And if we have a token in the metadata, it must match the loaded/provided user.
767 if ( $metadata['userToken'] !== null &&
768 $userInfo->getToken() !== $metadata['userToken']
769 ) {
770 $this->logger->warning( 'Session "{session}": User token mismatch', [
771 'session' => $info->__toString(),
772 ] );
773 return $failHandler();
774 }
775 if ( !$userInfo->isVerified() ) {
776 $newParams['userInfo'] = $userInfo->verified();
777 }
778
779 if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
780 $newParams['remembered'] = true;
781 }
782 if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
783 $newParams['forceHTTPS'] = true;
784 }
785 if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
786 $newParams['persisted'] = true;
787 }
788
789 if ( !$info->isIdSafe() ) {
790 $newParams['idIsSafe'] = true;
791 }
792 } else {
793 // No metadata, so we can't load the provider if one wasn't given.
794 if ( $info->getProvider() === null ) {
795 $this->logger->warning(
796 'Session "{session}": Null provider and no metadata',
797 [
798 'session' => $info->__toString(),
799 ] );
800 return $failHandler();
801 }
802
803 // If no user was provided and no metadata, it must be anon.
804 if ( !$info->getUserInfo() ) {
805 if ( $info->getProvider()->canChangeUser() ) {
806 $newParams['userInfo'] = UserInfo::newAnonymous();
807 } else {
808 $this->logger->info(
809 'Session "{session}": No user provided and provider cannot set user',
810 [
811 'session' => $info->__toString(),
812 ] );
813 return $failHandler();
814 }
815 } elseif ( !$info->getUserInfo()->isVerified() ) {
816 // probably just a session timeout
817 $this->logger->info(
818 'Session "{session}": Unverified user provided and no metadata to auth it',
819 [
820 'session' => $info->__toString(),
821 ] );
822 return $failHandler();
823 }
824
825 $data = false;
826 $metadata = false;
827
828 if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
829 // The ID doesn't come from the user, so it should be safe
830 // (and if not, nothing we can do about it anyway)
831 $newParams['idIsSafe'] = true;
832 }
833 }
834
835 // Construct the replacement SessionInfo, if necessary
836 if ( $newParams ) {
837 $newParams['copyFrom'] = $info;
838 $info = new SessionInfo( $info->getPriority(), $newParams );
839 }
840
841 // Allow the provider to check the loaded SessionInfo
842 $providerMetadata = $info->getProviderMetadata();
843 if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
844 return $failHandler();
845 }
846 if ( $providerMetadata !== $info->getProviderMetadata() ) {
847 $info = new SessionInfo( $info->getPriority(), [
848 'metadata' => $providerMetadata,
849 'copyFrom' => $info,
850 ] );
851 }
852
853 // Give hooks a chance to abort. Combined with the SessionMetadata
854 // hook, this can allow for tying a session to an IP address or the
855 // like.
856 $reason = 'Hook aborted';
857 if ( !$this->hookRunner->onSessionCheckInfo(
858 $reason, $info, $request, $metadata, $data )
859 ) {
860 $this->logger->warning( 'Session "{session}": ' . $reason, [
861 'session' => $info->__toString(),
862 ] );
863 return $failHandler();
864 }
865
866 return true;
867 }
868
877 public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
878 // @codeCoverageIgnoreStart
879 if ( defined( 'MW_NO_SESSION' ) ) {
880 $ep = defined( 'MW_ENTRY_POINT' ) ? MW_ENTRY_POINT : 'this';
881
882 if ( MW_NO_SESSION === 'warn' ) {
883 // Undocumented safety case for converting existing entry points
884 $this->logger->error( 'Sessions are supposed to be disabled for this entry point', [
885 'exception' => new \BadMethodCallException( "Sessions are disabled for $ep entry point" ),
886 ] );
887 } else {
888 throw new \BadMethodCallException( "Sessions are disabled for $ep entry point" );
889 }
890 }
891 // @codeCoverageIgnoreEnd
892
893 $id = $info->getId();
894
895 if ( !isset( $this->allSessionBackends[$id] ) ) {
896 if ( !isset( $this->allSessionIds[$id] ) ) {
897 $this->allSessionIds[$id] = new SessionId( $id );
898 }
899 $backend = new SessionBackend(
900 $this->allSessionIds[$id],
901 $info,
902 $this->store,
903 $this->logger,
904 $this->hookContainer,
905 $this->config->get( 'ObjectCacheSessionExpiry' )
906 );
907 $this->allSessionBackends[$id] = $backend;
908 $delay = $backend->delaySave();
909 } else {
910 $backend = $this->allSessionBackends[$id];
911 $delay = $backend->delaySave();
912 if ( $info->wasPersisted() ) {
913 $backend->persist();
914 }
915 if ( $info->wasRemembered() ) {
916 $backend->setRememberUser( true );
917 }
918 }
919
920 $request->setSessionId( $backend->getSessionId() );
921 $session = $backend->getSession( $request );
922
923 if ( !$info->isIdSafe() ) {
924 $session->resetId();
925 }
926
927 \Wikimedia\ScopedCallback::consume( $delay );
928 return $session;
929 }
930
936 public function deregisterSessionBackend( SessionBackend $backend ) {
937 $id = $backend->getId();
938 if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
939 $this->allSessionBackends[$id] !== $backend ||
940 $this->allSessionIds[$id] !== $backend->getSessionId()
941 ) {
942 throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
943 }
944
945 unset( $this->allSessionBackends[$id] );
946 // Explicitly do not unset $this->allSessionIds[$id]
947 }
948
954 public function changeBackendId( SessionBackend $backend ) {
955 $sessionId = $backend->getSessionId();
956 $oldId = (string)$sessionId;
957 if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
958 $this->allSessionBackends[$oldId] !== $backend ||
959 $this->allSessionIds[$oldId] !== $sessionId
960 ) {
961 throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
962 }
963
964 $newId = $this->generateSessionId();
965
966 unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
967 $sessionId->setId( $newId );
968 $this->allSessionBackends[$newId] = $backend;
969 $this->allSessionIds[$newId] = $sessionId;
970 }
971
976 public function generateSessionId() {
977 do {
978 $id = \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
979 $key = $this->store->makeKey( 'MWSession', $id );
980 } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
981 return $id;
982 }
983
989 public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
990 $handler->setManager( $this, $this->store, $this->logger );
991 }
992
998 public static function resetCache() {
999 if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
1000 // @codeCoverageIgnoreStart
1001 throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
1002 // @codeCoverageIgnoreEnd
1003 }
1004
1005 self::$globalSession = null;
1006 self::$globalSessionRequest = null;
1007 }
1008
1009 private function logUnpersist( SessionInfo $info, WebRequest $request ) {
1010 $logData = [
1011 'id' => $info->getId(),
1012 'provider' => get_class( $info->getProvider() ),
1013 'user' => '<anon>',
1014 'clientip' => $request->getIP(),
1015 'userAgent' => $request->getHeader( 'user-agent' ),
1016 ];
1017 if ( $info->getUserInfo() ) {
1018 if ( !$info->getUserInfo()->isAnon() ) {
1019 $logData['user'] = $info->getUserInfo()->getName();
1020 }
1021 $logData['userVerified'] = $info->getUserInfo()->isVerified();
1022 }
1023 $this->logger->info( 'Failed to load session, unpersisting', $logData );
1024 }
1025
1036 public function logPotentialSessionLeakage( Session $session = null ) {
1037 $proxyLookup = MediaWikiServices::getInstance()->getProxyLookup();
1038 $session = $session ?: self::getGlobalSession();
1039 $suspiciousIpExpiry = $this->config->get( 'SuspiciousIpExpiry' );
1040
1041 if ( $suspiciousIpExpiry === false
1042 // We only care about logged-in users.
1043 || !$session->isPersistent() || $session->getUser()->isAnon()
1044 // We only care about cookie-based sessions.
1045 || !( $session->getProvider() instanceof CookieSessionProvider )
1046 ) {
1047 return;
1048 }
1049 try {
1050 $ip = $session->getRequest()->getIP();
1051 } catch ( \MWException $e ) {
1052 return;
1053 }
1054 if ( $ip === '127.0.0.1' || $proxyLookup->isConfiguredProxy( $ip ) ) {
1055 return;
1056 }
1057 $mwuser = $session->getRequest()->getCookie( 'mwuser-sessionId' );
1058 $now = \MWTimestamp::now( TS_UNIX );
1059
1060 // Record (and possibly log) that the IP is using the current session.
1061 // Don't touch the stored data unless we are changing the IP or re-adding an expired one.
1062 // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
1063 // extended) but that shouldn't make much difference and limits the session write frequency.
1064 $data = $session->get( 'SessionManager-logPotentialSessionLeakage', [] )
1065 + [ 'ip' => null, 'mwuser' => null, 'timestamp' => 0 ];
1066 // Ignore old IP records; users change networks over time. mwuser is a session cookie and the
1067 // SessionManager session id is also a session cookie so there shouldn't be any problem there.
1068 if ( $data['ip'] &&
1069 ( $now - $data['timestamp'] > $suspiciousIpExpiry )
1070 ) {
1071 $data['ip'] = $data['timestamp'] = null;
1072 }
1073
1074 if ( $data['ip'] !== $ip || $data['mwuser'] !== $mwuser ) {
1075 $session->set( 'SessionManager-logPotentialSessionLeakage',
1076 [ 'ip' => $ip, 'mwuser' => $mwuser, 'timestamp' => $now ] );
1077 }
1078
1079 $ipChanged = ( $data['ip'] && $data['ip'] !== $ip );
1080 $mwuserChanged = ( $data['mwuser'] && $data['mwuser'] !== $mwuser );
1081 $logLevel = $message = null;
1082 $logData = [];
1083 // IPs change all the time. mwuser is a session cookie that's only set when missing,
1084 // so it should only change when the browser session ends which ends the SessionManager
1085 // session as well. Unless we are dealing with a very weird client, such as a bot that
1086 //manipulates cookies and can run Javascript, it should not change.
1087 // IP and mwuser changing at the same time would be *very* suspicious.
1088 if ( $ipChanged ) {
1089 $logLevel = LogLevel::INFO;
1090 $message = 'IP change within the same session';
1091 $logData += [
1092 'oldIp' => $data['ip'],
1093 'oldIpRecorded' => $data['timestamp'],
1094 ];
1095 }
1096 if ( $mwuserChanged ) {
1097 $logLevel = LogLevel::NOTICE;
1098 $message = 'mwuser change within the same session';
1099 $logData += [
1100 'oldMwuser' => $data['mwuser'],
1101 'newMwuser' => $mwuser,
1102 ];
1103 }
1104 if ( $ipChanged && $mwuserChanged ) {
1105 $logLevel = LogLevel::WARNING;
1106 $message = 'IP and mwuser change within the same session';
1107 }
1108 if ( $logLevel ) {
1109 $logData += [
1110 'session' => $session->getId(),
1111 'user' => $session->getUser()->getName(),
1112 'clientip' => $ip,
1113 'userAgent' => $session->getRequest()->getHeader( 'user-agent' ),
1114 ];
1115 $logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'session-ip' );
1116 $logger->log( $logLevel, $message, $logData );
1117 }
1118 }
1119
1120 // endregion -- end of Internal methods
1121
1122}
if(ini_get('mbstring.func_overload')) if(!defined('MW_ENTRY_POINT'))
Pre-config setup: Before loading LocalSettings.php.
Definition Setup.php:88
const MW_ENTRY_POINT
Definition api.php:41
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:86
Wrapper around a BagOStuff that caches data in memory.
WebRequest clone which takes values from a provided array.
static generateHex( $chars)
Generate a run of cryptographically random data and return it in hexadecimal string format.
MediaWiki exception.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
MediaWikiServices is the service locator for the application scope of MediaWiki.
static getInstance()
Returns the global default instance of the top level service locator.
A CookieSessionProvider persists sessions using cookies.
Subclass of UnexpectedValueException that can be annotated with additional data for debug logging.
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.
forceUse()
Force use of this SessionInfo if validation fails.
getProviderMetadata()
Return provider metadata.
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.
getPriority()
Return the priority.
wasRemembered()
Return whether the user was remembered.
forceHTTPS()
Whether this session should only be used over HTTPS.
static compare( $a, $b)
Compare two SessionInfo objects by priority.
This serves as the entry point to the MediaWiki session handling system.
static resetCache()
Reset the internal caching for unit testing.
getVaryHeaders()
Return the HTTP headers that need varying on.
deregisterSessionBackend(SessionBackend $backend)
Deregister a SessionBackend.
invalidateSessionsForUser(User $user)
Invalidate sessions for a user.
static WebRequest null $globalSessionRequest
loadSessionInfoFromStore(SessionInfo &$info, WebRequest $request)
Load and verify the session info against the store.
setHookContainer(HookContainer $hookContainer)
getVaryCookies()
Return the list of cookies that need varying on.
setupPHPSessionHandler(PHPSessionHandler $handler)
Call setters on a PHPSessionHandler.
getEmptySessionInternal(WebRequest $request=null, $id=null)
static SessionManager null $instance
getEmptySession(WebRequest $request=null)
Create a new, empty session.
logUnpersist(SessionInfo $info, WebRequest $request)
Reset the internal caching for unit testing.
static getGlobalSession()
If PHP's session_id() has been set, returns that session.
getSessionInfoForRequest(WebRequest $request)
Fetch the SessionInfo(s) for a request.
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.
OverflowException specific to the SessionManager, used when the request had multiple possible session...
A SessionProvider provides SessionInfo and support for Session.
init(LoggerInterface $logger, Config $config, SessionManager $manager, HookContainer $hookContainer, UserNameUtils $userNameUtils)
Initialise with dependencies of a SessionProvider.
Manages data for an authenticated session.
Definition Session.php:48
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.
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:69
saveSettings()
Save this user's settings into the database.
Definition User.php:3297
setToken( $token=false)
Set the random token (used for persistent authentication) Called from loadDefaults() among other plac...
Definition User.php:2417
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
setSessionId(SessionId $sessionId)
Set the session for this request.
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.
Interface for configuration instances.
Definition Config.php:30
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