PdoSessionHandler.php 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
  11. use Doctrine\DBAL\Schema\Column;
  12. use Doctrine\DBAL\Schema\Index;
  13. use Doctrine\DBAL\Schema\Name\Identifier;
  14. use Doctrine\DBAL\Schema\Name\UnqualifiedName;
  15. use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
  16. use Doctrine\DBAL\Schema\Schema;
  17. use Doctrine\DBAL\Schema\Table;
  18. use Doctrine\DBAL\Types\Types;
  19. /**
  20. * Session handler using a PDO connection to read and write data.
  21. *
  22. * It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
  23. * different locking strategies to handle concurrent access to the same session.
  24. * Locking is necessary to prevent loss of data due to race conditions and to keep
  25. * the session data consistent between read() and write(). With locking, requests
  26. * for the same session will wait until the other one finished writing. For this
  27. * reason it's best practice to close a session as early as possible to improve
  28. * concurrency. PHPs internal files session handler also implements locking.
  29. *
  30. * Attention: Since SQLite does not support row level locks but locks the whole database,
  31. * it means only one session can be accessed at a time. Even different sessions would wait
  32. * for another to finish. So saving session in SQLite should only be considered for
  33. * development or prototypes.
  34. *
  35. * Session data is a binary string that can contain non-printable characters like the null byte.
  36. * For this reason it must be saved in a binary column in the database like BLOB in MySQL.
  37. * Saving it in a character column could corrupt the data. You can use createTable()
  38. * to initialize a correctly defined table.
  39. *
  40. * @see https://php.net/sessionhandlerinterface
  41. *
  42. * @author Fabien Potencier <fabien@symfony.com>
  43. * @author Michael Williams <michael.williams@funsational.com>
  44. * @author Tobias Schultze <http://tobion.de>
  45. */
  46. class PdoSessionHandler extends AbstractSessionHandler
  47. {
  48. /**
  49. * No locking is done. This means sessions are prone to loss of data due to
  50. * race conditions of concurrent requests to the same session. The last session
  51. * write will win in this case. It might be useful when you implement your own
  52. * logic to deal with this like an optimistic approach.
  53. */
  54. public const LOCK_NONE = 0;
  55. /**
  56. * Creates an application-level lock on a session. The disadvantage is that the
  57. * lock is not enforced by the database and thus other, unaware parts of the
  58. * application could still concurrently modify the session. The advantage is it
  59. * does not require a transaction.
  60. * This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
  61. */
  62. public const LOCK_ADVISORY = 1;
  63. /**
  64. * Issues a real row lock. Since it uses a transaction between opening and
  65. * closing a session, you have to be careful when you use same database connection
  66. * that you also use for your application logic. This mode is the default because
  67. * it's the only reliable solution across DBMSs.
  68. */
  69. public const LOCK_TRANSACTIONAL = 2;
  70. private \PDO $pdo;
  71. /**
  72. * DSN string or null for session.save_path or false when lazy connection disabled.
  73. */
  74. private string|false|null $dsn = false;
  75. private string $driver;
  76. private string $table = 'sessions';
  77. private string $idCol = 'sess_id';
  78. private string $dataCol = 'sess_data';
  79. private string $lifetimeCol = 'sess_lifetime';
  80. private string $timeCol = 'sess_time';
  81. /**
  82. * Time to live in seconds.
  83. */
  84. private int|\Closure|null $ttl;
  85. /**
  86. * Username when lazy-connect.
  87. */
  88. private ?string $username = null;
  89. /**
  90. * Password when lazy-connect.
  91. */
  92. private ?string $password = null;
  93. /**
  94. * Connection options when lazy-connect.
  95. */
  96. private array $connectionOptions = [];
  97. /**
  98. * The strategy for locking, see constants.
  99. */
  100. private int $lockMode = self::LOCK_TRANSACTIONAL;
  101. /**
  102. * It's an array to support multiple reads before closing which is manual, non-standard usage.
  103. *
  104. * @var \PDOStatement[] An array of statements to release advisory locks
  105. */
  106. private array $unlockStatements = [];
  107. /**
  108. * True when the current session exists but expired according to session.gc_maxlifetime.
  109. */
  110. private bool $sessionExpired = false;
  111. /**
  112. * Whether a transaction is active.
  113. */
  114. private bool $inTransaction = false;
  115. /**
  116. * Whether gc() has been called.
  117. */
  118. private bool $gcCalled = false;
  119. /**
  120. * You can either pass an existing database connection as PDO instance or
  121. * pass a DSN string that will be used to lazy-connect to the database
  122. * when the session is actually used. Furthermore it's possible to pass null
  123. * which will then use the session.save_path ini setting as PDO DSN parameter.
  124. *
  125. * List of available options:
  126. * * db_table: The name of the table [default: sessions]
  127. * * db_id_col: The column where to store the session id [default: sess_id]
  128. * * db_data_col: The column where to store the session data [default: sess_data]
  129. * * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
  130. * * db_time_col: The column where to store the timestamp [default: sess_time]
  131. * * db_username: The username when lazy-connect [default: '']
  132. * * db_password: The password when lazy-connect [default: '']
  133. * * db_connection_options: An array of driver-specific connection options [default: []]
  134. * * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
  135. * * ttl: The time to live in seconds.
  136. *
  137. * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null
  138. *
  139. * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
  140. */
  141. public function __construct(#[\SensitiveParameter] \PDO|string|null $pdoOrDsn = null, #[\SensitiveParameter] array $options = [])
  142. {
  143. if ($pdoOrDsn instanceof \PDO) {
  144. if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
  145. 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__));
  146. }
  147. $this->pdo = $pdoOrDsn;
  148. $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
  149. } elseif (\is_string($pdoOrDsn) && str_contains($pdoOrDsn, '://')) {
  150. $this->dsn = $this->buildDsnFromUrl($pdoOrDsn);
  151. } else {
  152. $this->dsn = $pdoOrDsn;
  153. }
  154. $this->table = $options['db_table'] ?? $this->table;
  155. $this->idCol = $options['db_id_col'] ?? $this->idCol;
  156. $this->dataCol = $options['db_data_col'] ?? $this->dataCol;
  157. $this->lifetimeCol = $options['db_lifetime_col'] ?? $this->lifetimeCol;
  158. $this->timeCol = $options['db_time_col'] ?? $this->timeCol;
  159. $this->username = $options['db_username'] ?? $this->username;
  160. $this->password = $options['db_password'] ?? $this->password;
  161. $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions;
  162. $this->lockMode = $options['lock_mode'] ?? $this->lockMode;
  163. $this->ttl = $options['ttl'] ?? null;
  164. }
  165. /**
  166. * Adds the Table to the Schema if it doesn't exist.
  167. *
  168. * @return Schema The (possibly new) schema with the table added
  169. */
  170. public function configureSchema(Schema $schema, ?\Closure $isSameDatabase = null)
  171. {
  172. if ($schema->hasTable($this->table) || ($isSameDatabase && !$isSameDatabase($this->getConnection()->exec(...)))) {
  173. return $schema;
  174. }
  175. if (method_exists($schema, 'edit')) {
  176. return $schema->edit()->addTable($this->buildSchemaTable())->create();
  177. }
  178. $this->configureSchemaTable($schema->createTable($this->table));
  179. return $schema;
  180. }
  181. private function buildSchemaTable(): Table
  182. {
  183. $editor = Table::editor()->setUnquotedName($this->table);
  184. switch ($this->driver) {
  185. case 'mysql':
  186. $editor
  187. ->addColumn(Column::editor()->setUnquotedName($this->idCol)->setTypeName(Types::BINARY)->setLength(128)->setNotNull(true)->create())
  188. ->addColumn(Column::editor()->setUnquotedName($this->dataCol)->setTypeName(Types::BLOB)->setNotNull(true)->create())
  189. ->addColumn(Column::editor()->setUnquotedName($this->lifetimeCol)->setTypeName(Types::INTEGER)->setUnsigned(true)->setNotNull(true)->create())
  190. ->addColumn(Column::editor()->setUnquotedName($this->timeCol)->setTypeName(Types::INTEGER)->setUnsigned(true)->setNotNull(true)->create())
  191. ->setOptions(['engine' => 'InnoDB']);
  192. break;
  193. case 'sqlite':
  194. $editor
  195. ->addColumn(Column::editor()->setUnquotedName($this->idCol)->setTypeName(Types::TEXT)->setNotNull(true)->create())
  196. ->addColumn(Column::editor()->setUnquotedName($this->dataCol)->setTypeName(Types::BLOB)->setNotNull(true)->create())
  197. ->addColumn(Column::editor()->setUnquotedName($this->lifetimeCol)->setTypeName(Types::INTEGER)->setNotNull(true)->create())
  198. ->addColumn(Column::editor()->setUnquotedName($this->timeCol)->setTypeName(Types::INTEGER)->setNotNull(true)->create());
  199. break;
  200. case 'pgsql':
  201. $editor
  202. ->addColumn(Column::editor()->setUnquotedName($this->idCol)->setTypeName(Types::STRING)->setLength(128)->setNotNull(true)->create())
  203. ->addColumn(Column::editor()->setUnquotedName($this->dataCol)->setTypeName(Types::BINARY)->setNotNull(true)->create())
  204. ->addColumn(Column::editor()->setUnquotedName($this->lifetimeCol)->setTypeName(Types::INTEGER)->setNotNull(true)->create())
  205. ->addColumn(Column::editor()->setUnquotedName($this->timeCol)->setTypeName(Types::INTEGER)->setNotNull(true)->create());
  206. break;
  207. case 'oci':
  208. $editor
  209. ->addColumn(Column::editor()->setUnquotedName($this->idCol)->setTypeName(Types::STRING)->setLength(128)->setNotNull(true)->create())
  210. ->addColumn(Column::editor()->setUnquotedName($this->dataCol)->setTypeName(Types::BLOB)->setNotNull(true)->create())
  211. ->addColumn(Column::editor()->setUnquotedName($this->lifetimeCol)->setTypeName(Types::INTEGER)->setNotNull(true)->create())
  212. ->addColumn(Column::editor()->setUnquotedName($this->timeCol)->setTypeName(Types::INTEGER)->setNotNull(true)->create());
  213. break;
  214. case 'sqlsrv':
  215. $editor
  216. ->addColumn(Column::editor()->setUnquotedName($this->idCol)->setTypeName(Types::STRING)->setLength(128)->setNotNull(true)->create())
  217. ->addColumn(Column::editor()->setUnquotedName($this->dataCol)->setTypeName(Types::BLOB)->setNotNull(true)->create())
  218. ->addColumn(Column::editor()->setUnquotedName($this->lifetimeCol)->setTypeName(Types::INTEGER)->setUnsigned(true)->setNotNull(true)->create())
  219. ->addColumn(Column::editor()->setUnquotedName($this->timeCol)->setTypeName(Types::INTEGER)->setUnsigned(true)->setNotNull(true)->create());
  220. break;
  221. default:
  222. throw new \DomainException(\sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
  223. }
  224. return $editor
  225. ->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted($this->idCol))], true))
  226. ->addIndex(Index::editor()->setUnquotedName($this->lifetimeCol.'_idx')->setUnquotedColumnNames($this->lifetimeCol)->create())
  227. ->create();
  228. }
  229. /**
  230. * To be removed when doctrine/dbal minimum is bumped to ^4.5.
  231. */
  232. private function configureSchemaTable(Table $table): void
  233. {
  234. switch ($this->driver) {
  235. case 'mysql':
  236. $table->addColumn($this->idCol, Types::BINARY, ['length' => 128, 'notnull' => true]);
  237. $table->addColumn($this->dataCol, Types::BLOB, ['notnull' => true]);
  238. $table->addColumn($this->lifetimeCol, Types::INTEGER, ['unsigned' => true, 'notnull' => true]);
  239. $table->addColumn($this->timeCol, Types::INTEGER, ['unsigned' => true, 'notnull' => true]);
  240. $table->addOption('engine', 'InnoDB');
  241. break;
  242. case 'sqlite':
  243. $table->addColumn($this->idCol, Types::TEXT, ['notnull' => true]);
  244. $table->addColumn($this->dataCol, Types::BLOB, ['notnull' => true]);
  245. $table->addColumn($this->lifetimeCol, Types::INTEGER, ['notnull' => true]);
  246. $table->addColumn($this->timeCol, Types::INTEGER, ['notnull' => true]);
  247. break;
  248. case 'pgsql':
  249. $table->addColumn($this->idCol, Types::STRING, ['length' => 128, 'notnull' => true]);
  250. $table->addColumn($this->dataCol, Types::BINARY, ['notnull' => true]);
  251. $table->addColumn($this->lifetimeCol, Types::INTEGER, ['notnull' => true]);
  252. $table->addColumn($this->timeCol, Types::INTEGER, ['notnull' => true]);
  253. break;
  254. case 'oci':
  255. $table->addColumn($this->idCol, Types::STRING, ['length' => 128, 'notnull' => true]);
  256. $table->addColumn($this->dataCol, Types::BLOB, ['notnull' => true]);
  257. $table->addColumn($this->lifetimeCol, Types::INTEGER, ['notnull' => true]);
  258. $table->addColumn($this->timeCol, Types::INTEGER, ['notnull' => true]);
  259. break;
  260. case 'sqlsrv':
  261. $table->addColumn($this->idCol, Types::STRING, ['length' => 128, 'notnull' => true]);
  262. $table->addColumn($this->dataCol, Types::BLOB, ['notnull' => true]);
  263. $table->addColumn($this->lifetimeCol, Types::INTEGER, ['unsigned' => true, 'notnull' => true]);
  264. $table->addColumn($this->timeCol, Types::INTEGER, ['unsigned' => true, 'notnull' => true]);
  265. break;
  266. default:
  267. throw new \DomainException(\sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
  268. }
  269. if (class_exists(PrimaryKeyConstraint::class)) {
  270. $table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted($this->idCol))], true));
  271. } else {
  272. $table->setPrimaryKey([$this->idCol]);
  273. }
  274. $table->addIndex([$this->lifetimeCol], $this->lifetimeCol.'_idx');
  275. }
  276. /**
  277. * Creates the table to store sessions which can be called once for setup.
  278. *
  279. * Session ID is saved in a column of maximum length 128 because that is enough even
  280. * for a 512 bit configured session.hash_function like Whirlpool. Session data is
  281. * saved in a BLOB. One could also use a shorter inlined varbinary column
  282. * if one was sure the data fits into it.
  283. *
  284. * @throws \PDOException When the table already exists
  285. * @throws \DomainException When an unsupported PDO driver is used
  286. */
  287. public function createTable(): void
  288. {
  289. // connect if we are not yet
  290. $this->getConnection();
  291. $sql = match ($this->driver) {
  292. // We use varbinary for the ID column because it prevents unwanted conversions:
  293. // - character set conversions between server and client
  294. // - trailing space removal
  295. // - case-insensitivity
  296. // - language processing like é == e
  297. 'mysql' => "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) ENGINE = InnoDB",
  298. 'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
  299. 'pgsql' => "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)",
  300. 'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
  301. 'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
  302. default => throw new \DomainException(\sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)),
  303. };
  304. try {
  305. $this->pdo->exec($sql);
  306. $this->pdo->exec("CREATE INDEX {$this->lifetimeCol}_idx ON $this->table ($this->lifetimeCol)");
  307. } catch (\PDOException $e) {
  308. $this->rollback();
  309. throw $e;
  310. }
  311. }
  312. /**
  313. * Returns true when the current session exists but expired according to session.gc_maxlifetime.
  314. *
  315. * Can be used to distinguish between a new session and one that expired due to inactivity.
  316. */
  317. public function isSessionExpired(): bool
  318. {
  319. return $this->sessionExpired;
  320. }
  321. public function open(string $savePath, string $sessionName): bool
  322. {
  323. $this->sessionExpired = false;
  324. if (!isset($this->pdo)) {
  325. $this->connect($this->dsn ?: $savePath);
  326. }
  327. return parent::open($savePath, $sessionName);
  328. }
  329. public function read(#[\SensitiveParameter] string $sessionId): string
  330. {
  331. try {
  332. return parent::read($sessionId);
  333. } catch (\PDOException $e) {
  334. $this->rollback();
  335. throw $e;
  336. }
  337. }
  338. public function gc(int $maxlifetime): int|false
  339. {
  340. // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
  341. // This way, pruning expired sessions does not block them from being started while the current session is used.
  342. $this->gcCalled = true;
  343. return 0;
  344. }
  345. protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
  346. {
  347. // delete the record associated with this id
  348. $sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
  349. try {
  350. $stmt = $this->pdo->prepare($sql);
  351. $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
  352. $stmt->execute();
  353. } catch (\PDOException $e) {
  354. $this->rollback();
  355. throw $e;
  356. }
  357. return true;
  358. }
  359. protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
  360. {
  361. $maxlifetime = (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'));
  362. try {
  363. // We use a single MERGE SQL query when supported by the database.
  364. $mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime);
  365. if (null !== $mergeStmt) {
  366. $mergeStmt->execute();
  367. return true;
  368. }
  369. $updateStmt = $this->getUpdateStatement($sessionId, $data, $maxlifetime);
  370. $updateStmt->execute();
  371. // When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in
  372. // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
  373. // We can just catch such an error and re-execute the update. This is similar to a serializable
  374. // transaction with retry logic on serialization failures but without the overhead and without possible
  375. // false positives due to longer gap locking.
  376. if (!$updateStmt->rowCount()) {
  377. try {
  378. $insertStmt = $this->getInsertStatement($sessionId, $data, $maxlifetime);
  379. $insertStmt->execute();
  380. } catch (\PDOException $e) {
  381. // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
  382. if (str_starts_with($e->getCode(), '23')) {
  383. $updateStmt->execute();
  384. } else {
  385. throw $e;
  386. }
  387. }
  388. }
  389. } catch (\PDOException $e) {
  390. $this->rollback();
  391. throw $e;
  392. }
  393. return true;
  394. }
  395. public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
  396. {
  397. $expiry = time() + (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'));
  398. try {
  399. $updateStmt = $this->pdo->prepare(
  400. "UPDATE $this->table SET $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id"
  401. );
  402. $updateStmt->bindValue(':id', $sessionId, \PDO::PARAM_STR);
  403. $updateStmt->bindValue(':expiry', $expiry, \PDO::PARAM_INT);
  404. $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
  405. $updateStmt->execute();
  406. } catch (\PDOException $e) {
  407. $this->rollback();
  408. throw $e;
  409. }
  410. return true;
  411. }
  412. public function close(): bool
  413. {
  414. $this->commit();
  415. while ($unlockStmt = array_shift($this->unlockStatements)) {
  416. $unlockStmt->execute();
  417. }
  418. if ($this->gcCalled) {
  419. $this->gcCalled = false;
  420. // delete the session records that have expired
  421. $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol < :time";
  422. $stmt = $this->pdo->prepare($sql);
  423. $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
  424. $stmt->execute();
  425. }
  426. if (false !== $this->dsn) {
  427. unset($this->pdo, $this->driver); // only close lazy-connection
  428. }
  429. return true;
  430. }
  431. /**
  432. * Lazy-connects to the database.
  433. */
  434. private function connect(#[\SensitiveParameter] string $dsn): void
  435. {
  436. $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
  437. $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
  438. $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
  439. }
  440. /**
  441. * Builds a PDO DSN from a URL-like connection string.
  442. *
  443. * @todo implement missing support for oci DSN (which look totally different from other PDO ones)
  444. */
  445. private function buildDsnFromUrl(#[\SensitiveParameter] string $dsnOrUrl): string
  446. {
  447. // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid
  448. $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $dsnOrUrl);
  449. $params = parse_url($url);
  450. if (false === $params) {
  451. return $dsnOrUrl; // If the URL is not valid, let's assume it might be a DSN already.
  452. }
  453. $params = array_map('rawurldecode', $params);
  454. // Override the default username and password. Values passed through options will still win over these in the constructor.
  455. if (isset($params['user'])) {
  456. $this->username = $params['user'];
  457. }
  458. if (isset($params['pass'])) {
  459. $this->password = $params['pass'];
  460. }
  461. if (!isset($params['scheme'])) {
  462. throw new \InvalidArgumentException('URLs without scheme are not supported to configure the PdoSessionHandler.');
  463. }
  464. $driverAliasMap = [
  465. 'mssql' => 'sqlsrv',
  466. 'mysql2' => 'mysql', // Amazon RDS, for some weird reason
  467. 'postgres' => 'pgsql',
  468. 'postgresql' => 'pgsql',
  469. 'sqlite3' => 'sqlite',
  470. ];
  471. $driver = $driverAliasMap[$params['scheme']] ?? $params['scheme'];
  472. // Doctrine DBAL supports passing its internal pdo_* driver names directly too (allowing both dashes and underscores). This allows supporting the same here.
  473. if (str_starts_with($driver, 'pdo_') || str_starts_with($driver, 'pdo-')) {
  474. $driver = substr($driver, 4);
  475. }
  476. $dsn = null;
  477. switch ($driver) {
  478. case 'mysql':
  479. $dsn = 'mysql:';
  480. if ('' !== ($params['query'] ?? '')) {
  481. $queryParams = [];
  482. parse_str($params['query'], $queryParams);
  483. if ('' !== ($queryParams['charset'] ?? '')) {
  484. $dsn .= 'charset='.$queryParams['charset'].';';
  485. }
  486. if ('' !== ($queryParams['unix_socket'] ?? '')) {
  487. $dsn .= 'unix_socket='.$queryParams['unix_socket'].';';
  488. if (isset($params['path'])) {
  489. $dbName = substr($params['path'], 1); // Remove the leading slash
  490. $dsn .= 'dbname='.$dbName.';';
  491. }
  492. return $dsn;
  493. }
  494. }
  495. // If "unix_socket" is not in the query, we continue with the same process as pgsql
  496. // no break
  497. case 'pgsql':
  498. $dsn ??= 'pgsql:';
  499. if (isset($params['host']) && '' !== $params['host']) {
  500. $dsn .= 'host='.$params['host'].';';
  501. }
  502. if (isset($params['port']) && '' !== $params['port']) {
  503. $dsn .= 'port='.$params['port'].';';
  504. }
  505. if (isset($params['path'])) {
  506. $dbName = substr($params['path'], 1); // Remove the leading slash
  507. $dsn .= 'dbname='.$dbName.';';
  508. }
  509. return $dsn;
  510. case 'sqlite':
  511. return 'sqlite:'.substr($params['path'], 1);
  512. case 'sqlsrv':
  513. $dsn = 'sqlsrv:server=';
  514. if (isset($params['host'])) {
  515. $dsn .= $params['host'];
  516. }
  517. if (isset($params['port']) && '' !== $params['port']) {
  518. $dsn .= ','.$params['port'];
  519. }
  520. if (isset($params['path'])) {
  521. $dbName = substr($params['path'], 1); // Remove the leading slash
  522. $dsn .= ';Database='.$dbName;
  523. }
  524. return $dsn;
  525. default:
  526. throw new \InvalidArgumentException(\sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme']));
  527. }
  528. }
  529. /**
  530. * Helper method to begin a transaction.
  531. *
  532. * Since SQLite does not support row level locks, we have to acquire a reserved lock
  533. * on the database immediately. Because of https://bugs.php.net/42766 we have to create
  534. * such a transaction manually which also means we cannot use PDO::commit or
  535. * PDO::rollback or PDO::inTransaction for SQLite.
  536. *
  537. * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
  538. * due to https://percona.com/blog/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
  539. * So we change it to READ COMMITTED.
  540. */
  541. private function beginTransaction(): void
  542. {
  543. if (!$this->inTransaction) {
  544. if ('sqlite' === $this->driver) {
  545. $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
  546. } else {
  547. if ('mysql' === $this->driver) {
  548. $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
  549. }
  550. $this->pdo->beginTransaction();
  551. }
  552. $this->inTransaction = true;
  553. }
  554. }
  555. /**
  556. * Helper method to commit a transaction.
  557. */
  558. private function commit(): void
  559. {
  560. if ($this->inTransaction) {
  561. try {
  562. // commit read-write transaction which also releases the lock
  563. if ('sqlite' === $this->driver) {
  564. $this->pdo->exec('COMMIT');
  565. } else {
  566. $this->pdo->commit();
  567. }
  568. $this->inTransaction = false;
  569. } catch (\PDOException $e) {
  570. $this->rollback();
  571. throw $e;
  572. }
  573. }
  574. }
  575. /**
  576. * Helper method to rollback a transaction.
  577. */
  578. private function rollback(): void
  579. {
  580. // We only need to rollback if we are in a transaction. Otherwise the resulting
  581. // error would hide the real problem why rollback was called. We might not be
  582. // in a transaction when not using the transactional locking behavior or when
  583. // two callbacks (e.g. destroy and write) are invoked that both fail.
  584. if ($this->inTransaction) {
  585. if ('sqlite' === $this->driver) {
  586. $this->pdo->exec('ROLLBACK');
  587. } else {
  588. $this->pdo->rollBack();
  589. }
  590. $this->inTransaction = false;
  591. }
  592. }
  593. /**
  594. * Reads the session data in respect to the different locking strategies.
  595. *
  596. * We need to make sure we do not return session data that is already considered garbage according
  597. * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
  598. */
  599. protected function doRead(#[\SensitiveParameter] string $sessionId): string
  600. {
  601. if (self::LOCK_ADVISORY === $this->lockMode) {
  602. $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
  603. }
  604. $selectSql = $this->getSelectSql();
  605. $selectStmt = $this->pdo->prepare($selectSql);
  606. $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
  607. $insertStmt = null;
  608. while (true) {
  609. $selectStmt->execute();
  610. $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
  611. if ($sessionRows) {
  612. $expiry = (int) $sessionRows[0][1];
  613. if ($expiry < time()) {
  614. $this->sessionExpired = true;
  615. return '';
  616. }
  617. return \is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
  618. }
  619. if (null !== $insertStmt) {
  620. $this->rollback();
  621. throw new \RuntimeException('Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.');
  622. }
  623. if (!filter_var(\ini_get('session.use_strict_mode'), \FILTER_VALIDATE_BOOL) && self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
  624. // In strict mode, session fixation is not possible: new sessions always start with a unique
  625. // random id, so that concurrency is not possible and this code path can be skipped.
  626. // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
  627. // until other connections to the session are committed.
  628. try {
  629. $insertStmt = $this->getInsertStatement($sessionId, '', 0);
  630. $insertStmt->execute();
  631. } catch (\PDOException $e) {
  632. // Catch duplicate key error because other connection created the session already.
  633. // It would only not be the case when the other connection destroyed the session.
  634. if (str_starts_with($e->getCode(), '23')) {
  635. // Retrieve finished session data written by concurrent connection by restarting the loop.
  636. // We have to start a new transaction as a failed query will mark the current transaction as
  637. // aborted in PostgreSQL and disallow further queries within it.
  638. $this->rollback();
  639. $this->beginTransaction();
  640. continue;
  641. }
  642. throw $e;
  643. }
  644. }
  645. return '';
  646. }
  647. }
  648. /**
  649. * Executes an application-level lock on the database.
  650. *
  651. * @return \PDOStatement The statement that needs to be executed later to release the lock
  652. *
  653. * @throws \DomainException When an unsupported PDO driver is used
  654. *
  655. * @todo implement missing advisory locks
  656. * - for oci using DBMS_LOCK.REQUEST
  657. * - for sqlsrv using sp_getapplock with LockOwner = Session
  658. */
  659. private function doAdvisoryLock(#[\SensitiveParameter] string $sessionId): \PDOStatement
  660. {
  661. switch ($this->driver) {
  662. case 'mysql':
  663. // MySQL 5.7.5 and later enforces a maximum length on lock names of 64 characters. Previously, no limit was enforced.
  664. $lockId = substr($sessionId, 0, 64);
  665. // should we handle the return value? 0 on timeout, null on error
  666. // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
  667. $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
  668. $stmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
  669. $stmt->execute();
  670. $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
  671. $releaseStmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
  672. return $releaseStmt;
  673. case 'pgsql':
  674. // Obtaining an exclusive session level advisory lock requires an integer key.
  675. // When session.sid_bits_per_character > 4, the session id can contain non-hex-characters.
  676. // So we cannot just use hexdec().
  677. if (4 === \PHP_INT_SIZE) {
  678. $sessionInt1 = $this->convertStringToInt($sessionId);
  679. $sessionInt2 = $this->convertStringToInt(substr($sessionId, 4, 4));
  680. $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
  681. $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
  682. $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
  683. $stmt->execute();
  684. $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
  685. $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
  686. $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
  687. } else {
  688. $sessionBigInt = $this->convertStringToInt($sessionId);
  689. $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
  690. $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
  691. $stmt->execute();
  692. $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
  693. $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
  694. }
  695. return $releaseStmt;
  696. case 'sqlite':
  697. throw new \DomainException('SQLite does not support advisory locks.');
  698. default:
  699. throw new \DomainException(\sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
  700. }
  701. }
  702. /**
  703. * Encodes the first 4 (when PHP_INT_SIZE == 4) or 8 characters of the string as an integer.
  704. *
  705. * Keep in mind, PHP integers are signed.
  706. */
  707. private function convertStringToInt(string $string): int
  708. {
  709. if (4 === \PHP_INT_SIZE) {
  710. return (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
  711. }
  712. $int1 = (\ord($string[7]) << 24) + (\ord($string[6]) << 16) + (\ord($string[5]) << 8) + \ord($string[4]);
  713. $int2 = (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
  714. return $int2 + ($int1 << 32);
  715. }
  716. /**
  717. * Return a locking or nonlocking SQL query to read session information.
  718. *
  719. * @throws \DomainException When an unsupported PDO driver is used
  720. */
  721. private function getSelectSql(): string
  722. {
  723. if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
  724. $this->beginTransaction();
  725. switch ($this->driver) {
  726. case 'mysql':
  727. case 'oci':
  728. case 'pgsql':
  729. return "SELECT $this->dataCol, $this->lifetimeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
  730. case 'sqlsrv':
  731. return "SELECT $this->dataCol, $this->lifetimeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
  732. case 'sqlite':
  733. // we already locked when starting transaction
  734. break;
  735. default:
  736. throw new \DomainException(\sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
  737. }
  738. }
  739. return "SELECT $this->dataCol, $this->lifetimeCol FROM $this->table WHERE $this->idCol = :id";
  740. }
  741. /**
  742. * Returns an insert statement supported by the database for writing session data.
  743. */
  744. private function getInsertStatement(#[\SensitiveParameter] string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement
  745. {
  746. switch ($this->driver) {
  747. case 'oci':
  748. $data = fopen('php://memory', 'r+');
  749. fwrite($data, $sessionData);
  750. rewind($data);
  751. $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), :expiry, :time) RETURNING $this->dataCol into :data";
  752. break;
  753. case 'sqlsrv':
  754. $data = fopen('php://memory', 'r+');
  755. fwrite($data, $sessionData);
  756. rewind($data);
  757. $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
  758. break;
  759. default:
  760. $data = $sessionData;
  761. $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
  762. break;
  763. }
  764. $stmt = $this->pdo->prepare($sql);
  765. $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
  766. $stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
  767. $stmt->bindValue(':expiry', time() + $maxlifetime, \PDO::PARAM_INT);
  768. $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
  769. return $stmt;
  770. }
  771. /**
  772. * Returns an update statement supported by the database for writing session data.
  773. */
  774. private function getUpdateStatement(#[\SensitiveParameter] string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement
  775. {
  776. switch ($this->driver) {
  777. case 'oci':
  778. $data = fopen('php://memory', 'r+');
  779. fwrite($data, $sessionData);
  780. rewind($data);
  781. $sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data";
  782. break;
  783. case 'sqlsrv':
  784. $data = fopen('php://memory', 'r+');
  785. fwrite($data, $sessionData);
  786. rewind($data);
  787. $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id";
  788. break;
  789. default:
  790. $data = $sessionData;
  791. $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id";
  792. break;
  793. }
  794. $stmt = $this->pdo->prepare($sql);
  795. $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
  796. $stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
  797. $stmt->bindValue(':expiry', time() + $maxlifetime, \PDO::PARAM_INT);
  798. $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
  799. return $stmt;
  800. }
  801. /**
  802. * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data.
  803. */
  804. private function getMergeStatement(#[\SensitiveParameter] string $sessionId, string $data, int $maxlifetime): ?\PDOStatement
  805. {
  806. switch (true) {
  807. case 'mysql' === $this->driver:
  808. $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time) ".
  809. "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
  810. break;
  811. case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
  812. // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
  813. // It also requires HOLDLOCK according to https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/
  814. $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
  815. "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
  816. "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
  817. break;
  818. case 'sqlite' === $this->driver:
  819. $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
  820. break;
  821. case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='):
  822. $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time) ".
  823. "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
  824. break;
  825. default:
  826. // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html
  827. return null;
  828. }
  829. $mergeStmt = $this->pdo->prepare($mergeSql);
  830. if ('sqlsrv' === $this->driver) {
  831. $dataStream = fopen('php://memory', 'r+');
  832. fwrite($dataStream, $data);
  833. rewind($dataStream);
  834. $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
  835. $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
  836. $mergeStmt->bindParam(3, $dataStream, \PDO::PARAM_LOB);
  837. $mergeStmt->bindValue(4, time() + $maxlifetime, \PDO::PARAM_INT);
  838. $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
  839. $mergeStmt->bindParam(6, $dataStream, \PDO::PARAM_LOB);
  840. $mergeStmt->bindValue(7, time() + $maxlifetime, \PDO::PARAM_INT);
  841. $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
  842. } else {
  843. $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
  844. $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
  845. $mergeStmt->bindValue(':expiry', time() + $maxlifetime, \PDO::PARAM_INT);
  846. $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
  847. }
  848. return $mergeStmt;
  849. }
  850. /**
  851. * Return a PDO instance.
  852. */
  853. protected function getConnection(): \PDO
  854. {
  855. if (!isset($this->pdo)) {
  856. $this->connect($this->dsn ?: \ini_get('session.save_path'));
  857. }
  858. return $this->pdo;
  859. }
  860. }