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 
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.
const WRITE_CACHE_ONLY
Bitfield constants for set()/merge(); these are only advisory.
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.
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:564
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.
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
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:70
saveSettings()
Save this user's settings into the database.
Definition: User.php:2553
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Definition: WebRequest.php:44
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.