MediaWiki  master
SessionBackend.php
Go to the documentation of this file.
1 <?php
24 namespace MediaWiki\Session;
25 
26 use CachedBagOStuff;
31 use Psr\Log\LoggerInterface;
32 use User;
33 use WebRequest;
34 use Wikimedia\AtEase\AtEase;
35 
54 final 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 
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 
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 = \RequestContext::getMain()->getConfig()->get( MainConfigNames::PHPSessionHandling );
155  $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
156 
157  if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
158  throw new \InvalidArgumentException(
159  "Refusing to create session for unverified user {$info->getUserInfo()}"
160  );
161  }
162  if ( $info->getProvider() === null ) {
163  throw new \InvalidArgumentException( 'Cannot create session without a provider' );
164  }
165  if ( $info->getId() !== $id->getId() ) {
166  throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
167  }
168 
169  $this->id = $id;
170  $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
171  $this->store = $store;
172  $this->logger = $logger;
173  $this->hookRunner = new HookRunner( $hookContainer );
174  $this->lifetime = $lifetime;
175  $this->provider = $info->getProvider();
176  $this->persist = $info->wasPersisted();
177  $this->remember = $info->wasRemembered();
178  $this->forceHTTPS = $info->forceHTTPS();
179  $this->providerMetadata = $info->getProviderMetadata();
180 
181  $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id ) );
182  if ( !is_array( $blob ) ||
183  !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
184  !isset( $blob['data'] ) || !is_array( $blob['data'] )
185  ) {
186  $this->data = [];
187  $this->dataDirty = true;
188  $this->metaDirty = true;
189  $this->persistenceChangeType = 'no-store';
190  $this->logger->debug(
191  'SessionBackend "{session}" is unsaved, marking dirty in constructor',
192  [
193  'session' => $this->id->__toString(),
194  ] );
195  } else {
196  $this->data = $blob['data'];
197  if ( isset( $blob['metadata']['loggedOut'] ) ) {
198  $this->loggedOut = (int)$blob['metadata']['loggedOut'];
199  }
200  if ( isset( $blob['metadata']['expires'] ) ) {
201  $this->expires = (int)$blob['metadata']['expires'];
202  } else {
203  $this->metaDirty = true;
204  $this->persistenceChangeType = 'no-expiry';
205  $this->logger->debug(
206  'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
207  [
208  'session' => $this->id->__toString(),
209  ] );
210  }
211  }
212  $this->dataHash = md5( serialize( $this->data ) );
213  }
214 
220  public function getSession( WebRequest $request ) {
221  $index = ++$this->curIndex;
222  $this->requests[$index] = $request;
223  $session = new Session( $this, $index, $this->logger );
224  return $session;
225  }
226 
232  public function deregisterSession( $index ) {
233  unset( $this->requests[$index] );
234  if ( !$this->shutdown && !count( $this->requests ) ) {
235  $this->save( true );
236  $this->provider->getManager()->deregisterSessionBackend( $this );
237  }
238  }
239 
244  public function shutdown() {
245  $this->save( true );
246  $this->shutdown = true;
247  }
248 
253  public function getId() {
254  return (string)$this->id;
255  }
256 
262  public function getSessionId() {
263  return $this->id;
264  }
265 
270  public function resetId() {
271  if ( $this->provider->persistsSessionId() ) {
272  $oldId = (string)$this->id;
273  $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
275 
276  if ( $restart ) {
277  // If this session is the one behind PHP's $_SESSION, we need
278  // to close then reopen it.
279  session_write_close();
280  }
281 
282  $this->provider->getManager()->changeBackendId( $this );
283  $this->provider->sessionIdWasReset( $this, $oldId );
284  $this->metaDirty = true;
285  $this->logger->debug(
286  'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
287  [
288  'session' => $this->id->__toString(),
289  'oldId' => $oldId,
290  ] );
291 
292  if ( $restart ) {
293  session_id( (string)$this->id );
294  AtEase::quietCall( 'session_start' );
295  }
296 
297  $this->autosave();
298 
299  // Delete the data for the old session ID now
300  $this->store->delete( $this->store->makeKey( 'MWSession', $oldId ) );
301  }
302 
303  return $this->id;
304  }
305 
310  public function getProvider() {
311  return $this->provider;
312  }
313 
321  public function isPersistent() {
322  return $this->persist;
323  }
324 
331  public function persist() {
332  if ( !$this->persist ) {
333  $this->persist = true;
334  $this->forcePersist = true;
335  $this->metaDirty = true;
336  $this->logger->debug(
337  'SessionBackend "{session}" force-persist due to persist()',
338  [
339  'session' => $this->id->__toString(),
340  ] );
341  $this->autosave();
342  } else {
343  $this->renew();
344  }
345  }
346 
350  public function unpersist() {
351  if ( $this->persist ) {
352  // Close the PHP session, if we're the one that's open
353  if ( $this->usePhpSessionHandling && PHPSessionHandler::isEnabled() &&
354  session_id() === (string)$this->id
355  ) {
356  $this->logger->debug(
357  'SessionBackend "{session}" Closing PHP session for unpersist',
358  [ 'session' => $this->id->__toString() ]
359  );
360  session_write_close();
361  session_id( '' );
362  }
363 
364  $this->persist = false;
365  $this->forcePersist = true;
366  $this->metaDirty = true;
367 
368  // Delete the session data, so the local cache-only write in
369  // self::save() doesn't get things out of sync with the backend.
370  $this->store->delete( $this->store->makeKey( 'MWSession', (string)$this->id ) );
371 
372  $this->autosave();
373  }
374  }
375 
381  public function shouldRememberUser() {
382  return $this->remember;
383  }
384 
390  public function setRememberUser( $remember ) {
391  if ( $this->remember !== (bool)$remember ) {
392  $this->remember = (bool)$remember;
393  $this->metaDirty = true;
394  $this->logger->debug(
395  'SessionBackend "{session}" metadata dirty due to remember-user change',
396  [
397  'session' => $this->id->__toString(),
398  ] );
399  $this->autosave();
400  }
401  }
402 
408  public function getRequest( $index ) {
409  if ( !isset( $this->requests[$index] ) ) {
410  throw new \InvalidArgumentException( 'Invalid session index' );
411  }
412  return $this->requests[$index];
413  }
414 
419  public function getUser() {
420  return $this->user;
421  }
422 
427  public function getAllowedUserRights() {
428  return $this->provider->getAllowedUserRights( $this );
429  }
430 
435  public function canSetUser() {
436  return $this->provider->canChangeUser();
437  }
438 
446  public function setUser( $user ) {
447  if ( !$this->canSetUser() ) {
448  throw new \BadMethodCallException(
449  'Cannot set user on this session; check $session->canSetUser() first'
450  );
451  }
452 
453  $this->user = $user;
454  $this->metaDirty = true;
455  $this->logger->debug(
456  'SessionBackend "{session}" metadata dirty due to user change',
457  [
458  'session' => $this->id->__toString(),
459  ] );
460  $this->autosave();
461  }
462 
468  public function suggestLoginUsername( $index ) {
469  if ( !isset( $this->requests[$index] ) ) {
470  throw new \InvalidArgumentException( 'Invalid session index' );
471  }
472  return $this->provider->suggestLoginUsername( $this->requests[$index] );
473  }
474 
479  public function shouldForceHTTPS() {
480  return $this->forceHTTPS;
481  }
482 
487  public function setForceHTTPS( $force ) {
488  if ( $this->forceHTTPS !== (bool)$force ) {
489  $this->forceHTTPS = (bool)$force;
490  $this->metaDirty = true;
491  $this->logger->debug(
492  'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
493  [
494  'session' => $this->id->__toString(),
495  ] );
496  $this->autosave();
497  }
498  }
499 
504  public function getLoggedOutTimestamp() {
505  return $this->loggedOut;
506  }
507 
511  public function setLoggedOutTimestamp( $ts = null ) {
512  $ts = (int)$ts;
513  if ( $this->loggedOut !== $ts ) {
514  $this->loggedOut = $ts;
515  $this->metaDirty = true;
516  $this->logger->debug(
517  'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
518  [
519  'session' => $this->id->__toString(),
520  ] );
521  $this->autosave();
522  }
523  }
524 
530  public function getProviderMetadata() {
532  }
533 
538  public function setProviderMetadata( $metadata ) {
539  if ( $metadata !== null && !is_array( $metadata ) ) {
540  throw new \InvalidArgumentException( '$metadata must be an array or null' );
541  }
542  if ( $this->providerMetadata !== $metadata ) {
543  $this->providerMetadata = $metadata;
544  $this->metaDirty = true;
545  $this->logger->debug(
546  'SessionBackend "{session}" metadata dirty due to provider metadata change',
547  [
548  'session' => $this->id->__toString(),
549  ] );
550  $this->autosave();
551  }
552  }
553 
563  public function &getData() {
564  return $this->data;
565  }
566 
574  public function addData( array $newData ) {
575  $data = &$this->getData();
576  foreach ( $newData as $key => $value ) {
577  if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
578  $data[$key] = $value;
579  $this->dataDirty = true;
580  $this->logger->debug(
581  'SessionBackend "{session}" data dirty due to addData(): {callers}',
582  [
583  'session' => $this->id->__toString(),
584  'callers' => wfGetAllCallers( 5 ),
585  ] );
586  }
587  }
588  }
589 
594  public function dirty() {
595  $this->dataDirty = true;
596  $this->logger->debug(
597  'SessionBackend "{session}" data dirty due to dirty(): {callers}',
598  [
599  'session' => $this->id->__toString(),
600  'callers' => wfGetAllCallers( 5 ),
601  ] );
602  }
603 
610  public function renew() {
611  if ( time() + $this->lifetime / 2 > $this->expires ) {
612  $this->metaDirty = true;
613  $this->logger->debug(
614  'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
615  [
616  'session' => $this->id->__toString(),
617  'callers' => wfGetAllCallers( 5 ),
618  ] );
619  if ( $this->persist ) {
620  $this->persistenceChangeType = 'renew';
621  $this->forcePersist = true;
622  $this->logger->debug(
623  'SessionBackend "{session}" force-persist for renew(): {callers}',
624  [
625  'session' => $this->id->__toString(),
626  'callers' => wfGetAllCallers( 5 ),
627  ] );
628  }
629  }
630  $this->autosave();
631  }
632 
640  public function delaySave() {
641  $this->delaySave++;
642  return new \Wikimedia\ScopedCallback( function () {
643  if ( --$this->delaySave <= 0 ) {
644  $this->delaySave = 0;
645  $this->save();
646  }
647  } );
648  }
649 
654  private function autosave() {
655  if ( $this->delaySave <= 0 ) {
656  $this->save();
657  }
658  }
659 
669  public function save( $closing = false ) {
670  $anon = $this->user->isAnon();
671 
672  if ( !$anon && $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
673  $this->logger->debug(
674  'SessionBackend "{session}" not saving, user {user} was ' .
675  'passed to SessionManager::preventSessionsForUser',
676  [
677  'session' => $this->id->__toString(),
678  'user' => $this->user->__toString(),
679  ] );
680  return;
681  }
682 
683  // Ensure the user has a token
684  // @codeCoverageIgnoreStart
685  if ( !$anon && !$this->user->getToken( false ) ) {
686  $this->logger->debug(
687  'SessionBackend "{session}" creating token for user {user} on save',
688  [
689  'session' => $this->id->__toString(),
690  'user' => $this->user->__toString(),
691  ] );
692  $this->user->setToken();
693  if ( !wfReadOnly() ) {
694  // Promise that the token set here will be valid; save it at end of request
695  $user = $this->user;
696  \DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
697  $user->saveSettings();
698  } );
699  }
700  $this->metaDirty = true;
701  }
702  // @codeCoverageIgnoreEnd
703 
704  if ( !$this->metaDirty && !$this->dataDirty &&
705  $this->dataHash !== md5( serialize( $this->data ) )
706  ) {
707  $this->logger->debug(
708  'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
709  [
710  'session' => $this->id->__toString(),
711  'expected' => $this->dataHash,
712  'got' => md5( serialize( $this->data ) ),
713  ] );
714  $this->dataDirty = true;
715  }
716 
717  if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
718  return;
719  }
720 
721  $this->logger->debug(
722  'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
723  'metaDirty={metaDirty} forcePersist={forcePersist}',
724  [
725  'session' => $this->id->__toString(),
726  'dataDirty' => (int)$this->dataDirty,
727  'metaDirty' => (int)$this->metaDirty,
728  'forcePersist' => (int)$this->forcePersist,
729  ] );
730 
731  // Persist or unpersist to the provider, if necessary
732  if ( $this->metaDirty || $this->forcePersist ) {
733  if ( $this->persist ) {
734  foreach ( $this->requests as $request ) {
735  $request->setSessionId( $this->getSessionId() );
736  $this->logPersistenceChange( $request, true );
737  $this->provider->persistSession( $this, $request );
738  }
739  if ( !$closing ) {
740  $this->checkPHPSession();
741  }
742  } else {
743  foreach ( $this->requests as $request ) {
744  if ( $request->getSessionId() === $this->id ) {
745  $this->logPersistenceChange( $request, false );
746  $this->provider->unpersistSession( $request );
747  }
748  }
749  }
750  }
751 
752  $this->forcePersist = false;
753  $this->persistenceChangeType = null;
754 
755  if ( !$this->metaDirty && !$this->dataDirty ) {
756  return;
757  }
758 
759  // Save session data to store, if necessary
760  $metadata = $origMetadata = [
761  'provider' => (string)$this->provider,
762  'providerMetadata' => $this->providerMetadata,
763  'userId' => $anon ? 0 : $this->user->getId(),
764  'userName' => MediaWikiServices::getInstance()->getUserNameUtils()
765  ->isValid( $this->user->getName() ) ? $this->user->getName() : null,
766  'userToken' => $anon ? null : $this->user->getToken(),
767  'remember' => !$anon && $this->remember,
768  'forceHTTPS' => $this->forceHTTPS,
769  'expires' => time() + $this->lifetime,
770  'loggedOut' => $this->loggedOut,
771  'persisted' => $this->persist,
772  ];
773 
774  $this->hookRunner->onSessionMetadata( $this, $metadata, $this->requests );
775 
776  foreach ( $origMetadata as $k => $v ) {
777  if ( $metadata[$k] !== $v ) {
778  throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
779  }
780  }
781 
782  $flags = $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY;
783  $flags |= CachedBagOStuff::WRITE_SYNC; // write to all datacenters
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()
wfReadOnly()
Check whether the wiki is in read-only mode.
wfGetAllCallers( $limit=3)
Return a string consisting of callers in the stack.
const WRITE_SYNC
Bitfield constants for set()/merge(); these are only advisory.
Definition: BagOStuff.php:121
const WRITE_CACHE_ONLY
Definition: BagOStuff.php:122
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 with the given key.
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:562
A class containing constants representing the names of configuration variables.
const PHPSessionHandling
Name constant for the PHPSessionHandling setting, for use with Config::get()
MediaWikiServices is the service locator for the application scope of MediaWiki.
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.
logPersistenceChange(WebRequest $request, bool $persist)
Helper method for logging persistSession/unpersistSession calls.
__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.
string null $persistenceChangeType
The reason for the next persistSession/unpersistSession call.
checkPHPSession()
For backwards compatibility, open the PHP session when the global session is persisted.
unpersist()
Make this session not persisted across requests.
WebRequest[] $requests
Session requests.
array null $providerMetadata
provider-specified metadata
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.
autosave()
Save the session, unless delayed.
array $persistenceChangeData
The data from the previous logPersistenceChange() log event.
renew()
Renew the session by resaving everything.
SessionProvider $provider
provider
string $dataHash
Used to detect subarray modifications.
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.
shutdown()
Shut down a session.
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.
Definition: SessionInfo.php:37
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.
Manages data for an authenticated session.
Definition: Session.php:50
static getMain()
Get the RequestContext object associated with the main request.
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:68
saveSettings()
Save this user's settings into the database.
Definition: User.php:2791
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Definition: WebRequest.php:43
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.