Skip to content

Commit e7dd735

Browse files
committed
NEW Add cache-based session handler implementation
The intention is to use in-memory cache adapters such as Redis and Memcached - though theoretically any PSR16 adapter would work.
1 parent 9885b46 commit e7dd735

File tree

7 files changed

+281
-20
lines changed

7 files changed

+281
-20
lines changed

_config/session.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
Name: session-handlers
3+
---
4+
SilverStripe\Core\Injector\Injector:
5+
SilverStripe\Control\SessionHandler\CacheSessionHandler:
6+
constructor:
7+
cacheAdapter: '%$Psr\SimpleCache\CacheInterface.session-handler'
8+
9+
SilverStripe\Core\Cache\RedisCacheFactory:
10+
constructor:
11+
logger: '%$Psr\Log\LoggerInterface'
12+
13+
SilverStripe\Core\Cache\MemcachedCacheFactory:
14+
constructor:
15+
logger: '%$Psr\Log\LoggerInterface'
16+
17+
Psr\SimpleCache\CacheInterface.session-handler:
18+
factory: '`SS_SESSION_CACHE_FACTORY`'
19+
constructor:
20+
namespace: 'session-handler'
21+
defaultLifetime: 0
22+
useInjector: true
23+
# Use of the DefaultCacheFactory shouldn't be encouraged, but
24+
# is technically valid so we have to disable containers to avoid
25+
# weird behaviour with versioned, etc.
26+
disable-container: true
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace SilverStripe\Control\SessionHandler;
4+
5+
use SessionHandlerInterface;
6+
use SessionUpdateTimestampHandlerInterface;
7+
use SilverStripe\Control\Session;
8+
9+
abstract class AbstractSessionHandler implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
10+
{
11+
/**
12+
* Get the session lifetime in seconds.
13+
* Returns the cookie lifetime if it's non-zero, otherwise returns the garbage collection lifetime.
14+
*/
15+
protected function getLifetime(): int
16+
{
17+
$cookieLifetime = (int) Session::config()->get('timeout');
18+
if ($cookieLifetime) {
19+
return $cookieLifetime;
20+
}
21+
return (int) ini_get('session.gc_maxlifetime');
22+
}
23+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace SilverStripe\Control\SessionHandler;
4+
5+
use SensitiveParameter;
6+
use Psr\SimpleCache\CacheInterface;
7+
8+
/**
9+
* Session save handler that stores session data in an in a PSR-16 cache adapter.
10+
*/
11+
class CacheSessionHandler extends AbstractSessionHandler
12+
{
13+
private CacheInterface $cacheHandler;
14+
15+
public function __construct(CacheInterface $cacheHandler)
16+
{
17+
$this->cacheHandler = $cacheHandler;
18+
}
19+
20+
public function open(string $path, string $name): bool
21+
{
22+
// No action is required to open the session.
23+
return true;
24+
}
25+
26+
public function close(): bool
27+
{
28+
// No action is required to close the session.
29+
return true;
30+
}
31+
32+
/**
33+
* @inheritDoc
34+
* Clears the cache entry that represents this session ID.
35+
*/
36+
public function destroy(#[SensitiveParameter] string $id): bool
37+
{
38+
return $this->cacheHandler->delete($id);
39+
}
40+
41+
/**
42+
* @inheritDoc
43+
* Clears all session cache which have a last modified datetime older than the session max lifetime.
44+
*/
45+
public function gc(int $max_lifetime): int|false
46+
{
47+
// No action required - the cache adapter handles GC itself.
48+
return 0;
49+
}
50+
51+
/**
52+
* @inheritDoc
53+
* Returns data of a pre-existing session, or an empty string for a new session.
54+
*/
55+
public function read(#[SensitiveParameter] string $id): string|false
56+
{
57+
return $this->cacheHandler->get($id, '');
58+
}
59+
60+
/**
61+
* @inheritDoc
62+
* Writes session data to a cache entry.
63+
*/
64+
public function write(#[SensitiveParameter] string $id, string $data): bool
65+
{
66+
return $this->cacheHandler->set($id, $data, $this->getLifetime());
67+
}
68+
69+
/**
70+
* @inheritDoc
71+
* A session ID is valid if an entry for that session ID already exists and has not expired.
72+
*/
73+
public function validateId(#[SensitiveParameter] string $id): bool
74+
{
75+
return $this->cacheHandler->has($id);
76+
}
77+
78+
/**
79+
* @inheritDoc
80+
* Called instead of write if session.lazy_write is enabled and no data has changed for this session.
81+
*/
82+
public function updateTimestamp(#[SensitiveParameter] string $id, string $data): bool
83+
{
84+
// The cache interface doesn't let us just update TTL, so we have to set the data at the same time.
85+
return $this->write($id, $data);
86+
}
87+
}

src/Control/SessionHandler/FileSessionHandler.php

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@
77
use Psr\Log\LoggerInterface;
88
use RuntimeException;
99
use SensitiveParameter;
10-
use SessionHandlerInterface;
11-
use SessionUpdateTimestampHandlerInterface;
12-
use SilverStripe\Control\Session;
1310
use SilverStripe\Core\Injector\Injector;
1411
use SilverStripe\Core\Path;
1512
use SilverStripe\ORM\FieldType\DBDatetime;
@@ -23,7 +20,7 @@
2320
* Similar to PHP's default filesystem session handler, except it doesn't lock the session file meaning
2421
* sessions are non-blocking.
2522
*/
26-
class FileSessionHandler implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
23+
class FileSessionHandler extends AbstractSessionHandler
2724
{
2825
public const string SESSION_FILE_PREFIX = 'sess_';
2926

@@ -329,18 +326,6 @@ private function isSessionExpired(#[SensitiveParameter] string $path): bool
329326
return $mTime < (DBDatetime::now()->getTimestamp() - $maxLifeInSeconds);
330327
}
331328

332-
/**
333-
* Returns the cookie lifetime if it's non-zero, otherwise returns the garbage collection lifetime.
334-
*/
335-
private function getLifetime(): int
336-
{
337-
$cookieLifetime = (int) Session::config()->get('lifetime');
338-
if ($cookieLifetime) {
339-
return $cookieLifetime;
340-
}
341-
return (int) ini_get('session.gc_maxlifetime');
342-
}
343-
344329
private function logError(string $message): void
345330
{
346331
Injector::inst()->get(LoggerInterface::class)->error($message);
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
namespace SilverStripe\Control\Tests\SessionHandler;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use ReflectionProperty;
7+
use SilverStripe\Control\SessionHandler\CacheSessionHandler;
8+
use SilverStripe\Dev\SapphireTest;
9+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
10+
use Symfony\Component\Cache\Psr16Cache;
11+
12+
class CacheSessionHandlerTest extends SapphireTest
13+
{
14+
protected $usesDatabase = false;
15+
16+
public static function provideRead(): array
17+
{
18+
return [
19+
[
20+
'sessionID' => 'existing-session',
21+
'expected' => 'some-data',
22+
],
23+
[
24+
'sessionID' => 'new-session',
25+
'expected' => '',
26+
],
27+
];
28+
}
29+
30+
#[DataProvider('provideRead')]
31+
public function testRead(string $sessionID, string $expected): void
32+
{
33+
$cacheAdapter = new Psr16Cache(new ArrayAdapter());
34+
$handler = new CacheSessionHandler($cacheAdapter);
35+
$cacheAdapter->set('existing-session', 'some-data');
36+
$this->assertSame($expected, $handler->read($sessionID));
37+
}
38+
39+
public function testReadExpired(): void
40+
{
41+
$cacheAdapter = new Psr16Cache(new ArrayAdapter());
42+
$handler = new CacheSessionHandler($cacheAdapter);
43+
$cacheAdapter->set('existing-session', 'some-data', -1);
44+
$this->assertSame('', $handler->read('existing-session'));
45+
}
46+
47+
public static function provideWrite(): array
48+
{
49+
return [
50+
[
51+
'sessionID' => 'existing-session',
52+
],
53+
[
54+
'sessionID' => 'new-session',
55+
],
56+
];
57+
}
58+
59+
#[DataProvider('provideWrite')]
60+
public function testWrite(string $sessionID): void
61+
{
62+
$cacheAdapter = new Psr16Cache(new ArrayAdapter());
63+
$handler = new CacheSessionHandler($cacheAdapter);
64+
$cacheAdapter->set('existing-session', 'some-data');
65+
$handler->write($sessionID, 'updated-data');
66+
$this->assertSame('updated-data', $cacheAdapter->get($sessionID));
67+
}
68+
69+
public static function provideDestroy(): array
70+
{
71+
return [
72+
[
73+
'sessionID' => 'existing-session',
74+
],
75+
[
76+
'sessionID' => 'new-session',
77+
],
78+
];
79+
}
80+
81+
#[DataProvider('provideDestroy')]
82+
public function testDestroy(string $sessionID): void
83+
{
84+
$cacheAdapter = new Psr16Cache(new ArrayAdapter());
85+
$handler = new CacheSessionHandler($cacheAdapter);
86+
$cacheAdapter->set('existing-session', 'some-data');
87+
$this->assertTrue($handler->destroy($sessionID));
88+
$this->assertNull($cacheAdapter->get($sessionID));
89+
}
90+
91+
public static function provideValidateId(): array
92+
{
93+
return [
94+
'new session (no file) is invalid' => [
95+
'sessionID' => 'new-session',
96+
'isExpired' => false,
97+
'expected' => false,
98+
],
99+
'existing session is valid' => [
100+
'sessionID' => 'existing-session',
101+
'isExpired' => false,
102+
'expected' => true,
103+
],
104+
'expired existing session is invalid' => [
105+
'sessionID' => 'existing-session',
106+
'isExpired' => true,
107+
'expected' => false,
108+
],
109+
];
110+
}
111+
112+
#[DataProvider('provideValidateId')]
113+
public function testValidateId(string $sessionID, bool $isExpired, bool $expected): void
114+
{
115+
$cacheAdapter = new Psr16Cache(new ArrayAdapter());
116+
$handler = new CacheSessionHandler($cacheAdapter);
117+
$cacheAdapter->set('existing-session', 'some-data', $isExpired ? -1 : 60);
118+
$this->assertSame($expected, $handler->validateId($sessionID));
119+
}
120+
121+
public function testUpdateTimestamp(): void
122+
{
123+
$arrayAdapter = new ArrayAdapter();
124+
$cacheAdapter = new Psr16Cache($arrayAdapter);
125+
$handler = new CacheSessionHandler($cacheAdapter);
126+
$cacheAdapter->set('existing-session', 'some-data', 999999999);
127+
128+
$this->assertTrue($handler->updateTimestamp('existing-session', 'new content'));
129+
$this->assertTrue($cacheAdapter->has('existing-session'));
130+
$reflectionExpiries = new ReflectionProperty($arrayAdapter, 'expiries');
131+
$reflectionExpiries->setAccessible(true);
132+
$expiry = $reflectionExpiries->getValue($arrayAdapter)['existing-session'];
133+
134+
// 999999 is way more than the number of seconds the session should live for
135+
// so calling updateTimestamp should set it to less than that but more than right now
136+
// We can't do an exact time check because that would obviously introduce timing issues
137+
// and Symfony's cache stuff doesn't use our internal DateTime so we can't set a mock now.
138+
$this->assertLessThan(microtime(true) + 999999, $expiry);
139+
$this->assertGreaterThan(microtime(true), $expiry);
140+
$this->assertSame('new content', $cacheAdapter->get('existing-session'));
141+
}
142+
}

tests/php/Control/SessionHandler/FileSessionHandlerTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ public function testGc(int $gcLifetime, int $configLifetime, array $sessionFiles
481481
$handler->open($baseDir, 'PHPSESSID');
482482

483483
ini_set('session.gc_maxlifetime', $gcLifetime);
484-
Session::config()->set('lifetime', $configLifetime);
484+
Session::config()->set('timeout', $configLifetime);
485485

486486
try {
487487
$this->withSessionExpiry($nonSessionFilePath, function () use ($sessionFilesLifetimeMap, $handler, $nonSessionFilePath, $expectDeleted) {

tests/php/ORM/DataListTest.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -865,9 +865,7 @@ public static function provideDefaultSort(): array
865865
];
866866
}
867867

868-
/**
869-
* @dataProvider provideDefaultSort
870-
*/
868+
#[DataProvider('provideDefaultSort')]
871869
public function testDefaultSort(string|array $defaultSort, array $expected): void
872870
{
873871
// Prepare fixtures - we need some comments to be identical for the "two items" scenarios

0 commit comments

Comments
 (0)