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
{
53 private $persist = false;
54 private $remember = false;
55 private $forceHTTPS = false;
57 /** @var array|null */
60 private $forcePersist = false;
61 private $metaDirty = false;
62 private $dataDirty = false;
64 /** @var string Used to detect subarray modifications */
65 private $dataHash = null;
67 /** @var CachedBagOStuff */
70 /** @var LoggerInterface */
79 private $curIndex = 0;
81 /** @var WebRequest[] Session requests */
82 private $requests = array();
84 /** @var SessionProvider provider */
87 /** @var array|null provider-specified metadata */
88 private $providerMetadata = null;
91 private $loggedOut = 0;
92 private $delaySave = 0;
94 private $usePhpSessionHandling = true;
95 private $checkPHPSessionRecursionGuard = false;
98 * @param SessionId $id Session ID object
99 * @param SessionInfo $info Session info to populate from
100 * @param CachedBagOStuff $store Backend data store
101 * @param LoggerInterface $logger
102 * @param int $lifetime Session data lifetime in seconds
104 public function __construct(
105 SessionId
$id, SessionInfo
$info, CachedBagOStuff
$store, LoggerInterface
$logger, $lifetime
107 $phpSessionHandling = \RequestContext
::getMain()->getConfig()->get( 'PHPSessionHandling' );
108 $this->usePhpSessionHandling
= $phpSessionHandling !== 'disable';
110 if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
111 throw new \
InvalidArgumentException(
112 "Refusing to create session for unverified user {$info->getUserInfo()}"
115 if ( $info->getProvider() === null ) {
116 throw new \
InvalidArgumentException( 'Cannot create session without a provider' );
118 if ( $info->getId() !== $id->getId() ) {
119 throw new \
InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
123 $this->user
= $info->getUserInfo() ?
$info->getUserInfo()->getUser() : new User
;
124 $this->store
= $store;
125 $this->logger
= $logger;
126 $this->lifetime
= $lifetime;
127 $this->provider
= $info->getProvider();
128 $this->persist
= $info->wasPersisted();
129 $this->remember
= $info->wasRemembered();
130 $this->forceHTTPS
= $info->forceHTTPS();
131 $this->providerMetadata
= $info->getProviderMetadata();
133 $blob = $store->get( wfMemcKey( 'MWSession', (string)$this->id
) );
134 if ( !is_array( $blob ) ||
135 !isset( $blob['metadata'] ) ||
!is_array( $blob['metadata'] ) ||
136 !isset( $blob['data'] ) ||
!is_array( $blob['data'] )
138 $this->data
= array();
139 $this->dataDirty
= true;
140 $this->metaDirty
= true;
141 $this->logger
->debug(
142 'SessionBackend "{session}" is unsaved, marking dirty in constructor',
144 'session' => $this->id
,
147 $this->data
= $blob['data'];
148 if ( isset( $blob['metadata']['loggedOut'] ) ) {
149 $this->loggedOut
= (int)$blob['metadata']['loggedOut'];
151 if ( isset( $blob['metadata']['expires'] ) ) {
152 $this->expires
= (int)$blob['metadata']['expires'];
154 $this->metaDirty
= true;
155 $this->logger
->debug(
156 'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
158 'session' => $this->id
,
162 $this->dataHash
= md5( serialize( $this->data
) );
166 * Return a new Session for this backend
167 * @param WebRequest $request
170 public function getSession( WebRequest
$request ) {
171 $index = ++
$this->curIndex
;
172 $this->requests
[$index] = $request;
173 $session = new Session( $this, $index );
178 * Deregister a Session
179 * @private For use by \\MediaWiki\\Session\\Session::__destruct() only
182 public function deregisterSession( $index ) {
183 unset( $this->requests
[$index] );
184 if ( !count( $this->requests
) ) {
186 $this->provider
->getManager()->deregisterSessionBackend( $this );
191 * Returns the session ID.
194 public function getId() {
195 return (string)$this->id
;
199 * Fetch the SessionId object
200 * @private For internal use by WebRequest
203 public function getSessionId() {
208 * Changes the session ID
209 * @return string New ID (might be the same as the old)
211 public function resetId() {
212 if ( $this->provider
->persistsSessionId() ) {
213 $oldId = (string)$this->id
;
214 $restart = $this->usePhpSessionHandling
&& $oldId === session_id() &&
215 PHPSessionHandler
::isEnabled();
218 // If this session is the one behind PHP's $_SESSION, we need
219 // to close then reopen it.
220 session_write_close();
223 $this->provider
->getManager()->changeBackendId( $this );
224 $this->provider
->sessionIdWasReset( $this, $oldId );
225 $this->metaDirty
= true;
226 $this->logger
->debug(
227 'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
229 'session' => $this->id
,
234 session_id( (string)$this->id
);
235 \MediaWiki\
quietCall( 'session_start' );
240 // Delete the data for the old session ID now
241 $this->store
->delete( wfMemcKey( 'MWSession', $oldId ) );
246 * Fetch the SessionProvider for this session
247 * @return SessionProviderInterface
249 public function getProvider() {
250 return $this->provider
;
254 * Indicate whether this session is persisted across requests
256 * For example, if cookies are set.
260 public function isPersistent() {
261 return $this->persist
;
265 * Make this session persisted across requests
267 * If the session is already persistent, equivalent to calling
270 public function persist() {
271 if ( !$this->persist
) {
272 $this->persist
= true;
273 $this->forcePersist
= true;
274 $this->metaDirty
= true;
275 $this->logger
->debug(
276 'SessionBackend "{session}" force-persist due to persist()',
278 'session' => $this->id
,
287 * Indicate whether the user should be remembered independently of the
291 public function shouldRememberUser() {
292 return $this->remember
;
296 * Set whether the user should be remembered independently of the session
298 * @param bool $remember
300 public function setRememberUser( $remember ) {
301 if ( $this->remember
!== (bool)$remember ) {
302 $this->remember
= (bool)$remember;
303 $this->metaDirty
= true;
304 $this->logger
->debug(
305 'SessionBackend "{session}" metadata dirty due to remember-user change',
307 'session' => $this->id
,
314 * Returns the request associated with a Session
315 * @param int $index Session index
318 public function getRequest( $index ) {
319 if ( !isset( $this->requests
[$index] ) ) {
320 throw new \
InvalidArgumentException( 'Invalid session index' );
322 return $this->requests
[$index];
326 * Returns the authenticated user for this session
329 public function getUser() {
334 * Fetch the rights allowed the user when this session is active.
335 * @return null|string[] Allowed user rights, or null to allow all.
337 public function getAllowedUserRights() {
338 return $this->provider
->getAllowedUserRights( $this );
342 * Indicate whether the session user info can be changed
345 public function canSetUser() {
346 return $this->provider
->canChangeUser();
350 * Set a new user for this session
351 * @note This should only be called when the user has been authenticated via a login process
352 * @param User $user User to set on the session.
353 * This may become a "UserValue" in the future, or User may be refactored
356 public function setUser( $user ) {
357 if ( !$this->canSetUser() ) {
358 throw new \
BadMethodCallException(
359 'Cannot set user on this session; check $session->canSetUser() first'
364 $this->metaDirty
= true;
365 $this->logger
->debug(
366 'SessionBackend "{session}" metadata dirty due to user change',
368 'session' => $this->id
,
374 * Get a suggested username for the login form
375 * @param int $index Session index
376 * @return string|null
378 public function suggestLoginUsername( $index ) {
379 if ( !isset( $this->requests
[$index] ) ) {
380 throw new \
InvalidArgumentException( 'Invalid session index' );
382 return $this->provider
->suggestLoginUsername( $this->requests
[$index] );
386 * Whether HTTPS should be forced
389 public function shouldForceHTTPS() {
390 return $this->forceHTTPS
;
394 * Set whether HTTPS should be forced
397 public function setForceHTTPS( $force ) {
398 if ( $this->forceHTTPS
!== (bool)$force ) {
399 $this->forceHTTPS
= (bool)$force;
400 $this->metaDirty
= true;
401 $this->logger
->debug(
402 'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
404 'session' => $this->id
,
411 * Fetch the "logged out" timestamp
414 public function getLoggedOutTimestamp() {
415 return $this->loggedOut
;
419 * Set the "logged out" timestamp
422 public function setLoggedOutTimestamp( $ts = null ) {
424 if ( $this->loggedOut
!== $ts ) {
425 $this->loggedOut
= $ts;
426 $this->metaDirty
= true;
427 $this->logger
->debug(
428 'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
430 'session' => $this->id
,
437 * Fetch provider metadata
438 * @protected For use by SessionProvider subclasses only
441 public function getProviderMetadata() {
442 return $this->providerMetadata
;
446 * Set provider metadata
447 * @protected For use by SessionProvider subclasses only
448 * @param array|null $metadata
450 public function setProviderMetadata( $metadata ) {
451 if ( $metadata !== null && !is_array( $metadata ) ) {
452 throw new \
InvalidArgumentException( '$metadata must be an array or null' );
454 if ( $this->providerMetadata
!== $metadata ) {
455 $this->providerMetadata
= $metadata;
456 $this->metaDirty
= true;
457 $this->logger
->debug(
458 'SessionBackend "{session}" metadata dirty due to provider metadata change',
460 'session' => $this->id
,
467 * Fetch the session data array
469 * Note the caller is responsible for calling $this->dirty() if anything in
470 * the array is changed.
472 * @private For use by \\MediaWiki\\Session\\Session only.
475 public function &getData() {
480 * Add data to the session.
482 * Overwrites any existing data under the same keys.
484 * @param array $newData Key-value pairs to add to the session
486 public function addData( array $newData ) {
487 $data = &$this->getData();
488 foreach ( $newData as $key => $value ) {
489 if ( !array_key_exists( $key, $data ) ||
$data[$key] !== $value ) {
490 $data[$key] = $value;
491 $this->dataDirty
= true;
492 $this->logger
->debug(
493 'SessionBackend "{session}" data dirty due to addData(): {callers}',
495 'session' => $this->id
,
496 'callers' => wfGetAllCallers( 5 ),
504 * @private For use by \\MediaWiki\\Session\\Session only.
506 public function dirty() {
507 $this->dataDirty
= true;
508 $this->logger
->debug(
509 'SessionBackend "{session}" data dirty due to dirty(): {callers}',
511 'session' => $this->id
,
512 'callers' => wfGetAllCallers( 5 ),
517 * Renew the session by resaving everything
519 * Resets the TTL in the backend store if the session is near expiring, and
520 * re-persists the session to any active WebRequests if persistent.
522 public function renew() {
523 if ( time() +
$this->lifetime
/ 2 > $this->expires
) {
524 $this->metaDirty
= true;
525 $this->logger
->debug(
526 'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
528 'session' => $this->id
,
529 'callers' => wfGetAllCallers( 5 ),
531 if ( $this->persist
) {
532 $this->forcePersist
= true;
533 $this->logger
->debug(
534 'SessionBackend "{session}" force-persist for renew(): {callers}',
536 'session' => $this->id
,
537 'callers' => wfGetAllCallers( 5 ),
545 * Delay automatic saving while multiple updates are being made
547 * Calls to save() will not be delayed.
549 * @return \ScopedCallback When this goes out of scope, a save will be triggered
551 public function delaySave() {
553 return new \
ScopedCallback( function () {
554 if ( --$this->delaySave
<= 0 ) {
555 $this->delaySave
= 0;
562 * Save and persist session data, unless delayed
564 private function autosave() {
565 if ( $this->delaySave
<= 0 ) {
571 * Save and persist session data
572 * @param bool $closing Whether the session is being closed
574 public function save( $closing = false ) {
575 if ( $this->provider
->getManager()->isUserSessionPrevented( $this->user
->getName() ) ) {
576 $this->logger
->debug(
577 'SessionBackend "{session}" not saving, user {user} was ' .
578 'passed to SessionManager::preventSessionsForUser',
580 'session' => $this->id
,
581 'user' => $this->user
,
586 // Ensure the user has a token
587 // @codeCoverageIgnoreStart
588 $anon = $this->user
->isAnon();
589 if ( !$anon && !$this->user
->getToken( false ) ) {
590 $this->logger
->debug(
591 'SessionBackend "{session}" creating token for user {user} on save',
593 'session' => $this->id
,
594 'user' => $this->user
,
596 $this->user
->setToken();
597 if ( !wfReadOnly() ) {
598 $this->user
->saveSettings();
600 $this->metaDirty
= true;
602 // @codeCoverageIgnoreEnd
604 if ( !$this->metaDirty
&& !$this->dataDirty
&&
605 $this->dataHash
!== md5( serialize( $this->data
) )
607 $this->logger
->debug(
608 'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
610 'session' => $this->id
,
611 'expected' => $this->dataHash
,
612 'got' => md5( serialize( $this->data
) ),
614 $this->dataDirty
= true;
617 if ( !$this->metaDirty
&& !$this->dataDirty
&& !$this->forcePersist
) {
621 $this->logger
->debug(
622 'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
623 'metaDirty={metaDirty} forcePersist={forcePersist}',
625 'session' => $this->id
,
626 'dataDirty' => (int)$this->dataDirty
,
627 'metaDirty' => (int)$this->metaDirty
,
628 'forcePersist' => (int)$this->forcePersist
,
631 // Persist to the provider, if flagged
632 if ( $this->persist
&& ( $this->metaDirty ||
$this->forcePersist
) ) {
633 foreach ( $this->requests
as $request ) {
634 $request->setSessionId( $this->getSessionId() );
635 $this->provider
->persistSession( $this, $request );
638 $this->checkPHPSession();
642 $this->forcePersist
= false;
644 if ( !$this->metaDirty
&& !$this->dataDirty
) {
648 // Save session data to store, if necessary
649 $metadata = $origMetadata = array(
650 'provider' => (string)$this->provider
,
651 'providerMetadata' => $this->providerMetadata
,
652 'userId' => $anon ?
0 : $this->user
->getId(),
653 'userName' => User
::isValidUserName( $this->user
->getName() ) ?
$this->user
->getName() : null,
654 'userToken' => $anon ?
null : $this->user
->getToken(),
655 'remember' => !$anon && $this->remember
,
656 'forceHTTPS' => $this->forceHTTPS
,
657 'expires' => time() +
$this->lifetime
,
658 'loggedOut' => $this->loggedOut
,
659 'persisted' => $this->persist
,
662 \Hooks
::run( 'SessionMetadata', array( $this, &$metadata, $this->requests
) );
664 foreach ( $origMetadata as $k => $v ) {
665 if ( $metadata[$k] !== $v ) {
666 throw new \
UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
671 wfMemcKey( 'MWSession', (string)$this->id
),
673 'data' => $this->data
,
674 'metadata' => $metadata,
676 $metadata['expires'],
677 $this->persist ?
0 : CachedBagOStuff
::WRITE_CACHE_ONLY
680 $this->metaDirty
= false;
681 $this->dataDirty
= false;
682 $this->dataHash
= md5( serialize( $this->data
) );
683 $this->expires
= $metadata['expires'];
687 * For backwards compatibility, open the PHP session when the global
688 * session is persisted
690 private function checkPHPSession() {
691 if ( !$this->checkPHPSessionRecursionGuard
) {
692 $this->checkPHPSessionRecursionGuard
= true;
693 $reset = new \
ScopedCallback( function () {
694 $this->checkPHPSessionRecursionGuard
= false;
697 if ( $this->usePhpSessionHandling
&& session_id() === '' && PHPSessionHandler
::isEnabled() &&
698 SessionManager
::getGlobalSession()->getId() === (string)$this->id
700 $this->logger
->debug(
701 'SessionBackend "{session}" Taking over PHP session',
703 'session' => $this->id
,
705 session_id( (string)$this->id
);
706 \MediaWiki\
quietCall( 'session_start' );