From 51fc4542b6bdf3670b0f21d2ee59e37f2904141a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 28 Mar 2022 20:19:17 +0200 Subject: [PATCH] [HttpFoundation] Allow dynamic session "ttl" when using a remote storage --- .../FrameworkBundle/Resources/config/session.php | 2 +- src/Symfony/Component/HttpFoundation/CHANGELOG.md | 1 + .../Storage/Handler/MemcachedSessionHandler.php | 9 ++++++--- .../Storage/Handler/MongoDbSessionHandler.php | 11 ++++++++--- .../Session/Storage/Handler/PdoSessionHandler.php | 11 +++++++++-- .../Storage/Handler/RedisSessionHandler.php | 9 ++++++--- .../Storage/Handler/SessionHandlerFactory.php | 15 ++++++++++----- .../AbstractRedisSessionHandlerTestCase.php | 13 +++++++++++++ .../Handler/MemcachedSessionHandlerTest.php | 2 +- .../Storage/Handler/PdoSessionHandlerTest.php | 15 +++++++++++++++ .../Storage/Handler/SessionHandlerFactoryTest.php | 4 ++++ 11 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php index 185e85838271c..30a622d02c8fb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php @@ -77,7 +77,7 @@ ->set('session.abstract_handler', AbstractSessionHandler::class) ->factory([SessionHandlerFactory::class, 'createHandler']) - ->args([abstract_arg('A string or a connection object')]) + ->args([abstract_arg('A string or a connection object'), []]) ->set('session_listener', SessionListener::class) ->args([ diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 04140a3050be1..fdbd39cead318 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add stale while revalidate and stale if error cache header + * Allow dynamic session "ttl" when using a remote storage 6.0 --- diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MemcachedSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MemcachedSessionHandler.php index ebea8ede379f3..fb7fae6154520 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MemcachedSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MemcachedSessionHandler.php @@ -26,7 +26,7 @@ class MemcachedSessionHandler extends AbstractSessionHandler /** * Time to live in seconds. */ - private ?int $ttl; + private int|\Closure|null $ttl; /** * Key prefix for shared environments. @@ -69,7 +69,8 @@ protected function doRead(string $sessionId): string public function updateTimestamp(string $sessionId, string $data): bool { - $this->memcached->touch($this->prefix.$sessionId, time() + (int) ($this->ttl ?? ini_get('session.gc_maxlifetime'))); + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime'); + $this->memcached->touch($this->prefix.$sessionId, time() + (int) $ttl); return true; } @@ -79,7 +80,9 @@ public function updateTimestamp(string $sessionId, string $data): bool */ protected function doWrite(string $sessionId, string $data): bool { - return $this->memcached->set($this->prefix.$sessionId, $data, time() + (int) ($this->ttl ?? ini_get('session.gc_maxlifetime'))); + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime'); + + return $this->memcached->set($this->prefix.$sessionId, $data, time() + (int) $ttl); } /** diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MongoDbSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MongoDbSessionHandler.php index f9957d3b74c54..e473dcbad4767 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MongoDbSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/MongoDbSessionHandler.php @@ -29,6 +29,7 @@ class MongoDbSessionHandler extends AbstractSessionHandler private Client $mongo; private Collection $collection; private array $options; + private int|\Closure|null $ttl; /** * Constructor. @@ -39,7 +40,8 @@ class MongoDbSessionHandler extends AbstractSessionHandler * * id_field: The field name for storing the session id [default: _id] * * data_field: The field name for storing the session data [default: data] * * time_field: The field name for storing the timestamp [default: time] - * * expiry_field: The field name for storing the expiry-timestamp [default: expires_at]. + * * expiry_field: The field name for storing the expiry-timestamp [default: expires_at] + * * ttl: The time to live in seconds. * * It is strongly recommended to put an index on the `expiry_field` for * garbage-collection. Alternatively it's possible to automatically expire @@ -74,6 +76,7 @@ public function __construct(Client $mongo, array $options) 'time_field' => 'time', 'expiry_field' => 'expires_at', ], $options); + $this->ttl = $this->options['ttl'] ?? null; } public function close(): bool @@ -105,7 +108,8 @@ public function gc(int $maxlifetime): int|false */ protected function doWrite(string $sessionId, string $data): bool { - $expiry = new UTCDateTime((time() + (int) ini_get('session.gc_maxlifetime')) * 1000); + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime'); + $expiry = new UTCDateTime((time() + (int) $ttl) * 1000); $fields = [ $this->options['time_field'] => new UTCDateTime(), @@ -124,7 +128,8 @@ protected function doWrite(string $sessionId, string $data): bool public function updateTimestamp(string $sessionId, string $data): bool { - $expiry = new UTCDateTime((time() + (int) ini_get('session.gc_maxlifetime')) * 1000); + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime'); + $expiry = new UTCDateTime((time() + (int) $ttl) * 1000); $this->getCollection()->updateOne( [$this->options['id_field'] => $sessionId], diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php index f43507ae55f0f..3e23bad1446bb 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php @@ -79,6 +79,11 @@ class PdoSessionHandler extends AbstractSessionHandler private string $lifetimeCol = 'sess_lifetime'; private string $timeCol = 'sess_time'; + /** + * Time to live in seconds. + */ + private int|\Closure|null $ttl; + /** * Username when lazy-connect. */ @@ -137,6 +142,7 @@ class PdoSessionHandler extends AbstractSessionHandler * * db_password: The password when lazy-connect [default: ''] * * db_connection_options: An array of driver-specific connection options [default: []] * * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL] + * * ttl: The time to live in seconds. * * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null * @@ -166,6 +172,7 @@ public function __construct(\PDO|string $pdoOrDsn = null, array $options = []) $this->password = $options['db_password'] ?? $this->password; $this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions; $this->lockMode = $options['lock_mode'] ?? $this->lockMode; + $this->ttl = $options['ttl'] ?? null; } /** @@ -275,7 +282,7 @@ protected function doDestroy(string $sessionId): bool */ protected function doWrite(string $sessionId, string $data): bool { - $maxlifetime = (int) ini_get('session.gc_maxlifetime'); + $maxlifetime = (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime')); try { // We use a single MERGE SQL query when supported by the database. @@ -318,7 +325,7 @@ protected function doWrite(string $sessionId, string $data): bool public function updateTimestamp(string $sessionId, string $data): bool { - $expiry = time() + (int) ini_get('session.gc_maxlifetime'); + $expiry = time() + (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime')); try { $updateStmt = $this->pdo->prepare( diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/RedisSessionHandler.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/RedisSessionHandler.php index d0896a585c0f5..d7a233fbb98df 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/RedisSessionHandler.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/RedisSessionHandler.php @@ -33,7 +33,7 @@ class RedisSessionHandler extends AbstractSessionHandler /** * Time to live in seconds. */ - private ?int $ttl; + private int|\Closure|null $ttl; /** * List of available options: @@ -66,7 +66,8 @@ protected function doRead(string $sessionId): string */ protected function doWrite(string $sessionId, string $data): bool { - $result = $this->redis->setEx($this->prefix.$sessionId, (int) ($this->ttl ?? ini_get('session.gc_maxlifetime')), $data); + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime'); + $result = $this->redis->setEx($this->prefix.$sessionId, (int) $ttl, $data); return $result && !$result instanceof ErrorInterface; } @@ -109,6 +110,8 @@ public function gc(int $maxlifetime): int|false public function updateTimestamp(string $sessionId, string $data): bool { - return $this->redis->expire($this->prefix.$sessionId, (int) ($this->ttl ?? ini_get('session.gc_maxlifetime'))); + $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? ini_get('session.gc_maxlifetime'); + + return $this->redis->expire($this->prefix.$sessionId, (int) $ttl); } } diff --git a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php index 19ff94c81c860..9ad2a109083ac 100644 --- a/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php +++ b/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/SessionHandlerFactory.php @@ -21,11 +21,16 @@ */ class SessionHandlerFactory { - public static function createHandler(object|string $connection): AbstractSessionHandler + public static function createHandler(object|string $connection, array $options = []): AbstractSessionHandler { - if ($options = \is_string($connection) ? parse_url($connection) : false) { - parse_str($options['query'] ?? '', $options); + if ($query = \is_string($connection) ? parse_url($connection) : false) { + parse_str($query['query'] ?? '', $query); + + if (($options['ttl'] ?? null) instanceof \Closure) { + $query['ttl'] = $options['ttl']; + } } + $options = ($query ?: []) + $options; switch (true) { case $connection instanceof \Redis: @@ -58,7 +63,7 @@ public static function createHandler(object|string $connection): AbstractSession $handlerClass = str_starts_with($connection, 'memcached:') ? MemcachedSessionHandler::class : RedisSessionHandler::class; $connection = AbstractAdapter::createConnection($connection, ['lazy' => true]); - return new $handlerClass($connection, array_intersect_key($options ?: [], ['prefix' => 1, 'ttl' => 1])); + return new $handlerClass($connection, array_intersect_key($options, ['prefix' => 1, 'ttl' => 1])); case str_starts_with($connection, 'pdo_oci://'): if (!class_exists(DriverManager::class)) { @@ -76,7 +81,7 @@ public static function createHandler(object|string $connection): AbstractSession case str_starts_with($connection, 'sqlsrv://'): case str_starts_with($connection, 'sqlite://'): case str_starts_with($connection, 'sqlite3://'): - return new PdoSessionHandler($connection, $options ?: []); + return new PdoSessionHandler($connection, $options); } throw new \InvalidArgumentException(sprintf('Unsupported Connection: "%s".', $connection)); diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php index 0cd6a83cf49a2..406f84051ab11 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php @@ -164,6 +164,19 @@ public function testUseTtlOption(int $ttl) $this->assertLessThan($redisTtl, $ttl - 5); $this->assertGreaterThan($redisTtl, $ttl + 5); + + $options = [ + 'prefix' => self::PREFIX, + 'ttl' => fn () => $ttl, + ]; + + $handler = new RedisSessionHandler($this->redisClient, $options); + $handler->write('id', 'data'); + $redisTtl = $this->redisClient->ttl(self::PREFIX.'id'); + + $this->assertLessThan($redisTtl, $ttl - 5); + $this->assertGreaterThan($redisTtl, $ttl + 5); + } public function getTtlFixtures(): array diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php index d535989e6477c..eac380fd9094a 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php @@ -134,7 +134,7 @@ public function getOptionFixtures() return [ [['prefix' => 'session'], true], [['expiretime' => 100], true], - [['prefix' => 'session', 'expiretime' => 200], true], + [['prefix' => 'session', 'ttl' => 200], true], [['expiretime' => 100, 'foo' => 'bar'], false], ]; } diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php index f9b42c7cccffb..d77d062924e84 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php @@ -345,6 +345,21 @@ public function provideUrlDsnPairs() yield ['mssql://localhost:56/test', 'sqlsrv:server=localhost,56;Database=test']; } + public function testTtl() + { + foreach ([60, fn () => 60] as $ttl) { + $pdo = $this->getMemorySqlitePdo(); + $storage = new PdoSessionHandler($pdo, ['ttl' => $ttl]); + + $storage->open('', 'sid'); + $storage->read('id'); + $storage->write('id', 'data'); + $storage->close(); + + $this->assertEqualsWithDelta(time() + 60, $pdo->query('SELECT sess_lifetime FROM sessions')->fetchColumn(), 5); + } + } + /** * @return resource */ diff --git a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php index 74f7ebb8d00f2..1dee1b3fbe219 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php @@ -71,5 +71,9 @@ public function testCreateRedisHandlerFromDsn() $ttlProperty = $reflection->getProperty('ttl'); $this->assertSame(3600, $ttlProperty->getValue($handler)); + + $handler = SessionHandlerFactory::createHandler('redis://localhost?prefix=foo&ttl=3600&ignored=bar', ['ttl' => function () { return 123; }]); + + $this->assertInstanceOf(\Closure::class, $reflection->getProperty('ttl')->getValue($handler)); } }