MediaWiki REL1_37
SessionBackend.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Session;
25
30use Psr\Log\LoggerInterface;
31use User;
32use WebRequest;
33use Wikimedia\AtEase\AtEase;
34
53final class SessionBackend {
55 private $id;
56
58 private $persist = false;
59
61 private $remember = false;
62
64 private $forceHTTPS = false;
65
67 private $data = null;
68
70 private $forcePersist = false;
71
81
87
89 private $metaDirty = false;
90
92 private $dataDirty = false;
93
95 private $dataHash = null;
96
98 private $store;
99
101 private $logger;
102
104 private $hookRunner;
105
107 private $lifetime;
108
110 private $user;
111
113 private $curIndex = 0;
114
116 private $requests = [];
117
119 private $provider;
120
122 private $providerMetadata = null;
123
125 private $expires = 0;
126
128 private $loggedOut = 0;
129
131 private $delaySave = 0;
132
137
139 private $shutdown = false;
140
149 public function __construct(
150 SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger,
151 HookContainer $hookContainer, $lifetime
152 ) {
153 $phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
154 $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
155
156 if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
157 throw new \InvalidArgumentException(
158 "Refusing to create session for unverified user {$info->getUserInfo()}"
159 );
160 }
161 if ( $info->getProvider() === null ) {
162 throw new \InvalidArgumentException( 'Cannot create session without a provider' );
163 }
164 if ( $info->getId() !== $id->getId() ) {
165 throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
166 }
167
168 $this->id = $id;
169 $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
170 $this->store = $store;
171 $this->logger = $logger;
172 $this->hookRunner = new HookRunner( $hookContainer );
173 $this->lifetime = $lifetime;
174 $this->provider = $info->getProvider();
175 $this->persist = $info->wasPersisted();
176 $this->remember = $info->wasRemembered();
177 $this->forceHTTPS = $info->forceHTTPS();
178 $this->providerMetadata = $info->getProviderMetadata();
179
180 $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id ) );
181 if ( !is_array( $blob ) ||
182 !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
183 !isset( $blob['data'] ) || !is_array( $blob['data'] )
184 ) {
185 $this->data = [];
186 $this->dataDirty = true;
187 $this->metaDirty = true;
188 $this->persistenceChangeType = 'no-store';
189 $this->logger->debug(
190 'SessionBackend "{session}" is unsaved, marking dirty in constructor',
191 [
192 'session' => $this->id->__toString(),
193 ] );
194 } else {
195 $this->data = $blob['data'];
196 if ( isset( $blob['metadata']['loggedOut'] ) ) {
197 $this->loggedOut = (int)$blob['metadata']['loggedOut'];
198 }
199 if ( isset( $blob['metadata']['expires'] ) ) {
200 $this->expires = (int)$blob['metadata']['expires'];
201 } else {
202 $this->metaDirty = true;
203 $this->persistenceChangeType = 'no-expiry';
204 $this->logger->debug(
205 'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
206 [
207 'session' => $this->id->__toString(),
208 ] );
209 }
210 }
211 $this->dataHash = md5( serialize( $this->data ) );
212 }
213
219 public function getSession( WebRequest $request ) {
220 $index = ++$this->curIndex;
221 $this->requests[$index] = $request;
222 $session = new Session( $this, $index, $this->logger );
223 return $session;
224 }
225
231 public function deregisterSession( $index ) {
232 unset( $this->requests[$index] );
233 if ( !$this->shutdown && !count( $this->requests ) ) {
234 $this->save( true );
235 $this->provider->getManager()->deregisterSessionBackend( $this );
236 }
237 }
238
243 public function shutdown() {
244 $this->save( true );
245 $this->shutdown = true;
246 }
247
252 public function getId() {
253 return (string)$this->id;
254 }
255
261 public function getSessionId() {
262 return $this->id;
263 }
264
269 public function resetId() {
270 if ( $this->provider->persistsSessionId() ) {
271 $oldId = (string)$this->id;
272 $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
274
275 if ( $restart ) {
276 // If this session is the one behind PHP's $_SESSION, we need
277 // to close then reopen it.
278 session_write_close();
279 }
280
281 $this->provider->getManager()->changeBackendId( $this );
282 $this->provider->sessionIdWasReset( $this, $oldId );
283 $this->metaDirty = true;
284 $this->logger->debug(
285 'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
286 [
287 'session' => $this->id->__toString(),
288 'oldId' => $oldId,
289 ] );
290
291 if ( $restart ) {
292 session_id( (string)$this->id );
293 AtEase::quietCall( 'session_start' );
294 }
295
296 $this->autosave();
297
298 // Delete the data for the old session ID now
299 $this->store->delete( $this->store->makeKey( 'MWSession', $oldId ) );
300 }
301
302 return $this->id;
303 }
304
309 public function getProvider() {
310 return $this->provider;
311 }
312
320 public function isPersistent() {
321 return $this->persist;
322 }
323
330 public function persist() {
331 if ( !$this->persist ) {
332 $this->persist = true;
333 $this->forcePersist = true;
334 $this->metaDirty = true;
335 $this->logger->debug(
336 'SessionBackend "{session}" force-persist due to persist()',
337 [
338 'session' => $this->id->__toString(),
339 ] );
340 $this->autosave();
341 } else {
342 $this->renew();
343 }
344 }
345
349 public function unpersist() {
350 if ( $this->persist ) {
351 // Close the PHP session, if we're the one that's open
352 if ( $this->usePhpSessionHandling && PHPSessionHandler::isEnabled() &&
353 session_id() === (string)$this->id
354 ) {
355 $this->logger->debug(
356 'SessionBackend "{session}" Closing PHP session for unpersist',
357 [ 'session' => $this->id->__toString() ]
358 );
359 session_write_close();
360 session_id( '' );
361 }
362
363 $this->persist = false;
364 $this->forcePersist = true;
365 $this->metaDirty = true;
366
367 // Delete the session data, so the local cache-only write in
368 // self::save() doesn't get things out of sync with the backend.
369 $this->store->delete( $this->store->makeKey( 'MWSession', (string)$this->id ) );
370
371 $this->autosave();
372 }
373 }
374
380 public function shouldRememberUser() {
381 return $this->remember;
382 }
383
389 public function setRememberUser( $remember ) {
390 if ( $this->remember !== (bool)$remember ) {
391 $this->remember = (bool)$remember;
392 $this->metaDirty = true;
393 $this->logger->debug(
394 'SessionBackend "{session}" metadata dirty due to remember-user change',
395 [
396 'session' => $this->id->__toString(),
397 ] );
398 $this->autosave();
399 }
400 }
401
407 public function getRequest( $index ) {
408 if ( !isset( $this->requests[$index] ) ) {
409 throw new \InvalidArgumentException( 'Invalid session index' );
410 }
411 return $this->requests[$index];
412 }
413
418 public function getUser() {
419 return $this->user;
420 }
421
426 public function getAllowedUserRights() {
427 return $this->provider->getAllowedUserRights( $this );
428 }
429
434 public function canSetUser() {
435 return $this->provider->canChangeUser();
436 }
437
445 public function setUser( $user ) {
446 if ( !$this->canSetUser() ) {
447 throw new \BadMethodCallException(
448 'Cannot set user on this session; check $session->canSetUser() first'
449 );
450 }
451
452 $this->user = $user;
453 $this->metaDirty = true;
454 $this->logger->debug(
455 'SessionBackend "{session}" metadata dirty due to user change',
456 [
457 'session' => $this->id->__toString(),
458 ] );
459 $this->autosave();
460 }
461
467 public function suggestLoginUsername( $index ) {
468 if ( !isset( $this->requests[$index] ) ) {
469 throw new \InvalidArgumentException( 'Invalid session index' );
470 }
471 return $this->provider->suggestLoginUsername( $this->requests[$index] );
472 }
473
478 public function shouldForceHTTPS() {
479 return $this->forceHTTPS;
480 }
481
486 public function setForceHTTPS( $force ) {
487 if ( $this->forceHTTPS !== (bool)$force ) {
488 $this->forceHTTPS = (bool)$force;
489 $this->metaDirty = true;
490 $this->logger->debug(
491 'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
492 [
493 'session' => $this->id->__toString(),
494 ] );
495 $this->autosave();
496 }
497 }
498
503 public function getLoggedOutTimestamp() {
504 return $this->loggedOut;
505 }
506
510 public function setLoggedOutTimestamp( $ts = null ) {
511 $ts = (int)$ts;
512 if ( $this->loggedOut !== $ts ) {
513 $this->loggedOut = $ts;
514 $this->metaDirty = true;
515 $this->logger->debug(
516 'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
517 [
518 'session' => $this->id->__toString(),
519 ] );
520 $this->autosave();
521 }
522 }
523
529 public function getProviderMetadata() {
531 }
532
537 public function setProviderMetadata( $metadata ) {
538 if ( $metadata !== null && !is_array( $metadata ) ) {
539 throw new \InvalidArgumentException( '$metadata must be an array or null' );
540 }
541 if ( $this->providerMetadata !== $metadata ) {
542 $this->providerMetadata = $metadata;
543 $this->metaDirty = true;
544 $this->logger->debug(
545 'SessionBackend "{session}" metadata dirty due to provider metadata change',
546 [
547 'session' => $this->id->__toString(),
548 ] );
549 $this->autosave();
550 }
551 }
552
562 public function &getData() {
563 return $this->data;
564 }
565
573 public function addData( array $newData ) {
574 $data = &$this->getData();
575 foreach ( $newData as $key => $value ) {
576 if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
577 $data[$key] = $value;
578 $this->dataDirty = true;
579 $this->logger->debug(
580 'SessionBackend "{session}" data dirty due to addData(): {callers}',
581 [
582 'session' => $this->id->__toString(),
583 'callers' => wfGetAllCallers( 5 ),
584 ] );
585 }
586 }
587 }
588
593 public function dirty() {
594 $this->dataDirty = true;
595 $this->logger->debug(
596 'SessionBackend "{session}" data dirty due to dirty(): {callers}',
597 [
598 'session' => $this->id->__toString(),
599 'callers' => wfGetAllCallers( 5 ),
600 ] );
601 }
602
609 public function renew() {
610 if ( time() + $this->lifetime / 2 > $this->expires ) {
611 $this->metaDirty = true;
612 $this->logger->debug(
613 'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
614 [
615 'session' => $this->id->__toString(),
616 'callers' => wfGetAllCallers( 5 ),
617 ] );
618 if ( $this->persist ) {
619 $this->persistenceChangeType = 'renew';
620 $this->forcePersist = true;
621 $this->logger->debug(
622 'SessionBackend "{session}" force-persist for renew(): {callers}',
623 [
624 'session' => $this->id->__toString(),
625 'callers' => wfGetAllCallers( 5 ),
626 ] );
627 }
628 }
629 $this->autosave();
630 }
631
639 public function delaySave() {
640 $this->delaySave++;
641 return new \Wikimedia\ScopedCallback( function () {
642 if ( --$this->delaySave <= 0 ) {
643 $this->delaySave = 0;
644 $this->save();
645 }
646 } );
647 }
648
653 private function autosave() {
654 if ( $this->delaySave <= 0 ) {
655 $this->save();
656 }
657 }
658
668 public function save( $closing = false ) {
669 $anon = $this->user->isAnon();
670
671 if ( !$anon && $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
672 $this->logger->debug(
673 'SessionBackend "{session}" not saving, user {user} was ' .
674 'passed to SessionManager::preventSessionsForUser',
675 [
676 'session' => $this->id->__toString(),
677 'user' => $this->user->__toString(),
678 ] );
679 return;
680 }
681
682 // Ensure the user has a token
683 // @codeCoverageIgnoreStart
684 if ( !$anon && !$this->user->getToken( false ) ) {
685 $this->logger->debug(
686 'SessionBackend "{session}" creating token for user {user} on save',
687 [
688 'session' => $this->id->__toString(),
689 'user' => $this->user->__toString(),
690 ] );
691 $this->user->setToken();
692 if ( !wfReadOnly() ) {
693 // Promise that the token set here will be valid; save it at end of request
695 \DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
697 } );
698 }
699 $this->metaDirty = true;
700 }
701 // @codeCoverageIgnoreEnd
702
703 if ( !$this->metaDirty && !$this->dataDirty &&
704 $this->dataHash !== md5( serialize( $this->data ) )
705 ) {
706 $this->logger->debug(
707 'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
708 [
709 'session' => $this->id->__toString(),
710 'expected' => $this->dataHash,
711 'got' => md5( serialize( $this->data ) ),
712 ] );
713 $this->dataDirty = true;
714 }
715
716 if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
717 return;
718 }
719
720 $this->logger->debug(
721 'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
722 'metaDirty={metaDirty} forcePersist={forcePersist}',
723 [
724 'session' => $this->id->__toString(),
725 'dataDirty' => (int)$this->dataDirty,
726 'metaDirty' => (int)$this->metaDirty,
727 'forcePersist' => (int)$this->forcePersist,
728 ] );
729
730 // Persist or unpersist to the provider, if necessary
731 if ( $this->metaDirty || $this->forcePersist ) {
732 if ( $this->persist ) {
733 foreach ( $this->requests as $request ) {
734 $request->setSessionId( $this->getSessionId() );
735 $this->logPersistenceChange( $request, true );
736 $this->provider->persistSession( $this, $request );
737 }
738 if ( !$closing ) {
739 $this->checkPHPSession();
740 }
741 } else {
742 foreach ( $this->requests as $request ) {
743 if ( $request->getSessionId() === $this->id ) {
744 $this->logPersistenceChange( $request, false );
745 $this->provider->unpersistSession( $request );
746 }
747 }
748 }
749 }
750
751 $this->forcePersist = false;
752 $this->persistenceChangeType = null;
753
754 if ( !$this->metaDirty && !$this->dataDirty ) {
755 return;
756 }
757
758 // Save session data to store, if necessary
759 $metadata = $origMetadata = [
760 'provider' => (string)$this->provider,
761 'providerMetadata' => $this->providerMetadata,
762 'userId' => $anon ? 0 : $this->user->getId(),
763 'userName' => MediaWikiServices::getInstance()->getUserNameUtils()
764 ->isValid( $this->user->getName() ) ? $this->user->getName() : null,
765 'userToken' => $anon ? null : $this->user->getToken(),
766 'remember' => !$anon && $this->remember,
767 'forceHTTPS' => $this->forceHTTPS,
768 'expires' => time() + $this->lifetime,
769 'loggedOut' => $this->loggedOut,
770 'persisted' => $this->persist,
771 ];
772
773 $this->hookRunner->onSessionMetadata( $this, $metadata, $this->requests );
774
775 foreach ( $origMetadata as $k => $v ) {
776 if ( $metadata[$k] !== $v ) {
777 throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
778 }
779 }
780
781 $flags = $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY;
782 $flags |= CachedBagOStuff::WRITE_SYNC; // write to all datacenters
783 $this->store->set(
784 $this->store->makeKey( 'MWSession', (string)$this->id ),
785 [
786 'data' => $this->data,
787 'metadata' => $metadata,
788 ],
789 $metadata['expires'],
790 $flags
791 );
792
793 $this->metaDirty = false;
794 $this->dataDirty = false;
795 $this->dataHash = md5( serialize( $this->data ) );
796 $this->expires = $metadata['expires'];
797 }
798
803 private function checkPHPSession() {
804 if ( !$this->checkPHPSessionRecursionGuard ) {
805 $this->checkPHPSessionRecursionGuard = true;
806 $reset = new \Wikimedia\ScopedCallback( function () {
807 $this->checkPHPSessionRecursionGuard = false;
808 } );
809
810 if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
811 SessionManager::getGlobalSession()->getId() === (string)$this->id
812 ) {
813 $this->logger->debug(
814 'SessionBackend "{session}" Taking over PHP session',
815 [
816 'session' => $this->id->__toString(),
817 ] );
818 session_id( (string)$this->id );
819 AtEase::quietCall( 'session_start' );
820 }
821 }
822 }
823
829 private function logPersistenceChange( WebRequest $request, bool $persist ) {
830 if ( !$this->isPersistent() && !$persist ) {
831 // FIXME SessionManager calls unpersistSession() on anonymous requests (and the cookie
832 // filtering in WebResponse makes it a noop). Skip those.
833 return;
834 }
835
836 $verb = $persist ? 'Persisting' : 'Unpersisting';
837 if ( $this->persistenceChangeType === 'renew' ) {
838 $message = "$verb session for renewal";
839 } elseif ( $this->persistenceChangeType === 'no-store' ) {
840 $message = "$verb session due to no pre-existing stored session";
841 } elseif ( $this->persistenceChangeType === 'no-expiry' ) {
842 $message = "$verb session due to lack of stored expiry";
843 } elseif ( $this->persistenceChangeType === null ) {
844 $message = "$verb session for unknown reason";
845 }
846
847 // Because SessionManager repeats session loading several times in the same request,
848 // it will try to persist or unpersist several times. WebResponse deduplicates, but
849 // we want to deduplicate logging as well since the volume is already fairly large.
850 $id = $this->getId();
851 $user = $this->getUser()->isAnon() ? '<anon>' : $this->getUser()->getName();
852 if ( $this->persistenceChangeData
853 && $this->persistenceChangeData['id'] === $id
854 && $this->persistenceChangeData['user'] === $user
855 && $this->persistenceChangeData['message'] === $message
856 ) {
857 return;
858 }
859 $this->persistenceChangeData = [ 'id' => $id, 'user' => $user, 'message' => $message ];
860
861 $this->logger->info( $message, [
862 'id' => $id,
863 'provider' => get_class( $this->getProvider() ),
864 'user' => $user,
865 'clientip' => $request->getIP(),
866 'userAgent' => $request->getHeader( 'user-agent' ),
867 ] );
868 }
869
870}
serialize()
wfReadOnly()
Check whether the wiki is in read-only mode.
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 with the given key.
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.
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.
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:48
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
isAnon()
Get whether the user is anonymous.
Definition User.php:2986
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.