Last active
February 17, 2017 12:47
-
-
Save asentner/6d60887a00dae3e2eb0e310b0d3d1e80 to your computer and use it in GitHub Desktop.
Session handler using a PDO connection to read/write session data to the database on Symfony and Laravel projects. Compatible with MySQL (innodb databases) and PostgreSQL 9.5+. If the database connection fails, this essentially becomes a NullSessionHandler and doesn't write a session at all.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Acme\DemoBundle\Session\Storage\Handler; | |
/** | |
* Session handler using a PDO connection to read/write session data to the database on Symfony and | |
* Laravel projects. | |
* | |
* This script is intended to work with MySQL (innodb databases) and PostgreSQL 9.5+. | |
* | |
* Session data is a binary string that can contain non-printable characters like the null byte. | |
* For this reason it must be saved in a binary column in the database like BLOB in MySQL. | |
* Saving it in a character column could corrupt the data. You can use createTable() | |
* to initialize a correctly defined table. | |
*/ | |
class DatabaseSessionHandler implements \SessionHandlerInterface | |
{ | |
/** | |
* @var \PDO|null | |
*/ | |
private $pdo; | |
/** | |
* @var string|null | |
*/ | |
private $dsn = false; | |
/** | |
* @var string | |
*/ | |
private $table = 'session'; | |
/** | |
* @var string | |
*/ | |
private $idCol = 'session_id'; | |
/** | |
* @var string | |
*/ | |
private $dataCol = 'session_data'; | |
/** | |
* @var string | |
*/ | |
private $lifetimeCol = 'session_lifetime'; | |
/** | |
* @var string | |
*/ | |
private $timeCol = 'session_time'; | |
/** | |
* @var string | |
*/ | |
private $username = ''; | |
/** | |
* @var string | |
*/ | |
private $password = ''; | |
/** | |
* @var array | |
*/ | |
private $connectionOptions = []; | |
/** | |
* @var string | |
*/ | |
private $driver; | |
/** | |
* @var bool | |
*/ | |
private $sessionExpired = false; | |
/** | |
* @var bool | |
*/ | |
private $inTransaction = false; | |
/** | |
* @var bool | |
*/ | |
private $gcCalled = false; | |
/** | |
* DatabaseSessionHandler constructor. | |
* @param null $dsn | |
* @param array $options | |
*/ | |
public function __construct($dsn = null, array $options = []) | |
{ | |
$this->dsn = $dsn; | |
$this->table = isset($options['db_table']) ? $options['db_table'] : $this->table; | |
$this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol; | |
$this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol; | |
$this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol; | |
$this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol; | |
$this->username = isset($options['db_username']) ? $options['db_username'] : $this->username; | |
$this->password = isset($options['db_password']) ? $options['db_password'] : $this->password; | |
$this->connectionOptions = isset($options['db_connection_options']) ? | |
$options['db_connection_options'] : $this->connectionOptions; | |
} | |
public function createTable() | |
{ | |
$this->openConnection(); | |
switch ($this->driver) { | |
case 'mysql': | |
$sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; | |
break; | |
case 'pgsql': | |
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)"; | |
break; | |
default: | |
throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); | |
} | |
try { | |
$this->pdo->exec($sql); | |
} catch (\PDOException $e) { | |
$this->rollback(); | |
throw $e; | |
} | |
} | |
/** | |
* @return bool | |
*/ | |
public function isSessionExpired() | |
{ | |
return $this->sessionExpired; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function close() | |
{ | |
if (null !== $this->pdo) { | |
$this->commit(); | |
if ($this->gcCalled) { | |
$this->gcCalled = false; | |
// delete the session records that have expired | |
$sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time"; | |
$stmt = $this->pdo->prepare($sql); | |
$stmt->bindValue(':time', time(), \PDO::PARAM_INT); | |
$stmt->execute(); | |
} | |
$this->pdo = null; | |
} | |
return true; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function destroy($sessionId) | |
{ | |
if (null !== $this->pdo) { | |
// delete the record associated with this id | |
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id"; | |
try { | |
$stmt = $this->pdo->prepare($sql); | |
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); | |
$stmt->execute(); | |
} catch (\PDOException $e) { | |
$this->rollback(); | |
throw $e; | |
} | |
} | |
return true; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function gc($maxlifetime) | |
{ | |
$this->gcCalled = true; | |
return true; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function open($savePath, $name) | |
{ | |
try { | |
$this->pdo = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); | |
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); | |
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); | |
} | |
finally { | |
return true; | |
} | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function read($sessionId) | |
{ | |
if (null !== $this->pdo) { | |
try { | |
return $this->doRead($sessionId); | |
} catch (\PDOException $e) { | |
$this->rollback(); | |
throw $e; | |
} | |
} | |
return ''; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function write($sessionId, $data) | |
{ | |
$maxlifetime = (int) ini_get('session.gc_maxlifetime'); | |
if (null !== $this->pdo) { | |
try { | |
switch($this->driver) { | |
case 'mysql': | |
$mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". | |
"ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)"; | |
break; | |
case 'pgsql': | |
$mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ". | |
"ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)"; | |
break; | |
} | |
$mergeStmt = $this->pdo->prepare($mergeSql); | |
$mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); | |
$mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB); | |
$mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT); | |
$mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT); | |
if (null !== $mergeStmt) { | |
$mergeStmt->execute(); | |
return true; | |
} | |
} catch (\PDOException $e) { | |
$this->rollback(); | |
throw $e; | |
} | |
} | |
return true; | |
} | |
private function beginTransaction() | |
{ | |
if (!$this->inTransaction) { | |
if ('mysql' === $this->driver) { | |
$this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); | |
} | |
$this->pdo->beginTransaction(); | |
$this->inTransaction = true; | |
} | |
} | |
private function commit() | |
{ | |
if ($this->inTransaction) { | |
try { | |
$this->pdo->commit(); | |
$this->inTransaction = false; | |
} | |
catch (\PDOException $e) { | |
$this->rollback(); | |
throw $e; | |
} | |
} | |
} | |
private function rollback() | |
{ | |
if ($this->inTransaction) { | |
$this->pdo->rollBack(); | |
$this->inTransaction = false; | |
} | |
} | |
/** | |
* @param $sessionId | |
* @return string | |
*/ | |
private function doRead($sessionId) | |
{ | |
$this->sessionExpired = false; | |
$this->beginTransaction(); | |
$selectStmt = $this->pdo->prepare("SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE"); | |
$selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); | |
do { | |
$selectStmt->execute(); | |
$sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM); | |
if ($sessionRows) { | |
if ($sessionRows[0][1] + $sessionRows[0][2] < time()) { | |
$this->sessionExpired = true; | |
return ''; | |
} | |
return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0]; | |
} | |
try { | |
$insertStmt = $this->pdo->prepare( | |
"INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)" | |
); | |
$insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); | |
$insertStmt->bindValue(':data', '', \PDO::PARAM_LOB); | |
$insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT); | |
$insertStmt->bindValue(':time', time(), \PDO::PARAM_INT); | |
$insertStmt->execute(); | |
} catch (\PDOException $e) { | |
// Catch duplicate key error because other connection created the session already. | |
// It would only not be the case when the other connection destroyed the session. | |
if (0 === strpos($e->getCode(), '23')) { | |
$this->rollback(); | |
$this->beginTransaction(); | |
continue; | |
} | |
throw $e; | |
} | |
return ''; | |
} while (true); | |
} | |
/** | |
* @return \PDO | |
*/ | |
protected function openConnection() | |
{ | |
if (null === $this->pdo) { | |
$this->pdo = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions); | |
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); | |
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); | |
} | |
return $this->pdo; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment