MediaWiki REL1_39
SessionBackend.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Session;
25
31use Psr\Log\LoggerInterface;
32use User;
33use WebRequest;
34use Wikimedia\AtEase\AtEase;
35
54final class SessionBackend {
56 private $id;
57
59 private $persist = false;
60
62 private $remember = false;
63
65 private $forceHTTPS = false;
66
68 private $data = null;
69
71 private $forcePersist = false;
72
81 private $persistenceChangeType;
82
87 private $persistenceChangeData = [];
88
90 private $metaDirty = false;
91
93 private $dataDirty = false;
94
96 private $dataHash = null;
97
99 private $store;
100
102 private $logger;
103
105 private $hookRunner;
106
108 private $lifetime;
109
111 private $user;
112
114 private $curIndex = 0;
115
117 private $requests = [];
118
120 private $provider;
121
123 private $providerMetadata = null;
124
126 private $expires = 0;
127
129 private $loggedOut = 0;
130
132 private $delaySave = 0;
133
135 private $usePhpSessionHandling;
137 private $checkPHPSessionRecursionGuard = false;
138
140 private $shutdown = false;
141
150 public function __construct(
151 SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger,
152 HookContainer $hookContainer, $lifetime
153 ) {
154 $phpSessionHandling = MediaWikiServices::getInstance()->getMainConfig()
156 $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
157
158 if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
159 throw new \InvalidArgumentException(
160 "Refusing to create session for unverified user {$info->getUserInfo()}"
161 );
162 }
163 if ( $info->getProvider() === null ) {
164 throw new \InvalidArgumentException( 'Cannot create session without a provider' );
165 }
166 if ( $info->getId() !== $id->getId() ) {
167 throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
168 }
169
170 $this->id = $id;
171 $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
172 $this->store = $store;
173 $this->logger = $logger;
174 $this->hookRunner = new HookRunner( $hookContainer );
175 $this->lifetime = $lifetime;
176 $this->provider = $info->getProvider();
177 $this->persist = $info->wasPersisted();
178 $this->remember = $info->wasRemembered();
179 $this->forceHTTPS = $info->forceHTTPS();
180 $this->providerMetadata = $info->getProviderMetadata();
181
182 $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id ) );
183 if ( !is_array( $blob ) ||
184 !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
185 !isset( $blob['data'] ) || !is_array( $blob['data'] )
186 ) {
187 $this->data = [];
188 $this->dataDirty = true;
189 $this->metaDirty = true;
190 $this->persistenceChangeType = 'no-store';
191 $this->logger->debug(
192 'SessionBackend "{session}" is unsaved, marking dirty in constructor',
193 [
194 'session' => $this->id->__toString(),
195 ] );
196 } else {
197 $this->data = $blob['data'];
198 if ( isset( $blob['metadata']['loggedOut'] ) ) {
199 $this->loggedOut = (int)$blob['metadata']['loggedOut'];
200 }
201 if ( isset( $blob['metadata']['expires'] ) ) {
202 $this->expires = (int)$blob['metadata']['expires'];
203 } else {
204 $this->metaDirty = true;
205 $this->persistenceChangeType = 'no-expiry';
206 $this->logger->debug(
207 'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
208 [
209 'session' => $this->id->__toString(),
210 ] );
211 }
212 }
213 $this->dataHash = md5( serialize( $this->data ) );
214 }
215
221 public function getSession( WebRequest $request ) {
222 $index = ++$this->curIndex;
223 $this->requests[$index] = $request;
224 $session = new Session( $this, $index, $this->logger );
225 return $session;
226 }
227
233 public function deregisterSession( $index ) {
234 unset( $this->requests[$index] );
235 if ( !$this->shutdown && !count( $this->requests ) ) {
236 $this->save( true );
237 $this->provider->getManager()->deregisterSessionBackend( $this );
238 }
239 }
240
245 public function shutdown() {
246 $this->save( true );
247 $this->shutdown = true;
248 }
249
254 public function getId() {
255 return (string)$this->id;
256 }
257
263 public function getSessionId() {
264 return $this->id;
265 }
266
271 public function resetId() {
272 if ( $this->provider->persistsSessionId() ) {
273 $oldId = (string)$this->id;
274 $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
276
277 if ( $restart ) {
278 // If this session is the one behind PHP's $_SESSION, we need
279 // to close then reopen it.
280 session_write_close();
281 }
282
283 $this->provider->getManager()->changeBackendId( $this );
284 $this->provider->sessionIdWasReset( $this, $oldId );
285 $this->metaDirty = true;
286 $this->logger->debug(
287 'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
288 [
289 'session' => $this->id->__toString(),
290 'oldId' => $oldId,
291 ] );
292
293 if ( $restart ) {
294 session_id( (string)$this->id );
295 AtEase::quietCall( 'session_start' );
296 }
297
298 $this->autosave();
299
300 // Delete the data for the old session ID now
301 $this->store->delete( $this->store->makeKey( 'MWSession', $oldId ) );
302 }
303
304 return $this->id;
305 }
306
311 public function getProvider() {
312 return $this->provider;
313 }
314
322 public function isPersistent() {
323 return $this->persist;
324 }
325
332 public function persist() {
333 if ( !$this->persist ) {
334 $this->persist = true;
335 $this->forcePersist = true;
336 $this->metaDirty = true;
337 $this->logger->debug(
338 'SessionBackend "{session}" force-persist due to persist()',
339 [
340 'session' => $this->id->__toString(),
341 ] );
342 $this->autosave();
343 } else {
344 $this->renew();
345 }
346 }
347
351 public function unpersist() {
352 if ( $this->persist ) {
353 // Close the PHP session, if we're the one that's open
354 if ( $this->usePhpSessionHandling && PHPSessionHandler::isEnabled() &&
355 session_id() === (string)$this->id
356 ) {
357 $this->logger->debug(
358 'SessionBackend "{session}" Closing PHP session for unpersist',
359 [ 'session' => $this->id->__toString() ]
360 );
361 session_write_close();
362 session_id( '' );
363 }
364
365 $this->persist = false;
366 $this->forcePersist = true;
367 $this->metaDirty = true;
368
369 // Delete the session data, so the local cache-only write in
370 // self::save() doesn't get things out of sync with the backend.
371 $this->store->delete( $this->store->makeKey( 'MWSession', (string)$this->id ) );
372
373 $this->autosave();
374 }
375 }
376
382 public function shouldRememberUser() {
383 return $this->remember;
384 }
385
391 public function setRememberUser( $remember ) {
392 if ( $this->remember !== (bool)$remember ) {
393 $this->remember = (bool)$remember;
394 $this->metaDirty = true;
395 $this->logger->debug(
396 'SessionBackend "{session}" metadata dirty due to remember-user change',
397 [
398 'session' => $this->id->__toString(),
399 ] );
400 $this->autosave();
401 }
402 }
403
409 public function getRequest( $index ) {
410 if ( !isset( $this->requests[$index] ) ) {
411 throw new \InvalidArgumentException( 'Invalid session index' );
412 }
413 return $this->requests[$index];
414 }
415
420 public function getUser() {
421 return $this->user;
422 }
423
428 public function getAllowedUserRights() {
429 return $this->provider->getAllowedUserRights( $this );
430 }
431
436 public function canSetUser() {
437 return $this->provider->canChangeUser();
438 }
439
447 public function setUser( $user ) {
448 if ( !$this->canSetUser() ) {
449 throw new \BadMethodCallException(
450 'Cannot set user on this session; check $session->canSetUser() first'
451 );
452 }
453
454 $this->user = $user;
455 $this->metaDirty = true;
456 $this->logger->debug(
457 'SessionBackend "{session}" metadata dirty due to user change',
458 [
459 'session' => $this->id->__toString(),
460 ] );
461 $this->autosave();
462 }
463
469 public function suggestLoginUsername( $index ) {
470 if ( !isset( $this->requests[$index] ) ) {
471 throw new \InvalidArgumentException( 'Invalid session index' );
472 }
473 return $this->provider->suggestLoginUsername( $this->requests[$index] );
474 }
475
480 public function shouldForceHTTPS() {
481 return $this->forceHTTPS;
482 }
483
488 public function setForceHTTPS( $force ) {
489 if ( $this->forceHTTPS !== (bool)$force ) {
490 $this->forceHTTPS = (bool)$force;
491 $this->metaDirty = true;
492 $this->logger->debug(
493 'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
494 [
495 'session' => $this->id->__toString(),
496 ] );
497 $this->autosave();
498 }
499 }
500
505 public function getLoggedOutTimestamp() {
506 return $this->loggedOut;
507 }
508
512 public function setLoggedOutTimestamp( $ts = null ) {
513 $ts = (int)$ts;
514 if ( $this->loggedOut !== $ts ) {
515 $this->loggedOut = $ts;
516 $this->metaDirty = true;
517 $this->logger->debug(
518 'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
519 [
520 'session' => $this->id->__toString(),
521 ] );
522 $this->autosave();
523 }
524 }
525
531 public function getProviderMetadata() {
532 return $this->providerMetadata;
533 }
534
539 public function setProviderMetadata( $metadata ) {
540 if ( $metadata !== null && !is_array( $metadata ) ) {
541 throw new \InvalidArgumentException( '$metadata must be an array or null' );
542 }
543 if ( $this->providerMetadata !== $metadata ) {
544 $this->providerMetadata = $metadata;
545 $this->metaDirty = true;
546 $this->logger->debug(
547 'SessionBackend "{session}" metadata dirty due to provider metadata change',
548 [
549 'session' => $this->id->__toString(),
550 ] );
551 $this->autosave();
552 }
553 }
554
564 public function &getData() {
565 return $this->data;
566 }
567
575 public function addData( array $newData ) {
576 $data = &$this->getData();
577 foreach ( $newData as $key => $value ) {
578 if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
579 $data[$key] = $value;
580 $this->dataDirty = true;
581 $this->logger->debug(
582 'SessionBackend "{session}" data dirty due to addData(): {callers}',
583 [
584 'session' => $this->id->__toString(),
585 'callers' => wfGetAllCallers( 5 ),
586 ] );
587 }
588 }
589 }
590
595 public function dirty() {
596 $this->dataDirty = true;
597 $this->logger->debug(
598 'SessionBackend "{session}" data dirty due to dirty(): {callers}',
599 [
600 'session' => $this->id->__toString(),
601 'callers' => wfGetAllCallers( 5 ),
602 ] );
603 }
604
611 public function renew() {
612 if ( time() + $this->lifetime / 2 > $this->expires ) {
613 $this->metaDirty = true;
614 $this->logger->debug(
615 'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
616 [
617 'session' => $this->id->__toString(),
618 'callers' => wfGetAllCallers( 5 ),
619 ] );
620 if ( $this->persist ) {
621 $this->persistenceChangeType = 'renew';
622 $this->forcePersist = true;
623 $this->logger->debug(
624 'SessionBackend "{session}" force-persist for renew(): {callers}',
625 [
626 'session' => $this->id->__toString(),
627 'callers' => wfGetAllCallers( 5 ),
628 ] );
629 }
630 }
631 $this->autosave();
632 }
633
641 public function delaySave() {
642 $this->delaySave++;
643 return new \Wikimedia\ScopedCallback( function () {
644 if ( --$this->delaySave <= 0 ) {
645 $this->delaySave = 0;
646 $this->save();
647 }
648 } );
649 }
650
655 private function autosave() {
656 if ( $this->delaySave <= 0 ) {
657 $this->save();
658 }
659 }
660
670 public function save( $closing = false ) {
671 $anon = $this->user->isAnon();
672
673 if ( !$anon && $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
674 $this->logger->debug(
675 'SessionBackend "{session}" not saving, user {user} was ' .
676 'passed to SessionManager::preventSessionsForUser',
677 [
678 'session' => $this->id->__toString(),
679 'user' => $this->user->__toString(),
680 ] );
681 return;
682 }
683
684 // Ensure the user has a token
685 // @codeCoverageIgnoreStart
686 if ( !$anon && !$this->user->getToken( false ) ) {
687 $this->logger->debug(
688 'SessionBackend "{session}" creating token for user {user} on save',
689 [
690 'session' => $this->id->__toString(),
691 'user' => $this->user->__toString(),
692 ] );
693 $this->user->setToken();
694 if ( !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
695 // Promise that the token set here will be valid; save it at end of request
696 $user = $this->user;
697 \DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
698 $user->saveSettings();
699 } );
700 }
701 $this->metaDirty = true;
702 }
703 // @codeCoverageIgnoreEnd
704
705 if ( !$this->metaDirty && !$this->dataDirty &&
706 $this->dataHash !== md5( serialize( $this->data ) )
707 ) {
708 $this->logger->debug(
709 'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
710 [
711 'session' => $this->id->__toString(),
712 'expected' => $this->dataHash,
713 'got' => md5( serialize( $this->data ) ),
714 ] );
715 $this->dataDirty = true;
716 }
717
718 if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
719 return;
720 }
721
722 $this->logger->debug(
723 'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
724 'metaDirty={metaDirty} forcePersist={forcePersist}',
725 [
726 'session' => $this->id->__toString(),
727 'dataDirty' => (int)$this->dataDirty,
728 'metaDirty' => (int)$this->metaDirty,
729 'forcePersist' => (int)$this->forcePersist,
730 ] );
731
732 // Persist or unpersist to the provider, if necessary
733 if ( $this->metaDirty || $this->forcePersist ) {
734 if ( $this->persist ) {
735 foreach ( $this->requests as $request ) {
736 $request->setSessionId( $this->getSessionId() );
737 $this->logPersistenceChange( $request, true );
738 $this->provider->persistSession( $this, $request );
739 }
740 if ( !$closing ) {
741 $this->checkPHPSession();
742 }
743 } else {
744 foreach ( $this->requests as $request ) {
745 if ( $request->getSessionId() === $this->id ) {
746 $this->logPersistenceChange( $request, false );
747 $this->provider->unpersistSession( $request );
748 }
749 }
750 }
751 }
752
753 $this->forcePersist = false;
754 $this->persistenceChangeType = null;
755
756 if ( !$this->metaDirty && !$this->dataDirty ) {
757 return;
758 }
759
760 // Save session data to store, if necessary
761 $metadata = $origMetadata = [
762 'provider' => (string)$this->provider,
763 'providerMetadata' => $this->providerMetadata,
764 'userId' => $anon ? 0 : $this->user->getId(),
765 'userName' => MediaWikiServices::getInstance()->getUserNameUtils()
766 ->isValid( $this->user->getName() ) ? $this->user->getName() : null,
767 'userToken' => $anon ? null : $this->user->getToken(),
768 'remember' => !$anon && $this->remember,
769 'forceHTTPS' => $this->forceHTTPS,
770 'expires' => time() + $this->lifetime,
771 'loggedOut' => $this->loggedOut,
772 'persisted' => $this->persist,
773 ];
774
775 $this->hookRunner->onSessionMetadata( $this, $metadata, $this->requests );
776
777 foreach ( $origMetadata as $k => $v ) {
778 if ( $metadata[$k] !== $v ) {
779 throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
780 }
781 }
782
783 $flags = $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY;
784 $this->store->set(
785 $this->store->makeKey( 'MWSession', (string)$this->id ),
786 [
787 'data' => $this->data,
788 'metadata' => $metadata,
789 ],
790 $metadata['expires'],
791 $flags
792 );
793
794 $this->metaDirty = false;
795 $this->dataDirty = false;
796 $this->dataHash = md5( serialize( $this->data ) );
797 $this->expires = $metadata['expires'];
798 }
799
804 private function checkPHPSession() {
805 if ( !$this->checkPHPSessionRecursionGuard ) {
806 $this->checkPHPSessionRecursionGuard = true;
807 $reset = new \Wikimedia\ScopedCallback( function () {
808 $this->checkPHPSessionRecursionGuard = false;
809 } );
810
811 if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
812 SessionManager::getGlobalSession()->getId() === (string)$this->id
813 ) {
814 $this->logger->debug(
815 'SessionBackend "{session}" Taking over PHP session',
816 [
817 'session' => $this->id->__toString(),
818 ] );
819 session_id( (string)$this->id );
820 AtEase::quietCall( 'session_start' );
821 }
822 }
823 }
824
830 private function logPersistenceChange( WebRequest $request, bool $persist ) {
831 if ( !$this->isPersistent() && !$persist ) {
832 // FIXME SessionManager calls unpersistSession() on anonymous requests (and the cookie
833 // filtering in WebResponse makes it a noop). Skip those.
834 return;
835 }
836
837 $verb = $persist ? 'Persisting' : 'Unpersisting';
838 if ( $this->persistenceChangeType === 'renew' ) {
839 $message = "$verb session for renewal";
840 } elseif ( $this->persistenceChangeType === 'no-store' ) {
841 $message = "$verb session due to no pre-existing stored session";
842 } elseif ( $this->persistenceChangeType === 'no-expiry' ) {
843 $message = "$verb session due to lack of stored expiry";
844 } elseif ( $this->persistenceChangeType === null ) {
845 $message = "$verb session for unknown reason";
846 }
847
848 // Because SessionManager repeats session loading several times in the same request,
849 // it will try to persist or unpersist several times. WebResponse deduplicates, but
850 // we want to deduplicate logging as well since the volume is already fairly large.
851 $id = $this->getId();
852 $user = $this->getUser()->isAnon() ? '<anon>' : $this->getUser()->getName();
853 if ( $this->persistenceChangeData
854 && $this->persistenceChangeData['id'] === $id
855 && $this->persistenceChangeData['user'] === $user
856 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable message always set
857 && $this->persistenceChangeData['message'] === $message
858 ) {
859 return;
860 }
861 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable message always set
862 $this->persistenceChangeData = [ 'id' => $id, 'user' => $user, 'message' => $message ];
863
864 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable message always set
865 $this->logger->info( $message, [
866 'id' => $id,
867 'provider' => get_class( $this->getProvider() ),
868 'user' => $user,
869 'clientip' => $request->getIP(),
870 'userAgent' => $request->getHeader( 'user-agent' ),
871 ] );
872 }
873
874}
serialize()
wfGetAllCallers( $limit=3)
Return a string consisting of callers in the stack.
Wrapper around a BagOStuff that caches data in memory.
makeKey( $collection,... $components)
Make a cache key for the global keyspace and given components.
get( $key, $flags=0)
Get an item.
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 PHPSessionHandling
Name constant for the PHPSessionHandling 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.
static isEnabled()
Test whether the handler is installed and enabled.
This is the actual workhorse for Session.
addData(array $newData)
Add data to the session.
__construct(SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger, HookContainer $hookContainer, $lifetime)
suggestLoginUsername( $index)
Get a suggested username for the login form.
isPersistent()
Indicate whether this session is persisted across requests.
getSession(WebRequest $request)
Return a new Session for this backend.
shouldForceHTTPS()
Whether HTTPS should be forced.
& getData()
Fetch the session data array.
getProviderMetadata()
Fetch provider metadata.
deregisterSession( $index)
Deregister a Session.
getSessionId()
Fetch the SessionId object.
canSetUser()
Indicate whether the session user info can be changed.
delaySave()
Delay automatic saving while multiple updates are being made.
unpersist()
Make this session not persisted across requests.
setForceHTTPS( $force)
Set whether HTTPS should be forced.
getProvider()
Fetch the SessionProvider for this session.
getId()
Returns the session ID.
getLoggedOutTimestamp()
Fetch the "logged out" timestamp.
resetId()
Changes the session ID.
setUser( $user)
Set a new user for this session.
save( $closing=false)
Save the session.
getUser()
Returns the authenticated user for this session.
renew()
Renew the session by resaving everything.
getAllowedUserRights()
Fetch the rights allowed the user when this session is active.
getRequest( $index)
Returns the request associated with a Session.
persist()
Make this session persisted across requests.
setRememberUser( $remember)
Set whether the user should be remembered independently of the session ID.
shouldRememberUser()
Indicate whether the user should be remembered independently of 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.
getProviderMetadata()
Return provider metadata.
getId()
Return the session ID.
getProvider()
Return the provider.
getUserInfo()
Return the user.
wasPersisted()
Return whether the session is persisted.
wasRemembered()
Return whether the user was remembered.
forceHTTPS()
Whether this session should only be used over HTTPS.
static getGlobalSession()
If PHP's session_id() has been set, returns that session.
A SessionProvider provides SessionInfo and support for Session.
Manages data for an authenticated session.
Definition Session.php:50
internal since 1.36
Definition User.php:70
saveSettings()
Save this user's settings into the database.
Definition User.php:2555
isAnon()
Get whether the user is anonymous.
Definition User.php:2319
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...
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.