From 1ade2accc0ca3d4e092bcf0a6966070d935a25b6 Mon Sep 17 00:00:00 2001 From: Oleg Kasyanov Date: Sun, 21 Jan 2024 20:08:19 +0400 Subject: [PATCH] Session subsystem in a separate package --- composer.json | 1 + composer.lock | 72 ++++++- install/sql/system.sql | 9 - .../CannotWhiteTimestampException.php | 15 -- src/Session/SessionHandler.php | 157 --------------- src/Session/SessionInterface.php | 21 -- src/Session/SessionMiddleware.php | 28 --- src/Session/SessionMiddlewareFactory.php | 57 ------ tests/unit/Session/SessionHandlerTest.php | 181 ------------------ .../Session/SessionMiddlewareFactoryTest.php | 122 ------------ tests/unit/Session/SessionMiddlewareTest.php | 64 ------- 11 files changed, 71 insertions(+), 656 deletions(-) delete mode 100644 install/sql/system.sql delete mode 100644 src/Session/Exception/CannotWhiteTimestampException.php delete mode 100644 src/Session/SessionHandler.php delete mode 100644 src/Session/SessionInterface.php delete mode 100644 src/Session/SessionMiddleware.php delete mode 100644 src/Session/SessionMiddlewareFactory.php delete mode 100644 tests/unit/Session/SessionHandlerTest.php delete mode 100644 tests/unit/Session/SessionMiddlewareFactoryTest.php delete mode 100644 tests/unit/Session/SessionMiddlewareTest.php diff --git a/composer.json b/composer.json index 5a1e410..0fe6f29 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "httpsoft/http-basis": "^1.1", "mobicms/config": "^1.0", "mobicms/render": "4.0", + "mobicms/session": "dev-main", "monolog/monolog": "^3.5", "psr/container": "^2.0" }, diff --git a/composer.lock b/composer.lock index 407e6f6..7c2ec9f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f7f0710c581994d2dfc668ee86295fb5", + "content-hash": "391d7f20165003774e407965d91d4dec", "packages": [ { "name": "filp/whoops", @@ -778,6 +778,72 @@ }, "time": "2022-12-09T13:18:22+00:00" }, + { + "name": "mobicms/session", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/mobicms/session.git", + "reference": "97d9784858284d485766e53a7b3b0eea7e61a6c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mobicms/session/zipball/97d9784858284d485766e53a7b3b0eea7e61a6c6", + "reference": "97d9784858284d485766e53a7b3b0eea7e61a6c6", + "shasum": "" + }, + "require": { + "ext-pdo": "*", + "httpsoft/http-cookie": "^1.1", + "php": "~8.2 || ~8.3", + "psr/container": "^2.0", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "httpsoft/http-response": "^1.1", + "mobicms/config": "^1.0", + "mobicms/testutils": "^1.2", + "phpunit/phpunit": "^10.5", + "slevomat/coding-standard": "^8.14", + "squizlabs/php_codesniffer": "^3.8", + "vimeo/psalm": "^5.20" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Mobicms\\Session\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Oleg Kasyanov", + "email": "oleg@batumi.org", + "homepage": "https://github.com/batumibiz", + "role": "Team Lead, Developer" + }, + { + "name": "mobiCMS Contributors", + "homepage": "https://github.com/mobicms/session/graphs/contributors" + } + ], + "description": "Session handler", + "homepage": "https://mobicms.org", + "keywords": [ + "mobicms" + ], + "support": { + "issues": "https://github.com/mobicms/session/issues", + "source": "https://github.com/mobicms/session" + }, + "time": "2024-01-21T13:00:40+00:00" + }, { "name": "monolog/monolog", "version": "3.5.0", @@ -4942,7 +5008,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": { + "mobicms/session": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/install/sql/system.sql b/install/sql/system.sql deleted file mode 100644 index ddfc308..0000000 --- a/install/sql/system.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS `system__session`; -CREATE TABLE `system__session` -( - `session_id` VARBINARY(128) NOT NULL PRIMARY KEY, - `modified` INT(10) UNSIGNED NOT NULL DEFAULT 0, - `data` BLOB NOT NULL -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4 - COLLATE utf8mb4_bin; diff --git a/src/Session/Exception/CannotWhiteTimestampException.php b/src/Session/Exception/CannotWhiteTimestampException.php deleted file mode 100644 index f202149..0000000 --- a/src/Session/Exception/CannotWhiteTimestampException.php +++ /dev/null @@ -1,15 +0,0 @@ -pdo = $pdo; - $this->cookieManager = $cookieManager; - $this->resolveOptions($options); - } - - public function startSession(ServerRequestInterface $request): void - { - $id = trim((string) ($request->getCookieParams()[$this->cookieName] ?? '')); - - if (! empty($id) && strlen($id) == 32) { - $this->sessionId = $id; - - $stmt = $this->pdo->prepare('SELECT * FROM `system__session` WHERE `session_id` = :id'); - $stmt->bindValue(':id', $id); - $stmt->execute(); - /** @var array|false $result */ - $result = $stmt->fetch(); - - if ($result !== false && $result['modified'] > time() - $this->lifeTime) { - $this->data = (array) unserialize((string) $result['data'], ['allowed_classes' => false]); - } - } - } - - public function get(string $name, mixed $default = null): mixed - { - return $this->data[$name] ?? $default; - } - - public function has(string $name): bool - { - return array_key_exists($name, $this->data); - } - - public function set(string $name, mixed $value): void - { - $this->data[$name] = $value; - } - - public function unset(string $name): void - { - unset($this->data[$name]); - } - - public function clear(): void - { - $this->data = []; - } - - public function persistSession(ResponseInterface $response): ResponseInterface - { - if ('' === $this->sessionId && [] === $this->data) { - return $response; - } - - $id = '' === $this->sessionId ? bin2hex(random_bytes(16)) : $this->sessionId; - - $stmt = $this->pdo->prepare( - 'INSERT INTO `system__session` - (`session_id`, `modified`, `data`) - VALUES(:id, :modified, :data) - ON DUPLICATE KEY UPDATE - `modified` = :modified, - `data` = :data' - ); - - $stmt->bindValue(':id', $id); - $stmt->bindValue(':modified', time(), PDO::PARAM_INT); - $stmt->bindValue(':data', serialize($this->data)); - $stmt->execute(); - - return $this->sendCookies($id, $response); - } - - public function garbageCollector(): void - { - $stmt = $this->pdo->prepare('DELETE FROM `system__session` WHERE `modified` < :duration'); - $stmt->bindValue(':duration', time() - $this->lifeTime, PDO::PARAM_INT); - $stmt->execute(); - } - - private function sendCookies(string $id, ResponseInterface $response): ResponseInterface - { - $this->cookieManager->set( - new Cookie( - $this->cookieName, - $id, - null, - $this->cookieDomain, - $this->cookiePath, - $this->cookieSecure, - $this->cookieHttpOnly - ) - ); - - $response = $response->withHeader('Expires', 'Thu, 19 Nov 1981 08:52:00 GMT'); - $response = $response->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - - return $response->withHeader('Pragma', 'no-cache'); - } - - private function resolveOptions(array $options): void - { - if (isset($options['cookie_name'])) { - $this->cookieName = (string) $options['cookie_name']; - } - - if (isset($options['cookie_domain'])) { - $this->cookieDomain = (string) $options['cookie_domain']; - } - - if (isset($options['cookie_path'])) { - $this->cookiePath = (string) $options['cookie_path']; - } - - if (isset($options['cookie_secure'])) { - $this->cookieSecure = (bool) $options['cookie_secure']; - } - - if (isset($options['cookie_http_only'])) { - $this->cookieHttpOnly = (bool) $options['cookie_http_only']; - } - - if (isset($options['lifetime'])) { - $this->lifeTime = (int) $options['lifetime']; - } - } -} diff --git a/src/Session/SessionInterface.php b/src/Session/SessionInterface.php deleted file mode 100644 index 814bc56..0000000 --- a/src/Session/SessionInterface.php +++ /dev/null @@ -1,21 +0,0 @@ -session = $session; - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $this->session->startSession($request); - $response = $handler->handle($request->withAttribute(SessionInterface::class, $this->session)); - - return $this->session->persistSession($response); - } -} diff --git a/src/Session/SessionMiddlewareFactory.php b/src/Session/SessionMiddlewareFactory.php deleted file mode 100644 index dfa7e80..0000000 --- a/src/Session/SessionMiddlewareFactory.php +++ /dev/null @@ -1,57 +0,0 @@ -get(ConfigInterface::class); - $config = (array) $configContainer->get('session', []); - - $session = new SessionHandler( - $container->get(PDO::class), - $container->get(CookieManagerInterface::class), - $config - ); - - if ($this->checkGc($config)) { - $session->garbageCollector(); - } - - return new SessionMiddleware($session); - } - - public function checkGc(array $config): bool - { - $file = (string) ($config['gc_timestamp_file'] ?? ''); - $gcPeriod = (int) ($config['gc_period'] ?? 3600); - - if (! is_writable(dirname($file))) { - throw new CannotWhiteTimestampException($file); - } - - if (file_exists($file)) { - if (filemtime($file) < time() - $gcPeriod) { - touch($file); - return true; - } - } else { - touch($file); - } - - return false; - } -} diff --git a/tests/unit/Session/SessionHandlerTest.php b/tests/unit/Session/SessionHandlerTest.php deleted file mode 100644 index 4413b42..0000000 --- a/tests/unit/Session/SessionHandlerTest.php +++ /dev/null @@ -1,181 +0,0 @@ - 'TESTSESSION', - 'cookie_domain' => 'localhost', - 'cookie_path' => '/', - 'cookie_secure' => false, - 'cookie_http_only' => true, - 'lifetime' => 10800, - ]; - - public function setUp(): void - { - $loader = new SqlDumpLoader(self::getPdo()); - $loader->loadFile('install/sql/system.sql'); - - if ($loader->hasErrors()) { - $this->fail(implode("\n", $loader->getErrors())); - } - - $this->session = new SessionHandler( - self::getPdo(), - $this->createMock(CookieManagerInterface::class), - $this->options - ); - $this->request = $this->createMock(ServerRequestInterface::class); - $this->request - ->method('getCookieParams') - ->willReturn(['TESTSESSION' => 'ssssssssssssssssssssssssssssssss']); - } - - public function testImplementsSessionInterface(): void - { - $this->assertInstanceOf(SessionInterface::class, $this->session); - } - - public function testHasMethodWithExistingKey(): void - { - $this->initializeSessionWithData(); - $this->assertTrue($this->session->has('foo')); - } - - public function testHasMethodWithNonExistentKey(): void - { - $this->initializeSessionWithData(); - $this->assertFalse($this->session->has('bar')); - } - - public function testGetMethodWithExistingKey(): void - { - $this->initializeSessionWithData(); - $this->assertSame('test-session', $this->session->get('foo')); - } - - public function testGetMethodWithNonExistentKeyReturnNull(): void - { - $this->initializeSessionWithData(); - $this->assertNull($this->session->get('bar')); - } - - public function testGetMethodWithNonExistentKeyReturnDefaultValue(): void - { - $this->initializeSessionWithData(); - $this->assertSame('mydata', $this->session->get('bar', 'mydata')); - } - - public function testUnsetMethod(): void - { - $this->initializeSessionWithData(); - $this->assertTrue($this->session->has('foo')); - $this->session->unset('foo'); - $this->session->unset('bar'); - $this->assertFalse($this->session->has('foo')); - } - - public function testSetMethod(): void - { - $this->initializeSessionWithData(); - $this->session->set('foo', 'newdata'); - $this->session->set('baz', 'bat'); - $this->assertSame('newdata', $this->session->get('foo')); - $this->assertSame('bat', $this->session->get('baz')); - } - - public function testClearMethod(): void - { - $this->initializeSessionWithData(); - $this->session->set('baz', 'bat'); - $this->session->clear(); - $this->assertFalse($this->session->has('foo')); - $this->assertFalse($this->session->has('baz')); - } - - public function testWithExpiredData(): void - { - $this->initializeSessionWithData(30000); - $this->assertFalse($this->session->has('foo')); - } - - public function testGarbageCollector(): void - { - $this->initializeSessionWithData(30000); - $this->session->garbageCollector(); - $query = self::getPdo()->query( - "SELECT * FROM `system__session` WHERE `session_id` = 'ssssssssssssssssssssssssssssssss'" - ); - $this->assertEquals(0, $query->rowCount()); - } - - public function testPersistenceWithExistingSessionId(): void - { - $this->initializeSessionWithData(); - $this->session->set('baz', 'bat'); - $this->session->persistSession(new Response()); - - $session2 = new SessionHandler( - self::getPdo(), - $this->createMock(CookieManagerInterface::class), - $this->options - ); - $session2->startSession($this->request); - $this->assertEquals('bat', $session2->get('baz')); - } - - public function testPeresistenceGenerateNewsessionId(): void - { - $this->session->startSession($this->createMock(ServerRequestInterface::class)); - $this->session->set('baz', 'bat'); - $this->session->persistSession(new Response()); - - $id = self::getPdo()->query('SELECT * FROM `system__session`')->fetchColumn(); - $request = $this->createMock(ServerRequestInterface::class); - $request - ->method('getCookieParams') - ->willReturn(['TESTSESSION' => $id]); - - $newSession = new SessionHandler( - self::getPdo(), - $this->createMock(CookieManagerInterface::class), - $this->options - ); - $newSession->startSession($request); - $this->assertEquals('bat', $newSession->get('baz')); - } - - public function testPersistenceIfNoIdAndNoData(): void - { - $this->session->startSession($this->createMock(ServerRequestInterface::class)); - $this->session->persistSession(new Response()); - $query = self::getPdo()->query('SELECT * FROM `system__session`'); - $this->assertEquals(0, $query->rowCount()); - } - - private function initializeSessionWithData(int $modified = 0): void - { - self::getPdo()->prepare( - "INSERT INTO `system__session` - SET - `session_id` = ?, - `modified` = ?, - `data` = ?" - )->execute(['ssssssssssssssssssssssssssssssss', time() - $modified, 'a:1:{s:3:"foo";s:12:"test-session";}']); - $this->session->startSession($this->request); - } -} diff --git a/tests/unit/Session/SessionMiddlewareFactoryTest.php b/tests/unit/Session/SessionMiddlewareFactoryTest.php deleted file mode 100644 index 26f14d7..0000000 --- a/tests/unit/Session/SessionMiddlewareFactoryTest.php +++ /dev/null @@ -1,122 +0,0 @@ -file = __DIR__ . '/../../stubs/gc.timestamp'; - $this->factory = new SessionMiddlewareFactory(); - } - - public function tearDown(): void - { - if (is_file($this->file)) { - unlink($this->file); - } - } - - public function testExceptionIfTimestampFileIsNotWritable(): void - { - $this->expectException(CannotWhiteTimestampException::class); - $this->factory->checkGc( - [ - 'gc_timestamp_file' => 'unknown/gc.timestamp', - 'gc_period' => 3600, - ] - ); - } - - public function testNeedGarbageCollection(): void - { - touch($this->file, time() - 10000); - $this->assertTrue( - $this->factory->checkGc( - [ - 'gc_timestamp_file' => $this->file, - 'gc_period' => 3600, - ] - ) - ); - } - - public function testNotNeedGarbageCollection(): void - { - touch($this->file); - $this->assertFalse( - $this->factory->checkGc( - [ - 'gc_timestamp_file' => $this->file, - 'gc_period' => 3600, - ] - ) - ); - } - - public function testCreateTimestampFile(): void - { - if (is_file($this->file)) { - unlink($this->file); - } - - $this->factory->checkGc(['gc_timestamp_file' => $this->file]); - $this->assertTrue(is_file($this->file)); - } - - public function testFactoryReturnsSessionMiddlewareInstance() - { - $loader = new SqlDumpLoader(self::getPdo()); - $loader->loadFile('install/sql/system.sql'); - - if ($loader->hasErrors()) { - $this->fail(implode("\n", $loader->getErrors())); - } - - touch($this->file, time() - 10000); - $result = (new SessionMiddlewareFactory())($this->getContainer(['gc_timestamp_file' => $this->file])); - $this->assertInstanceOf(SessionMiddleware::class, $result); - } - - private function getContainer(array $options): ContainerInterface - { - $config = $this->createMock(ConfigInterface::class); - $config - ->method('get') - ->with('session') - ->willReturn($options); - - $container = $this->createMock(ContainerInterface::class); - $container - ->method('get') - ->willReturnCallback( - fn($val) => match ($val) { - ConfigInterface::class => $config, - PDO::class => self::getPdo(), - CookieManagerInterface::class => $this->createMock(CookieManagerInterface::class) - } - ); - - return $container; - } -} diff --git a/tests/unit/Session/SessionMiddlewareTest.php b/tests/unit/Session/SessionMiddlewareTest.php deleted file mode 100644 index 912260b..0000000 --- a/tests/unit/Session/SessionMiddlewareTest.php +++ /dev/null @@ -1,64 +0,0 @@ -middleware = new SessionMiddleware( - new SessionHandler( - self::getPdo(), - $this->createMock(CookieManagerInterface::class) - ) - ); - } - - public function testImplementsMiddlewareInterface(): void - { - $this->assertInstanceOf(MiddlewareInterface::class, $this->middleware); - } - - public function testProcess(): void - { - $result = $this->middleware->process( - $this->mockRequest(), - $this->mockRequestHandler() - ); - - $this->assertInstanceOf(ResponseInterface::class, $result); - } - - private function mockRequest(): ServerRequestInterface - { - $request = $this->createMock(ServerRequestInterface::class); - $request - ->method('withAttribute') - ->willReturn($request); - - return $request; - } - - private function mockRequestHandler(): RequestHandlerInterface - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler - ->method('handle') - ->willReturn($this->createMock(ResponseInterface::class)); - - return $handler; - } -}