[HttpFoundation] implement session locking for PDO

This commit is contained in:
Tobias Schultze 2014-05-15 19:19:48 +02:00
parent 1ad7d05524
commit 50ec828642
3 changed files with 334 additions and 122 deletions

View File

@ -12,6 +12,14 @@
/**
* SessionHandlerInterface for PHP < 5.4
*
* The order in which these methods are invoked by PHP are:
* 1. open [session_start]
* 2. read
* 3. gc [optional depending on probability settings: gc_probability / gc_divisor]
* 4. destroy [optional when session_regenerate_id(true) is used]
* 5. write [session_write_close] or destroy [session_destroy]
* 6. close
*
* Extensive documentation can be found at php.net, see links:
*
* @see http://php.net/sessionhandlerinterface
@ -19,6 +27,7 @@
* @see http://php.net/session-set-save-handler
*
* @author Drak <drak@zikula.org>
* @author Tobias Schultze <http://tobion.de>
*/
interface SessionHandlerInterface
{
@ -57,6 +66,9 @@ interface SessionHandlerInterface
/**
* Writes the session data to the storage.
*
* Care, the session ID passed to write() can be different from the one previously
* received in read() when the session ID changed due to session_regenerate_id().
*
* @see http://php.net/sessionhandlerinterface.write
*
* @param string $sessionId Session ID , see http://php.net/function.session-id

View File

@ -12,7 +12,19 @@
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* PdoSessionHandler.
* Session handler using a PDO connection to read and write data.
*
* It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
* locking of sessions to prevent loss of data by concurrent access to the same session.
* This means requests for the same session will wait until the other one finished.
* PHPs internal files session handler also works this way.
*
* Attention: Since SQLite does not support row level locks but locks the whole database,
* it means only one session can be accessed at a time. Even different sessions would wait
* for another to finish. So saving session in SQLite should only be considered for
* development or prototypes.
*
* @see http://php.net/sessionhandlerinterface
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Michael Williams <michael.williams@funsational.com>
@ -25,6 +37,11 @@ class PdoSessionHandler implements \SessionHandlerInterface
*/
private $pdo;
/**
* @var string Database driver
*/
private $driver;
/**
* @var string Table name
*/
@ -45,39 +62,50 @@ class PdoSessionHandler implements \SessionHandlerInterface
*/
private $timeCol;
/**
* @var bool Whether a transaction is active
*/
private $inTransaction = false;
/**
* @var bool Whether gc() has been called
*/
private $gcCalled = false;
/**
* Constructor.
*
* List of available options:
* * db_table: The name of the table [required]
* * db_table: The name of the table [default: sessions]
* * db_id_col: The column where to store the session id [default: sess_id]
* * db_data_col: The column where to store the session data [default: sess_data]
* * db_time_col: The column where to store the timestamp [default: sess_time]
*
* @param \PDO $pdo A \PDO instance
* @param array $dbOptions An associative array of DB options
* @param \PDO $pdo A \PDO instance
* @param array $options An associative array of DB options
*
* @throws \InvalidArgumentException When "db_table" option is not provided
* @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
*/
public function __construct(\PDO $pdo, array $dbOptions = array())
public function __construct(\PDO $pdo, array $options = array())
{
if (!array_key_exists('db_table', $dbOptions)) {
throw new \InvalidArgumentException('You must provide the "db_table" option for a PdoSessionStorage.');
}
if (\PDO::ERRMODE_EXCEPTION !== $pdo->getAttribute(\PDO::ATTR_ERRMODE)) {
throw new \InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION))', __CLASS__));
}
$this->pdo = $pdo;
$dbOptions = array_merge(array(
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
$options = array_replace(array(
'db_table' => 'sessions',
'db_id_col' => 'sess_id',
'db_data_col' => 'sess_data',
'db_time_col' => 'sess_time',
), $dbOptions);
), $options);
$this->table = $dbOptions['db_table'];
$this->idCol = $dbOptions['db_id_col'];
$this->dataCol = $dbOptions['db_data_col'];
$this->timeCol = $dbOptions['db_time_col'];
$this->table = $options['db_table'];
$this->idCol = $options['db_id_col'];
$this->dataCol = $options['db_data_col'];
$this->timeCol = $options['db_time_col'];
}
/**
@ -85,14 +113,56 @@ class PdoSessionHandler implements \SessionHandlerInterface
*/
public function open($savePath, $sessionName)
{
$this->gcCalled = false;
return true;
}
/**
* {@inheritdoc}
*/
public function close()
public function read($sessionId)
{
$this->beginTransaction();
try {
$this->lockSession($sessionId);
// We need to make sure we do not return session data that is already considered garbage according
// to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
$maxlifetime = (int) ini_get('session.gc_maxlifetime');
$sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id AND $this->timeCol > :time";
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->bindValue(':time', time() - $maxlifetime, \PDO::PARAM_INT);
$stmt->execute();
// We use fetchAll instead of fetchColumn to make sure the DB cursor gets closed
$sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM);
if ($sessionRows) {
return base64_decode($sessionRows[0][0]);
}
return '';
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
/**
* {@inheritdoc}
*/
public function gc($maxlifetime)
{
// We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
// This way, pruning expired sessions does not block them from being started while the current session is used.
$this->gcCalled = true;
return true;
}
@ -109,56 +179,14 @@ class PdoSessionHandler implements \SessionHandlerInterface
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to delete a session: %s', $e->getMessage()), 0, $e);
$this->rollback();
throw $e;
}
return true;
}
/**
* {@inheritdoc}
*/
public function gc($maxlifetime)
{
// delete the session records that have expired
$sql = "DELETE FROM $this->table WHERE $this->timeCol < :time";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':time', time() - $maxlifetime, \PDO::PARAM_INT);
$stmt->execute();
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to delete expired sessions: %s', $e->getMessage()), 0, $e);
}
return true;
}
/**
* {@inheritdoc}
*/
public function read($sessionId)
{
$sql = "SELECT $this->dataCol FROM $this->table WHERE $this->idCol = :id";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->execute();
// We use fetchAll instead of fetchColumn to make sure the DB cursor gets closed
$sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM);
if ($sessionRows) {
return base64_decode($sessionRows[0][0]);
}
return '';
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to read the session data: %s', $e->getMessage()), 0, $e);
}
}
/**
* {@inheritdoc}
*/
@ -167,8 +195,10 @@ class PdoSessionHandler implements \SessionHandlerInterface
// Session data can contain non binary safe characters so we need to encode it.
$encoded = base64_encode($data);
// The session ID can be different from the one previously received in read()
// when the session ID changed due to session_regenerate_id(). So we have to
// do an insert or update even if we created a row in read() for locking.
// We use a MERGE SQL query when supported by the database.
// Otherwise we have to use a transactional DELETE followed by INSERT to prevent duplicate entries under high concurrency.
try {
$mergeSql = $this->getMergeSql();
@ -183,15 +213,18 @@ class PdoSessionHandler implements \SessionHandlerInterface
return true;
}
$this->pdo->beginTransaction();
try {
$deleteStmt = $this->pdo->prepare(
"DELETE FROM $this->table WHERE $this->idCol = :id"
);
$deleteStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$deleteStmt->execute();
$updateStmt = $this->pdo->prepare(
"UPDATE $this->table SET $this->dataCol = :data, $this->timeCol = :time WHERE $this->idCol = :id"
);
$updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$updateStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
$updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$updateStmt->execute();
// Since we have a lock on the session, this is safe to do. Otherwise it would be prone to
// race conditions in high concurrency. And if it's a regenerated session ID it should be
// unique anyway.
if (!$updateStmt->rowCount()) {
$insertStmt = $this->pdo->prepare(
"INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)"
);
@ -199,20 +232,155 @@ class PdoSessionHandler implements \SessionHandlerInterface
$insertStmt->bindParam(':data', $encoded, \PDO::PARAM_STR);
$insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$insertStmt->execute();
$this->pdo->commit();
} catch (\PDOException $e) {
$this->pdo->rollback();
throw $e;
}
} catch (\PDOException $e) {
throw new \RuntimeException(sprintf('PDOException was thrown when trying to write the session data: %s', $e->getMessage()), 0, $e);
$this->rollback();
throw $e;
}
return true;
}
/**
* {@inheritdoc}
*/
public function close()
{
$this->commit();
if ($this->gcCalled) {
$maxlifetime = (int) ini_get('session.gc_maxlifetime');
// delete the session records that have expired
$sql = "DELETE FROM $this->table WHERE $this->timeCol <= :time";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':time', time() - $maxlifetime, \PDO::PARAM_INT);
$stmt->execute();
}
return true;
}
/**
* Helper method to begin a transaction.
*
* Since SQLite does not support row level locks, we have to acquire a reserved lock
* on the database immediately. Because of https://bugs.php.net/42766 we have to create
* such a transaction manually which also means we cannot use PDO::commit or
* PDO::rollback or PDO::inTransaction for SQLite.
*/
private function beginTransaction()
{
if ($this->inTransaction) {
$this->rollback();
throw new \BadMethodCallException(
'Session handler methods have been invoked in wrong sequence. ' .
'Expected sequence: open() -> read() -> destroy() / write() -> close()');
}
if ('sqlite' === $this->driver) {
$this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
} else {
$this->pdo->beginTransaction();
}
$this->inTransaction = true;
}
/**
* Helper method to commit a transaction.
*/
private function commit()
{
if ($this->inTransaction) {
try {
// commit read-write transaction which also releases the lock
if ('sqlite' === $this->driver) {
$this->pdo->exec('COMMIT');
} else {
$this->pdo->commit();
}
$this->inTransaction = false;
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
}
/**
* Helper method to rollback a transaction.
*/
private function rollback()
{
// We only need to rollback if we are in a transaction. Otherwise the resulting
// error would hide the real problem why rollback was called. We might not be
// in a transaction when two callbacks (e.g. destroy and write) are invoked that
// both fail.
if ($this->inTransaction) {
if ('sqlite' === $this->driver) {
$this->pdo->exec('ROLLBACK');
} else {
$this->pdo->rollback();
}
$this->inTransaction = false;
}
}
/**
* Exclusively locks the row so other concurrent requests on the same session will block.
*
* This prevents loss of data by keeping the data consistent between read() and write().
* We do not use SELECT FOR UPDATE because it does not lock non-existent rows. And a following
* INSERT when not found can result in a deadlock for one connection.
*
* @param string $sessionId Session ID
*/
private function lockSession($sessionId)
{
switch ($this->driver) {
case 'mysql':
// will also lock the row when actually nothing got updated (id = id)
$sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
"ON DUPLICATE KEY UPDATE $this->idCol = $this->idCol";
break;
case 'oci':
// DUAL is Oracle specific dummy table
$sql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) " .
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
"WHEN MATCHED THEN UPDATE SET $this->idCol = $this->idCol";
break;
case 'sqlsrv':
// MS SQL Server requires MERGE be terminated by semicolon
$sql = "MERGE INTO $this->table USING (SELECT 'x' AS dummy) AS src ON ($this->idCol = :id) " .
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
"WHEN MATCHED THEN UPDATE SET $this->idCol = $this->idCol;";
break;
case 'pgsql':
// obtain an exclusive transaction level advisory lock
$sql = 'SELECT pg_advisory_xact_lock(:lock_id)';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':lock_id', hexdec(substr($sessionId, 0, 15)), \PDO::PARAM_INT);
$stmt->execute();
return;
default:
return;
}
// We create a DML lock for the session by inserting empty data or updating the row.
// This is safer than an application level advisory lock because it also prevents concurrent modification
// of the session from other parts of the application.
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->bindValue(':data', '', \PDO::PARAM_STR);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->execute();
}
/**
* Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database.
*
@ -220,9 +388,7 @@ class PdoSessionHandler implements \SessionHandlerInterface
*/
private function getMergeSql()
{
$driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
switch ($driver) {
switch ($this->driver) {
case 'mysql':
return "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
"ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->timeCol = VALUES($this->timeCol)";
@ -230,12 +396,12 @@ class PdoSessionHandler implements \SessionHandlerInterface
// DUAL is Oracle specific dummy table
return "MERGE INTO $this->table USING DUAL ON ($this->idCol = :id) " .
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data";
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->timeCol = :time";
case 'sqlsrv':
// MS SQL Server requires MERGE be terminated by semicolon
return "MERGE INTO $this->table USING (SELECT 'x' AS dummy) AS src ON ($this->idCol = :id) " .
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time) " .
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data;";
"WHEN MATCHED THEN UPDATE SET $this->dataCol = :data, $this->timeCol = :time;";
case 'sqlite':
return "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->timeCol) VALUES (:id, :data, :time)";
}

View File

@ -29,73 +29,107 @@ class PdoSessionHandlerTest extends \PHPUnit_Framework_TestCase
$this->pdo->exec($sql);
}
public function testIncompleteOptions()
{
$this->setExpectedException('InvalidArgumentException');
$storage = new PdoSessionHandler($this->pdo, array());
}
/**
* @expectedException \InvalidArgumentException
*/
public function testWrongPdoErrMode()
{
$pdo = new \PDO("sqlite::memory:");
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT);
$pdo->exec("CREATE TABLE sessions (sess_id VARCHAR(255) PRIMARY KEY, sess_data TEXT, sess_time INTEGER)");
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_SILENT);
$this->setExpectedException('InvalidArgumentException');
$storage = new PdoSessionHandler($pdo, array('db_table' => 'sessions'));
$storage = new PdoSessionHandler($this->pdo);
}
public function testWrongTableOptionsWrite()
/**
* @expectedException \RuntimeException
*/
public function testInexistentTable()
{
$storage = new PdoSessionHandler($this->pdo, array('db_table' => 'bad_name'));
$this->setExpectedException('RuntimeException');
$storage->write('foo', 'bar');
$storage = new PdoSessionHandler($this->pdo, array('db_table' => 'inexistent_table'));
$storage->open('', 'sid');
$storage->read('id');
$storage->write('id', 'data');
$storage->close();
}
public function testWrongTableOptionsRead()
public function testReadWriteRead()
{
$storage = new PdoSessionHandler($this->pdo, array('db_table' => 'bad_name'));
$this->setExpectedException('RuntimeException');
$storage->read('foo', 'bar');
$storage = new PdoSessionHandler($this->pdo);
$storage->open('', 'sid');
$this->assertSame('', $storage->read('id'), 'New session returns empty string data');
$storage->write('id', 'data');
$storage->close();
$storage->open('', 'sid');
$this->assertSame('data', $storage->read('id'), 'Written value can be read back correctly');
$storage->close();
}
public function testWriteRead()
/**
* Simulates session_regenerate_id(true) which will require an INSERT or UPDATE (replace)
*/
public function testWriteDifferentSessionIdThanRead()
{
$storage = new PdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage->write('foo', 'bar');
$this->assertEquals('bar', $storage->read('foo'), 'written value can be read back correctly');
$storage = new PdoSessionHandler($this->pdo);
$storage->open('', 'sid');
$storage->read('id');
$storage->destroy('id');
$storage->write('new_id', 'data_of_new_session_id');
$storage->close();
$storage->open('', 'sid');
$this->assertSame('data_of_new_session_id', $storage->read('new_id'), 'Data of regenerated session id is available');
$storage->close();
}
public function testMultipleInstances()
/**
* @expectedException \BadMethodCallException
*/
public function testWrongUsage()
{
$storage1 = new PdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage1->write('foo', 'bar');
$storage2 = new PdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$this->assertEquals('bar', $storage2->read('foo'), 'values persist between instances');
$storage = new PdoSessionHandler($this->pdo);
$storage->open('', 'sid');
$storage->read('id');
$storage->read('id');
}
public function testSessionDestroy()
{
$storage = new PdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$storage->write('foo', 'bar');
$this->assertCount(1, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
$storage = new PdoSessionHandler($this->pdo);
$storage->destroy('foo');
$storage->open('', 'sid');
$storage->read('id');
$storage->write('id', 'data');
$storage->close();
$this->assertEquals(1, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn());
$this->assertCount(0, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
$storage->open('', 'sid');
$storage->read('id');
$storage->destroy('id');
$storage->close();
$this->assertEquals(0, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn());
$storage->open('', 'sid');
$this->assertSame('', $storage->read('id'), 'Destroyed session returns empty string');
$storage->close();
}
public function testSessionGC()
{
$storage = new PdoSessionHandler($this->pdo, array('db_table' => 'sessions'));
$previousLifeTime = ini_set('session.gc_maxlifetime', 0);
$storage = new PdoSessionHandler($this->pdo);
$storage->write('foo', 'bar');
$storage->write('baz', 'bar');
$storage->open('', 'sid');
$storage->read('id');
$storage->write('id', 'data');
$storage->close();
$this->assertEquals(1, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn());
$this->assertCount(2, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
$storage->open('', 'sid');
$this->assertSame('', $storage->read('id'), 'Session already considered garbage, so not returning data even if it is not pruned yet');
$storage->gc(0);
$storage->close();
$this->assertEquals(0, $this->pdo->query('SELECT COUNT(*) FROM sessions')->fetchColumn());
$storage->gc(-1);
$this->assertCount(0, $this->pdo->query('SELECT * FROM sessions')->fetchAll());
ini_set('session.gc_maxlifetime', $previousLifeTime);
}
}