wiki.techinc.nl/includes/session/SessionBackend.php
Brad Jorsch ef5bd7347b Move grant and IP restriction logic from OAuth to core
This also adds code to User to allow SessionProviders to apply the grant
restrictions without needing to hook UserGetRights.

Change-Id: Ida2b686157aab7c8240d6a7a5a5046374ef86d52
2016-01-12 22:37:33 +00:00

632 lines
17 KiB
PHP

<?php
/**
* MediaWiki session backend
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
* @ingroup Session
*/
namespace MediaWiki\Session;
use BagOStuff;
use Psr\Log\LoggerInterface;
use User;
use WebRequest;
/**
* This is the actual workhorse for Session.
*
* Most code does not need to use this class, you want \\MediaWiki\\Session\\Session.
* The exceptions are SessionProviders and SessionMetadata hook functions,
* which get an instance of this class rather than Session.
*
* The reasons for this split are:
* 1. A session can be attached to multiple requests, but we want the Session
* object to have some features that correspond to just one of those
* requests.
* 2. We want reasonable garbage collection behavior, but we also want the
* SessionManager to hold a reference to every active session so it can be
* saved when the request ends.
*
* @ingroup Session
* @since 1.27
*/
final class SessionBackend {
/** @var SessionId */
private $id;
private $persist = false;
private $remember = false;
private $forceHTTPS = false;
/** @var array|null */
private $data = null;
private $forcePersist = false;
private $metaDirty = false;
private $dataDirty = false;
/** @var string Used to detect subarray modifications */
private $dataHash = null;
/** @var BagOStuff */
private $store;
/** @var LoggerInterface */
private $logger;
/** @var int */
private $lifetime;
/** @var User */
private $user;
private $curIndex = 0;
/** @var WebRequest[] Session requests */
private $requests = array();
/** @var SessionProvider provider */
private $provider;
/** @var array|null provider-specified metadata */
private $providerMetadata = null;
private $expires = 0;
private $loggedOut = 0;
private $delaySave = 0;
private $usePhpSessionHandling = true;
private $checkPHPSessionRecursionGuard = false;
/**
* @param SessionId $id Session ID object
* @param SessionInfo $info Session info to populate from
* @param BagOStuff $store Backend data store
* @param LoggerInterface $logger
* @param int $lifetime Session data lifetime in seconds
*/
public function __construct(
SessionId $id, SessionInfo $info, BagOStuff $store, LoggerInterface $logger, $lifetime
) {
$phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
$this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
throw new \InvalidArgumentException(
"Refusing to create session for unverified user {$info->getUserInfo()}"
);
}
if ( $info->getProvider() === null ) {
throw new \InvalidArgumentException( 'Cannot create session without a provider' );
}
if ( $info->getId() !== $id->getId() ) {
throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
}
$this->id = $id;
$this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
$this->store = $store;
$this->logger = $logger;
$this->lifetime = $lifetime;
$this->provider = $info->getProvider();
$this->persist = $info->wasPersisted();
$this->remember = $info->wasRemembered();
$this->forceHTTPS = $info->forceHTTPS();
$this->providerMetadata = $info->getProviderMetadata();
$blob = $store->get( wfMemcKey( 'MWSession', (string)$this->id ) );
if ( !is_array( $blob ) ||
!isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
!isset( $blob['data'] ) || !is_array( $blob['data'] )
) {
$this->data = array();
$this->dataDirty = true;
$this->metaDirty = true;
$this->logger->debug( "SessionBackend $this->id is unsaved, marking dirty in constructor" );
} else {
$this->data = $blob['data'];
if ( isset( $blob['metadata']['loggedOut'] ) ) {
$this->loggedOut = (int)$blob['metadata']['loggedOut'];
}
if ( isset( $blob['metadata']['expires'] ) ) {
$this->expires = (int)$blob['metadata']['expires'];
} else {
$this->metaDirty = true;
$this->logger->debug(
"SessionBackend $this->id metadata dirty due to missing expiration timestamp"
);
}
}
$this->dataHash = md5( serialize( $this->data ) );
}
/**
* Return a new Session for this backend
* @param WebRequest $request
* @return Session
*/
public function getSession( WebRequest $request ) {
$index = ++$this->curIndex;
$this->requests[$index] = $request;
$session = new Session( $this, $index );
return $session;
}
/**
* Deregister a Session
* @private For use by \\MediaWiki\\Session\\Session::__destruct() only
* @param int $index
*/
public function deregisterSession( $index ) {
unset( $this->requests[$index] );
if ( !count( $this->requests ) ) {
$this->save( true );
$this->provider->getManager()->deregisterSessionBackend( $this );
}
}
/**
* Returns the session ID.
* @return string
*/
public function getId() {
return (string)$this->id;
}
/**
* Fetch the SessionId object
* @private For internal use by WebRequest
* @return SessionId
*/
public function getSessionId() {
return $this->id;
}
/**
* Changes the session ID
* @return string New ID (might be the same as the old)
*/
public function resetId() {
if ( $this->provider->persistsSessionId() ) {
$oldId = (string)$this->id;
$restart = $this->usePhpSessionHandling && $oldId === session_id() &&
PHPSessionHandler::isEnabled();
if ( $restart ) {
// If this session is the one behind PHP's $_SESSION, we need
// to close then reopen it.
session_write_close();
}
$this->provider->getManager()->changeBackendId( $this );
$this->provider->sessionIdWasReset( $this, $oldId );
$this->metaDirty = true;
$this->logger->debug(
"SessionBackend $this->id metadata dirty due to ID reset (formerly $oldId)"
);
if ( $restart ) {
session_id( (string)$this->id );
\MediaWiki\quietCall( 'session_start' );
}
$this->autosave();
// Delete the data for the old session ID now
$this->store->delete( wfMemcKey( 'MWSession', $oldId ) );
}
}
/**
* Fetch the SessionProvider for this session
* @return SessionProviderInterface
*/
public function getProvider() {
return $this->provider;
}
/**
* Indicate whether this session is persisted across requests
*
* For example, if cookies are set.
*
* @return bool
*/
public function isPersistent() {
return $this->persist;
}
/**
* Make this session persisted across requests
*
* If the session is already persistent, equivalent to calling
* $this->renew().
*/
public function persist() {
if ( !$this->persist ) {
$this->persist = true;
$this->forcePersist = true;
$this->logger->debug( "SessionBackend $this->id force-persist due to persist()" );
$this->autosave();
} else {
$this->renew();
}
}
/**
* Indicate whether the user should be remembered independently of the
* session ID.
* @return bool
*/
public function shouldRememberUser() {
return $this->remember;
}
/**
* Set whether the user should be remembered independently of the session
* ID.
* @param bool $remember
*/
public function setRememberUser( $remember ) {
if ( $this->remember !== (bool)$remember ) {
$this->remember = (bool)$remember;
$this->metaDirty = true;
$this->logger->debug( "SessionBackend $this->id metadata dirty due to remember-user change" );
$this->autosave();
}
}
/**
* Returns the request associated with a Session
* @param int $index Session index
* @return WebRequest
*/
public function getRequest( $index ) {
if ( !isset( $this->requests[$index] ) ) {
throw new \InvalidArgumentException( 'Invalid session index' );
}
return $this->requests[$index];
}
/**
* Returns the authenticated user for this session
* @return User
*/
public function getUser() {
return $this->user;
}
/**
* Fetch the rights allowed the user when this session is active.
* @return null|string[] Allowed user rights, or null to allow all.
*/
public function getAllowedUserRights() {
return $this->provider->getAllowedUserRights( $this );
}
/**
* Indicate whether the session user info can be changed
* @return bool
*/
public function canSetUser() {
return $this->provider->canChangeUser();
}
/**
* Set a new user for this session
* @note This should only be called when the user has been authenticated via a login process
* @param User $user User to set on the session.
* This may become a "UserValue" in the future, or User may be refactored
* into such.
*/
public function setUser( $user ) {
if ( !$this->canSetUser() ) {
throw new \BadMethodCallException(
'Cannot set user on this session; check $session->canSetUser() first'
);
}
$this->user = $user;
$this->metaDirty = true;
$this->logger->debug( "SessionBackend $this->id metadata dirty due to user change" );
$this->autosave();
}
/**
* Get a suggested username for the login form
* @param int $index Session index
* @return string|null
*/
public function suggestLoginUsername( $index ) {
if ( !isset( $this->requests[$index] ) ) {
throw new \InvalidArgumentException( 'Invalid session index' );
}
return $this->provider->suggestLoginUsername( $this->requests[$index] );
}
/**
* Whether HTTPS should be forced
* @return bool
*/
public function shouldForceHTTPS() {
return $this->forceHTTPS;
}
/**
* Set whether HTTPS should be forced
* @param bool $force
*/
public function setForceHTTPS( $force ) {
if ( $this->forceHTTPS !== (bool)$force ) {
$this->forceHTTPS = (bool)$force;
$this->metaDirty = true;
$this->logger->debug( "SessionBackend $this->id metadata dirty due to force-HTTPS change" );
$this->autosave();
}
}
/**
* Fetch the "logged out" timestamp
* @return int
*/
public function getLoggedOutTimestamp() {
return $this->loggedOut;
}
/**
* Set the "logged out" timestamp
* @param int $ts
*/
public function setLoggedOutTimestamp( $ts = null ) {
$ts = (int)$ts;
if ( $this->loggedOut !== $ts ) {
$this->loggedOut = $ts;
$this->metaDirty = true;
$this->logger->debug(
"SessionBackend $this->id metadata dirty due to logged-out-timestamp change"
);
$this->autosave();
}
}
/**
* Fetch provider metadata
* @protected For use by SessionProvider subclasses only
* @return mixed
*/
public function getProviderMetadata() {
return $this->providerMetadata;
}
/**
* Fetch the session data array
*
* Note the caller is responsible for calling $this->dirty() if anything in
* the array is changed.
*
* @private For use by \\MediaWiki\\Session\\Session only.
* @return array
*/
public function &getData() {
return $this->data;
}
/**
* Add data to the session.
*
* Overwrites any existing data under the same keys.
*
* @param array $newData Key-value pairs to add to the session
*/
public function addData( array $newData ) {
$data = &$this->getData();
foreach ( $newData as $key => $value ) {
if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
$data[$key] = $value;
$this->dataDirty = true;
$this->logger->debug(
"SessionBackend $this->id data dirty due to addData(): " . wfGetAllCallers( 5 )
);
}
}
}
/**
* Mark data as dirty
* @private For use by \\MediaWiki\\Session\\Session only.
*/
public function dirty() {
$this->dataDirty = true;
$this->logger->debug(
"SessionBackend $this->id data dirty due to dirty(): " . wfGetAllCallers( 5 )
);
}
/**
* Renew the session by resaving everything
*
* Resets the TTL in the backend store if the session is near expiring, and
* re-persists the session to any active WebRequests if persistent.
*/
public function renew() {
if ( time() + $this->lifetime / 2 > $this->expires ) {
$this->metaDirty = true;
$this->logger->debug(
"SessionBackend $this->id metadata dirty for renew(): " . wfGetAllCallers( 5 )
);
if ( $this->persist ) {
$this->forcePersist = true;
$this->logger->debug(
"SessionBackend $this->id force-persist for renew(): " . wfGetAllCallers( 5 )
);
}
}
$this->autosave();
}
/**
* Delay automatic saving while multiple updates are being made
*
* Calls to save() will not be delayed.
*
* @return \ScopedCallback When this goes out of scope, a save will be triggered
*/
public function delaySave() {
$that = $this;
$this->delaySave++;
$ref = &$this->delaySave;
return new \ScopedCallback( function () use ( $that, &$ref ) {
if ( --$ref <= 0 ) {
$ref = 0;
$that->save();
}
} );
}
/**
* Save and persist session data, unless delayed
*/
private function autosave() {
if ( $this->delaySave <= 0 ) {
$this->save();
}
}
/**
* Save and persist session data
* @param bool $closing Whether the session is being closed
*/
public function save( $closing = false ) {
if ( $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
$this->logger->debug(
"SessionBackend $this->id not saving, " .
"user {$this->user} was passed to SessionManager::preventSessionsForUser"
);
return;
}
// Ensure the user has a token
// @codeCoverageIgnoreStart
$anon = $this->user->isAnon();
if ( !$anon && !$this->user->getToken() ) {
$this->logger->debug(
"SessionBackend $this->id creating token for user {$this->user} on save"
);
$this->user->setToken();
if ( !wfReadOnly() ) {
$this->user->saveSettings();
}
$this->metaDirty = true;
}
// @codeCoverageIgnoreEnd
if ( !$this->metaDirty && !$this->dataDirty &&
$this->dataHash !== md5( serialize( $this->data ) )
) {
$this->logger->debug( "SessionBackend $this->id data dirty due to hash mismatch, " .
"$this->dataHash !== " . md5( serialize( $this->data ) ) );
$this->dataDirty = true;
}
if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
return;
}
$this->logger->debug( "SessionBackend $this->id save: " .
'dataDirty=' . (int)$this->dataDirty . ' ' .
'metaDirty=' . (int)$this->metaDirty . ' ' .
'forcePersist=' . (int)$this->forcePersist
);
// Persist to the provider, if flagged
if ( $this->persist && ( $this->metaDirty || $this->forcePersist ) ) {
foreach ( $this->requests as $request ) {
$request->setSessionId( $this->getSessionId() );
$this->provider->persistSession( $this, $request );
}
if ( !$closing ) {
$this->checkPHPSession();
}
}
$this->forcePersist = false;
if ( !$this->metaDirty && !$this->dataDirty ) {
return;
}
// Save session data to store, if necessary
$metadata = $origMetadata = array(
'provider' => (string)$this->provider,
'providerMetadata' => $this->providerMetadata,
'userId' => $anon ? 0 : $this->user->getId(),
'userName' => $anon ? null : $this->user->getName(),
'userToken' => $anon ? null : $this->user->getToken(),
'remember' => !$anon && $this->remember,
'forceHTTPS' => $this->forceHTTPS,
'expires' => time() + $this->lifetime,
'loggedOut' => $this->loggedOut,
);
\Hooks::run( 'SessionMetadata', array( $this, &$metadata, $this->requests ) );
foreach ( $origMetadata as $k => $v ) {
if ( $metadata[$k] !== $v ) {
throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
}
}
$this->store->set(
wfMemcKey( 'MWSession', (string)$this->id ),
array(
'data' => $this->data,
'metadata' => $metadata,
),
$metadata['expires']
);
$this->metaDirty = false;
$this->dataDirty = false;
$this->dataHash = md5( serialize( $this->data ) );
$this->expires = $metadata['expires'];
}
/**
* For backwards compatibility, open the PHP session when the global
* session is persisted
*/
private function checkPHPSession() {
if ( !$this->checkPHPSessionRecursionGuard ) {
$this->checkPHPSessionRecursionGuard = true;
$ref = &$this->checkPHPSessionRecursionGuard;
$reset = new \ScopedCallback( function () use ( &$ref ) {
$ref = false;
} );
if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
SessionManager::getGlobalSession()->getId() === (string)$this->id
) {
$this->logger->debug( "SessionBackend $this->id: Taking over PHP session" );
session_id( (string)$this->id );
\MediaWiki\quietCall( 'session_start' );
}
}
}
}