3 * MediaWiki session backend
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
24 namespace MediaWiki\Session
;
27 use Psr\Log\LoggerInterface
;
32 * This is the actual workhorse for Session.
34 * Most code does not need to use this class, you want \MediaWiki\Session\Session.
35 * The exceptions are SessionProviders and SessionMetadata hook functions,
36 * which get an instance of this class rather than Session.
38 * The reasons for this split are:
39 * 1. A session can be attached to multiple requests, but we want the Session
40 * object to have some features that correspond to just one of those
42 * 2. We want reasonable garbage collection behavior, but we also want the
43 * SessionManager to hold a reference to every active session so it can be
44 * saved when the request ends.
49 final class SessionBackend
{
54 private $persist = false;
57 private $remember = false;
60 private $forceHTTPS = false;
62 /** @var array|null */
66 private $forcePersist = false;
69 private $metaDirty = false;
72 private $dataDirty = false;
74 /** @var string Used to detect subarray modifications */
75 private $dataHash = null;
77 /** @var CachedBagOStuff */
80 /** @var LoggerInterface */
90 private $curIndex = 0;
92 /** @var WebRequest[] Session requests */
93 private $requests = [];
95 /** @var SessionProvider provider */
98 /** @var array|null provider-specified metadata */
99 private $providerMetadata = null;
102 private $expires = 0;
105 private $loggedOut = 0;
108 private $delaySave = 0;
111 private $usePhpSessionHandling = true;
113 private $checkPHPSessionRecursionGuard = false;
116 private $shutdown = false;
119 * @param SessionId $id
120 * @param SessionInfo $info Session info to populate from
121 * @param CachedBagOStuff $store Backend data store
122 * @param LoggerInterface $logger
123 * @param int $lifetime Session data lifetime in seconds
125 public function __construct(
126 SessionId
$id, SessionInfo
$info, CachedBagOStuff
$store, LoggerInterface
$logger, $lifetime
128 $phpSessionHandling = \RequestContext
::getMain()->getConfig()->get( 'PHPSessionHandling' );
129 $this->usePhpSessionHandling
= $phpSessionHandling !== 'disable';
131 if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
132 throw new \
InvalidArgumentException(
133 "Refusing to create session for unverified user {$info->getUserInfo()}"
136 if ( $info->getProvider() === null ) {
137 throw new \
InvalidArgumentException( 'Cannot create session without a provider' );
139 if ( $info->getId() !== $id->getId() ) {
140 throw new \
InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
144 $this->user
= $info->getUserInfo() ?
$info->getUserInfo()->getUser() : new User
;
145 $this->store
= $store;
146 $this->logger
= $logger;
147 $this->lifetime
= $lifetime;
148 $this->provider
= $info->getProvider();
149 $this->persist
= $info->wasPersisted();
150 $this->remember
= $info->wasRemembered();
151 $this->forceHTTPS
= $info->forceHTTPS();
152 $this->providerMetadata
= $info->getProviderMetadata();
154 $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id
) );
155 if ( !is_array( $blob ) ||
156 !isset( $blob['metadata'] ) ||
!is_array( $blob['metadata'] ) ||
157 !isset( $blob['data'] ) ||
!is_array( $blob['data'] )
160 $this->dataDirty
= true;
161 $this->metaDirty
= true;
162 $this->logger
->debug(
163 'SessionBackend "{session}" is unsaved, marking dirty in constructor',
165 'session' => $this->id
,
168 $this->data
= $blob['data'];
169 if ( isset( $blob['metadata']['loggedOut'] ) ) {
170 $this->loggedOut
= (int)$blob['metadata']['loggedOut'];
172 if ( isset( $blob['metadata']['expires'] ) ) {
173 $this->expires
= (int)$blob['metadata']['expires'];
175 $this->metaDirty
= true;
176 $this->logger
->debug(
177 'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
179 'session' => $this->id
,
183 $this->dataHash
= md5( serialize( $this->data
) );
187 * Return a new Session for this backend
188 * @param WebRequest $request
191 public function getSession( WebRequest
$request ) {
192 $index = ++
$this->curIndex
;
193 $this->requests
[$index] = $request;
194 $session = new Session( $this, $index, $this->logger
);
199 * Deregister a Session
200 * @private For use by \MediaWiki\Session\Session::__destruct() only
203 public function deregisterSession( $index ) {
204 unset( $this->requests
[$index] );
205 if ( !$this->shutdown
&& !count( $this->requests
) ) {
207 $this->provider
->getManager()->deregisterSessionBackend( $this );
212 * Shut down a session
213 * @private For use by \MediaWiki\Session\SessionManager::shutdown() only
215 public function shutdown() {
217 $this->shutdown
= true;
221 * Returns the session ID.
224 public function getId() {
225 return (string)$this->id
;
229 * Fetch the SessionId object
230 * @private For internal use by WebRequest
233 public function getSessionId() {
238 * Changes the session ID
239 * @return string New ID (might be the same as the old)
241 public function resetId() {
242 if ( $this->provider
->persistsSessionId() ) {
243 $oldId = (string)$this->id
;
244 $restart = $this->usePhpSessionHandling
&& $oldId === session_id() &&
245 PHPSessionHandler
::isEnabled();
248 // If this session is the one behind PHP's $_SESSION, we need
249 // to close then reopen it.
250 session_write_close();
253 $this->provider
->getManager()->changeBackendId( $this );
254 $this->provider
->sessionIdWasReset( $this, $oldId );
255 $this->metaDirty
= true;
256 $this->logger
->debug(
257 'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
259 'session' => $this->id
,
264 session_id( (string)$this->id
);
265 \Wikimedia\
quietCall( 'session_start' );
270 // Delete the data for the old session ID now
271 $this->store
->delete( $this->store
->makeKey( 'MWSession', $oldId ) );
278 * Fetch the SessionProvider for this session
279 * @return SessionProviderInterface
281 public function getProvider() {
282 return $this->provider
;
286 * Indicate whether this session is persisted across requests
288 * For example, if cookies are set.
292 public function isPersistent() {
293 return $this->persist
;
297 * Make this session persisted across requests
299 * If the session is already persistent, equivalent to calling
302 public function persist() {
303 if ( !$this->persist
) {
304 $this->persist
= true;
305 $this->forcePersist
= true;
306 $this->metaDirty
= true;
307 $this->logger
->debug(
308 'SessionBackend "{session}" force-persist due to persist()',
310 'session' => $this->id
,
319 * Make this session not persisted across requests
321 public function unpersist() {
322 if ( $this->persist
) {
323 // Close the PHP session, if we're the one that's open
324 if ( $this->usePhpSessionHandling
&& PHPSessionHandler
::isEnabled() &&
325 session_id() === (string)$this->id
327 $this->logger
->debug(
328 'SessionBackend "{session}" Closing PHP session for unpersist',
329 [ 'session' => $this->id
]
331 session_write_close();
335 $this->persist
= false;
336 $this->forcePersist
= true;
337 $this->metaDirty
= true;
339 // Delete the session data, so the local cache-only write in
340 // self::save() doesn't get things out of sync with the backend.
341 $this->store
->delete( $this->store
->makeKey( 'MWSession', (string)$this->id
) );
348 * Indicate whether the user should be remembered independently of the
352 public function shouldRememberUser() {
353 return $this->remember
;
357 * Set whether the user should be remembered independently of the session
359 * @param bool $remember
361 public function setRememberUser( $remember ) {
362 if ( $this->remember
!== (bool)$remember ) {
363 $this->remember
= (bool)$remember;
364 $this->metaDirty
= true;
365 $this->logger
->debug(
366 'SessionBackend "{session}" metadata dirty due to remember-user change',
368 'session' => $this->id
,
375 * Returns the request associated with a Session
376 * @param int $index Session index
379 public function getRequest( $index ) {
380 if ( !isset( $this->requests
[$index] ) ) {
381 throw new \
InvalidArgumentException( 'Invalid session index' );
383 return $this->requests
[$index];
387 * Returns the authenticated user for this session
390 public function getUser() {
395 * Fetch the rights allowed the user when this session is active.
396 * @return null|string[] Allowed user rights, or null to allow all.
398 public function getAllowedUserRights() {
399 return $this->provider
->getAllowedUserRights( $this );
403 * Indicate whether the session user info can be changed
406 public function canSetUser() {
407 return $this->provider
->canChangeUser();
411 * Set a new user for this session
412 * @note This should only be called when the user has been authenticated via a login process
413 * @param User $user User to set on the session.
414 * This may become a "UserValue" in the future, or User may be refactored
417 public function setUser( $user ) {
418 if ( !$this->canSetUser() ) {
419 throw new \
BadMethodCallException(
420 'Cannot set user on this session; check $session->canSetUser() first'
425 $this->metaDirty
= true;
426 $this->logger
->debug(
427 'SessionBackend "{session}" metadata dirty due to user change',
429 'session' => $this->id
,
435 * Get a suggested username for the login form
436 * @param int $index Session index
437 * @return string|null
439 public function suggestLoginUsername( $index ) {
440 if ( !isset( $this->requests
[$index] ) ) {
441 throw new \
InvalidArgumentException( 'Invalid session index' );
443 return $this->provider
->suggestLoginUsername( $this->requests
[$index] );
447 * Whether HTTPS should be forced
450 public function shouldForceHTTPS() {
451 return $this->forceHTTPS
;
455 * Set whether HTTPS should be forced
458 public function setForceHTTPS( $force ) {
459 if ( $this->forceHTTPS
!== (bool)$force ) {
460 $this->forceHTTPS
= (bool)$force;
461 $this->metaDirty
= true;
462 $this->logger
->debug(
463 'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
465 'session' => $this->id
,
472 * Fetch the "logged out" timestamp
475 public function getLoggedOutTimestamp() {
476 return $this->loggedOut
;
480 * Set the "logged out" timestamp
481 * @param int|null $ts
483 public function setLoggedOutTimestamp( $ts = null ) {
485 if ( $this->loggedOut
!== $ts ) {
486 $this->loggedOut
= $ts;
487 $this->metaDirty
= true;
488 $this->logger
->debug(
489 'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
491 'session' => $this->id
,
498 * Fetch provider metadata
499 * @protected For use by SessionProvider subclasses only
502 public function getProviderMetadata() {
503 return $this->providerMetadata
;
507 * Set provider metadata
508 * @protected For use by SessionProvider subclasses only
509 * @param array|null $metadata
511 public function setProviderMetadata( $metadata ) {
512 if ( $metadata !== null && !is_array( $metadata ) ) {
513 throw new \
InvalidArgumentException( '$metadata must be an array or null' );
515 if ( $this->providerMetadata
!== $metadata ) {
516 $this->providerMetadata
= $metadata;
517 $this->metaDirty
= true;
518 $this->logger
->debug(
519 'SessionBackend "{session}" metadata dirty due to provider metadata change',
521 'session' => $this->id
,
528 * Fetch the session data array
530 * Note the caller is responsible for calling $this->dirty() if anything in
531 * the array is changed.
533 * @private For use by \MediaWiki\Session\Session only.
536 public function &getData() {
541 * Add data to the session.
543 * Overwrites any existing data under the same keys.
545 * @param array $newData Key-value pairs to add to the session
547 public function addData( array $newData ) {
548 $data = &$this->getData();
549 foreach ( $newData as $key => $value ) {
550 if ( !array_key_exists( $key, $data ) ||
$data[$key] !== $value ) {
551 $data[$key] = $value;
552 $this->dataDirty
= true;
553 $this->logger
->debug(
554 'SessionBackend "{session}" data dirty due to addData(): {callers}',
556 'session' => $this->id
,
557 'callers' => wfGetAllCallers( 5 ),
565 * @private For use by \MediaWiki\Session\Session only.
567 public function dirty() {
568 $this->dataDirty
= true;
569 $this->logger
->debug(
570 'SessionBackend "{session}" data dirty due to dirty(): {callers}',
572 'session' => $this->id
,
573 'callers' => wfGetAllCallers( 5 ),
578 * Renew the session by resaving everything
580 * Resets the TTL in the backend store if the session is near expiring, and
581 * re-persists the session to any active WebRequests if persistent.
583 public function renew() {
584 if ( time() +
$this->lifetime
/ 2 > $this->expires
) {
585 $this->metaDirty
= true;
586 $this->logger
->debug(
587 'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
589 'session' => $this->id
,
590 'callers' => wfGetAllCallers( 5 ),
592 if ( $this->persist
) {
593 $this->forcePersist
= true;
594 $this->logger
->debug(
595 'SessionBackend "{session}" force-persist for renew(): {callers}',
597 'session' => $this->id
,
598 'callers' => wfGetAllCallers( 5 ),
606 * Delay automatic saving while multiple updates are being made
608 * Calls to save() will not be delayed.
610 * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
612 public function delaySave() {
614 return new \Wikimedia\
ScopedCallback( function () {
615 if ( --$this->delaySave
<= 0 ) {
616 $this->delaySave
= 0;
623 * Save the session, unless delayed
624 * @see SessionBackend::save()
626 private function autosave() {
627 if ( $this->delaySave
<= 0 ) {
635 * Update both the backend data and the associated WebRequest(s) to
636 * reflect the state of the SessionBackend. This might include
637 * persisting or unpersisting the session.
639 * @param bool $closing Whether the session is being closed
641 public function save( $closing = false ) {
642 $anon = $this->user
->isAnon();
644 if ( !$anon && $this->provider
->getManager()->isUserSessionPrevented( $this->user
->getName() ) ) {
645 $this->logger
->debug(
646 'SessionBackend "{session}" not saving, user {user} was ' .
647 'passed to SessionManager::preventSessionsForUser',
649 'session' => $this->id
,
650 'user' => $this->user
,
655 // Ensure the user has a token
656 // @codeCoverageIgnoreStart
657 if ( !$anon && !$this->user
->getToken( false ) ) {
658 $this->logger
->debug(
659 'SessionBackend "{session}" creating token for user {user} on save',
661 'session' => $this->id
,
662 'user' => $this->user
,
664 $this->user
->setToken();
665 if ( !wfReadOnly() ) {
666 // Promise that the token set here will be valid; save it at end of request
668 \DeferredUpdates
::addCallableUpdate( function () use ( $user ) {
669 $user->saveSettings();
672 $this->metaDirty
= true;
674 // @codeCoverageIgnoreEnd
676 if ( !$this->metaDirty
&& !$this->dataDirty
&&
677 $this->dataHash
!== md5( serialize( $this->data
) )
679 $this->logger
->debug(
680 'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
682 'session' => $this->id
,
683 'expected' => $this->dataHash
,
684 'got' => md5( serialize( $this->data
) ),
686 $this->dataDirty
= true;
689 if ( !$this->metaDirty
&& !$this->dataDirty
&& !$this->forcePersist
) {
693 $this->logger
->debug(
694 'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
695 'metaDirty={metaDirty} forcePersist={forcePersist}',
697 'session' => $this->id
,
698 'dataDirty' => (int)$this->dataDirty
,
699 'metaDirty' => (int)$this->metaDirty
,
700 'forcePersist' => (int)$this->forcePersist
,
703 // Persist or unpersist to the provider, if necessary
704 if ( $this->metaDirty ||
$this->forcePersist
) {
705 if ( $this->persist
) {
706 foreach ( $this->requests
as $request ) {
707 $request->setSessionId( $this->getSessionId() );
708 $this->provider
->persistSession( $this, $request );
711 $this->checkPHPSession();
714 foreach ( $this->requests
as $request ) {
715 if ( $request->getSessionId() === $this->id
) {
716 $this->provider
->unpersistSession( $request );
722 $this->forcePersist
= false;
724 if ( !$this->metaDirty
&& !$this->dataDirty
) {
728 // Save session data to store, if necessary
729 $metadata = $origMetadata = [
730 'provider' => (string)$this->provider
,
731 'providerMetadata' => $this->providerMetadata
,
732 'userId' => $anon ?
0 : $this->user
->getId(),
733 'userName' => User
::isValidUserName( $this->user
->getName() ) ?
$this->user
->getName() : null,
734 'userToken' => $anon ?
null : $this->user
->getToken(),
735 'remember' => !$anon && $this->remember
,
736 'forceHTTPS' => $this->forceHTTPS
,
737 'expires' => time() +
$this->lifetime
,
738 'loggedOut' => $this->loggedOut
,
739 'persisted' => $this->persist
,
742 \Hooks
::run( 'SessionMetadata', [ $this, &$metadata, $this->requests
] );
744 foreach ( $origMetadata as $k => $v ) {
745 if ( $metadata[$k] !== $v ) {
746 throw new \
UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
750 $flags = $this->persist ?
0 : CachedBagOStuff
::WRITE_CACHE_ONLY
;
751 $flags |
= CachedBagOStuff
::WRITE_SYNC
; // write to all datacenters
753 $this->store
->makeKey( 'MWSession', (string)$this->id
),
755 'data' => $this->data
,
756 'metadata' => $metadata,
758 $metadata['expires'],
762 $this->metaDirty
= false;
763 $this->dataDirty
= false;
764 $this->dataHash
= md5( serialize( $this->data
) );
765 $this->expires
= $metadata['expires'];
769 * For backwards compatibility, open the PHP session when the global
770 * session is persisted
772 private function checkPHPSession() {
773 if ( !$this->checkPHPSessionRecursionGuard
) {
774 $this->checkPHPSessionRecursionGuard
= true;
775 $reset = new \Wikimedia\
ScopedCallback( function () {
776 $this->checkPHPSessionRecursionGuard
= false;
779 if ( $this->usePhpSessionHandling
&& session_id() === '' && PHPSessionHandler
::isEnabled() &&
780 SessionManager
::getGlobalSession()->getId() === (string)$this->id
782 $this->logger
->debug(
783 'SessionBackend "{session}" Taking over PHP session',
785 'session' => $this->id
,
787 session_id( (string)$this->id
);
788 \Wikimedia\
quietCall( 'session_start' );