├── .github ├── FUNDING.yml └── workflows │ ├── php.yml │ └── release-please.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── behat.yml ├── composer.json ├── composer.lock ├── features ├── access.feature ├── concurrency.feature ├── gc.feature ├── id.feature ├── persistence.feature └── regeneration.feature ├── phpstan.neon ├── src ├── CacheControl.php ├── Config.php ├── ExtraConfig.php ├── Factory.php ├── Frameworks │ └── Slim │ │ └── registerSessionMiddleware.php ├── Handlers │ ├── ArrayHandler.php │ ├── FileHandler.php │ ├── Psr16Handler.php │ ├── RedisHandler.php │ ├── ScrapbookHandler.php │ ├── SessionCasHandlerInterface.php │ ├── SessionIdTrait.php │ └── SessionLastModifiedTimestampHandlerInterface.php ├── Manager.php ├── Middleware │ ├── SessionBeforeMiddleware.php │ ├── SessionCacheControlMiddleware.php │ ├── SessionCookieMiddleware.php │ └── SessionMiddleware.php ├── Serializers │ ├── BaseSerializer.php │ ├── CallbackSerializer.php │ ├── Factory.php │ ├── JsonSerializer.php │ └── SerializerInterface.php ├── Session.php ├── SessionCookie.php └── SessionId.php └── tests ├── behavior ├── AccessContext.php ├── ConcurrencyContext.php ├── GarbageCollectionContext.php ├── GivenHandlerContextTrait.php ├── IdContext.php ├── PersistenceContext.php └── RegenerationContext.php └── integration └── server ├── App ├── Routes │ └── SessionRoutes.php ├── app.php └── config.php └── index.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [compwright] 4 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | runner-job: 14 | 15 | strategy: 16 | matrix: 17 | operating-system: [ubuntu-latest] 18 | php-versions: ['8.0', '8.1', '8.2', '8.3', '8.4'] 19 | 20 | runs-on: ${{ matrix.operating-system }} 21 | 22 | services: 23 | redis: 24 | image: redis 25 | ports: 26 | - 6379:6379 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | 31 | - name: Validate composer.json and composer.lock 32 | run: composer validate --strict 33 | 34 | - name: Cache Composer packages 35 | id: composer-cache 36 | uses: actions/cache@v3 37 | with: 38 | path: vendor 39 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 40 | restore-keys: | 41 | ${{ runner.os }}-php- 42 | 43 | - name: Install dependencies 44 | run: composer install --prefer-dist --no-progress 45 | 46 | - name: Run test suite 47 | run: make test 48 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | name: release-please 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: google-github-actions/release-please-action@v3 11 | with: 12 | release-type: php 13 | package-name: compwright/php-session 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.php-cs-fixer.cache 3 | /.vscode 4 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | true, 10 | 'array_syntax' => ['syntax' => 'short'], 11 | 'no_unused_imports' => true, 12 | 'single_line_comment_style' => true, 13 | 'single_line_comment_spacing' => true, 14 | 'control_structure_braces' => true, 15 | 'control_structure_continuation_position' => true, 16 | 'no_useless_else' => true, 17 | 'no_superfluous_elseif' => true, 18 | 'simplified_if_return' => true, 19 | 'single_quote' => true, 20 | ]; 21 | 22 | $finder = PhpCsFixer\Finder::create() 23 | ->in($dirs); 24 | 25 | return (new PhpCsFixer\Config()) 26 | ->setRules($rules) 27 | ->setFinder($finder); 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.3.0](https://github.com/compwright/php-session/compare/v3.2.1...v3.3.0) (2025-01-13) 4 | 5 | 6 | ### Features 7 | 8 | * support PHP 8.4 ([eeb6d22](https://github.com/compwright/php-session/commit/eeb6d22d9d2675f19a02223adb4286c1243e92fa)) 9 | 10 | ## [3.2.1](https://github.com/compwright/php-session/compare/v3.2.0...v3.2.1) (2024-01-08) 11 | 12 | 13 | ### Miscellaneous Chores 14 | 15 | * **master:** release 3.2.0 ([#19](https://github.com/compwright/php-session/issues/19)) ([2d2c77a](https://github.com/compwright/php-session/commit/2d2c77affe7a64f6c2c5389a345c7dcdde552749)) 16 | 17 | ## [3.2.0](https://github.com/compwright/php-session/compare/v3.1.2...v3.2.0) (2024-01-08) 18 | 19 | 20 | ### Features 21 | 22 | * drop Swoole support ([273a6b1](https://github.com/compwright/php-session/commit/273a6b187a4bd19a0459ebbea7a1112482e184cb)) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * lint ([ba80392](https://github.com/compwright/php-session/commit/ba8039252bc9e7224cf9293a7b2d1b5ac42ca175)) 28 | * overloading behaviour ([#14](https://github.com/compwright/php-session/issues/14)) ([3d43518](https://github.com/compwright/php-session/commit/3d4351824ed014c6eb0057fe1b60c9b6ba9f8cea)) 29 | 30 | 31 | ### Miscellaneous Chores 32 | 33 | * upgrade deps ([e3082d7](https://github.com/compwright/php-session/commit/e3082d760e7910aa007427c509119ef27681adc5)) 34 | 35 | ## [3.1.2](https://github.com/compwright/php-session/compare/v3.1.1...v3.1.2) (2023-02-14) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * setContents() does not toggle modified flag ([#15](https://github.com/compwright/php-session/issues/15)) ([14395fe](https://github.com/compwright/php-session/commit/14395fe884cf4ae2d5979ecb864de83ed222bff9)) 41 | 42 | ## [3.1.1](https://github.com/compwright/php-session/compare/v3.1.0...v3.1.1) (2022-12-29) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * fix session regeneration when used with a CAS handler ([4adb6a3](https://github.com/compwright/php-session/commit/4adb6a366302212e5415bde4152b67a78c83f076)) 48 | 49 | ## [3.1.0](https://github.com/compwright/php-session/compare/v3.0.1...v3.1.0) (2022-12-28) 50 | 51 | 52 | ### Features 53 | 54 | * make Session iterable ([#11](https://github.com/compwright/php-session/issues/11)) ([723a911](https://github.com/compwright/php-session/commit/723a9116e16d1a20373b8d7bcee63c789eede86f)) 55 | 56 | ## [3.0.1](https://github.com/compwright/php-session/compare/v3.0.0...v3.0.1) (2022-12-23) 57 | 58 | 59 | ### Miscellaneous Chores 60 | 61 | * fix type checks ([314e8ff](https://github.com/compwright/php-session/commit/314e8ff682484819e10e0cf0c63f4d1fb050617a)) 62 | * test in ci ([0cf510a](https://github.com/compwright/php-session/commit/0cf510a9fab5899e2bef2c94b6d5207d517ae932)) 63 | * upgrade dev dependencies ([53f29f8](https://github.com/compwright/php-session/commit/53f29f8b3d3a97ee4f8a8a7d6c1df17e1458dfe6)) 64 | 65 | ## [3.0.0](https://github.com/compwright/php-session/compare/v2.0.0...v3.0.0) (2022-12-23) 66 | 67 | 68 | ### ⚠ BREAKING CHANGES 69 | 70 | * drop support for PHP 7.4 71 | * support psr/simple-cache v2 and v3 72 | 73 | ### Features 74 | 75 | * drop support for PHP 7.4 ([19358d0](https://github.com/compwright/php-session/commit/19358d039685beca8c8ec14e8cba260aeacdc0fa)) 76 | * support psr/simple-cache v2 and v3 ([c3e7563](https://github.com/compwright/php-session/commit/c3e756337fe2de35270201cf9a9271d42bc3b4ee)), closes [#2](https://github.com/compwright/php-session/issues/2) 77 | * upgrade dependencies ([243d859](https://github.com/compwright/php-session/commit/243d859028fdfa0f4be4c8761f63a364b0f0e7f2)), closes [#2](https://github.com/compwright/php-session/issues/2) 78 | 79 | 80 | ### Bug Fixes 81 | 82 | * add ArrayAccess to Session class ([#7](https://github.com/compwright/php-session/issues/7)) ([7d13b9d](https://github.com/compwright/php-session/commit/7d13b9dd1fea5243f382ad51802146e5d60c963e)) 83 | * Session::__get() should trigger error when data does not exist ([80fea20](https://github.com/compwright/php-session/commit/80fea2000d4d4bb624c8e3fecc196a6ba4697899)) 84 | * sid generation in cookie middleware ([#5](https://github.com/compwright/php-session/issues/5)) ([0fe1c63](https://github.com/compwright/php-session/commit/0fe1c6322a46acf0b2ce9e4e7072e80563e28279)) 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jonathon Hill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 1G 3 | 4 | fix: 5 | vendor/bin/php-cs-fixer fix 6 | 7 | test-behavior: 8 | php -d memory_limit=4G vendor/bin/behat 9 | 10 | test: lint test-behavior 11 | 12 | start-php: 13 | php -S localhost:8080 -t tests/integration/server 2> /dev/null 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Compwright\PhpSession 2 | 3 | A PHP session implementation that is single-process safe, compatible with PSR-7 and PSR-15, and 4 | does not rely on global variables ($_SESSION, $_COOKIE, etc). 5 | 6 | This implementation is patterned after the built-in PHP session library, but is not a drop-in 7 | replacement for it. This library differs from the PHP session library in the following ways: 8 | 9 | * Requires PHP 8+ 10 | * Fully object-oriented 11 | * Strict mode is always on and cannot be disabled 12 | * Auto-start and auto-shutdown are not supported 13 | * Reading/writing cookie and cache headers is handled in middleware (included) 14 | * Handlers implement the built-in PHP SessionHandlerInterface, but the PHP SessionHandler class 15 | will not work because it depends internally on the PHP session extension 16 | * Session data is accessed using a Session object, not via $_SESSION 17 | 18 | This library is ideal for single-process event loop-driven applications, using servers like 19 | [ReactPHP](https://reactphp.org). 20 | 21 | ## Supported Features 22 | 23 | * [Array or Object Access](features/access.feature) 24 | * [Collision-Proof Secure ID Generation](features/id.feature) 25 | * [Data Persistance](features/persistence.feature) 26 | * [ID Regeneration](features/regeneration.feature) 27 | * [Lockless Concurrency](features/concurrency.feature) 28 | * [Garbage Collection](features/gc.feature) 29 | 30 | ## Installation 31 | 32 | composer require compwright/php-session 33 | 34 | ## Examples 35 | 36 | ### Slim Framework 37 | 38 | See [tests/integration/server/App](tests/integration/server/App) 39 | 40 | To run with PHP Development Server: 41 | 42 | $ composer run-script start-php 43 | 44 | ### Basic Usage 45 | 46 | ```php 47 | $sessionFactory = new Compwright\PhpSession\Factory(); 48 | 49 | $manager = $sessionFactory->psr16Session( 50 | /** 51 | * @param Psr\SimpleCache\CacheInterface 52 | */ 53 | $cache, 54 | 55 | /** 56 | * @param array|Compwright\PhpSession\Config 57 | */ 58 | [ 59 | 'name' => 'my_app', 60 | 'sid_length' => 48, 61 | 'sid_bits_per_character' => 5, 62 | ] 63 | ); 64 | 65 | // Start the session 66 | $manager->id($sid); // Read $sid from request 67 | $started = $manager->start(); 68 | if ($started === false) { 69 | throw new RuntimeException("The session failed to start"); 70 | } 71 | 72 | // Read/write the current session 73 | $session = $manager->getCurrentSession(); 74 | $session["foo"] = "bar"; 75 | unset($session["bar"]); 76 | 77 | // Save and close the session 78 | $ended = $manager->write_close(); 79 | if ($ended === false) { 80 | throw new RuntimeException("The session failed to close properly, data may have been lost"); 81 | } 82 | ``` 83 | 84 | ## License 85 | 86 | [MIT License](LICENSE) 87 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | access: 4 | paths: 5 | - 'features/access.feature' 6 | contexts: 7 | - 'Compwright\PhpSession\BehaviorTest\AccessContext' 8 | concurrency: 9 | paths: 10 | - 'features/concurrency.feature' 11 | contexts: 12 | - 'Compwright\PhpSession\BehaviorTest\ConcurrencyContext' 13 | gc: 14 | paths: 15 | - 'features/gc.feature' 16 | contexts: 17 | - 'Compwright\PhpSession\BehaviorTest\GarbageCollectionContext' 18 | id: 19 | paths: 20 | - 'features/id.feature' 21 | contexts: 22 | - 'Compwright\PhpSession\BehaviorTest\IdContext' 23 | persistance: 24 | paths: 25 | - 'features/persistence.feature' 26 | contexts: 27 | - 'Compwright\PhpSession\BehaviorTest\PersistenceContext' 28 | regeneration: 29 | paths: 30 | - 'features/regeneration.feature' 31 | contexts: 32 | - 'Compwright\PhpSession\BehaviorTest\RegenerationContext' 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compwright/php-session", 3 | "description": "Standalone session implementation that does not rely on the PHP session module or the $_SESSION global, ideal for ReactPHP applications", 4 | "type": "library", 5 | "keywords": [ 6 | "standalone", 7 | "session", 8 | "middleware", 9 | "reactphp", 10 | "psr7", 11 | "psr15", 12 | "psr16" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Jonathon Hill", 17 | "email": "jonathon@compwright.com" 18 | }, 19 | { 20 | "name": "Yani", 21 | "email": "yani@xenokore.com" 22 | } 23 | ], 24 | "homepage": "https://github.com/compwright/php-session", 25 | "support": { 26 | "issues": "https://github.com/compwright/php-session/issues" 27 | }, 28 | "funding": [ 29 | { 30 | "type": "github", 31 | "url": "https://github.com/sponsors/compwright" 32 | }, 33 | { 34 | "type": "BuyMeACoffee.com", 35 | "url": "https://www.buymeacoffee.com/compwright" 36 | } 37 | ], 38 | "license": "MIT", 39 | "suggest": { 40 | "ext-redis": "Redis extension for PHP", 41 | "matthiasmullie/scrapbook": "Scrapbook Cache provides excellent session storage options" 42 | }, 43 | "require": { 44 | "php": "^8.0", 45 | "dflydev/fig-cookies": "^3.0", 46 | "psr/simple-cache": "^1 || ^2 || ^3", 47 | "psr/http-message": "^1 || ^2", 48 | "psr/http-server-handler": "^1", 49 | "psr/http-server-middleware": "^1" 50 | }, 51 | "require-dev": { 52 | "behat/behat": "^3.13", 53 | "bramus/monolog-colored-line-formatter": "^3.0", 54 | "friendsofphp/php-cs-fixer": "^3.11", 55 | "kodus/file-cache": "^2", 56 | "league/flysystem": "^3.12", 57 | "matthiasmullie/scrapbook": "^1.4", 58 | "middlewares/access-log": "^2.0", 59 | "monolog/monolog": "^3.2", 60 | "php-di/php-di": "^7.0", 61 | "phpstan/phpstan": "^2.1", 62 | "phpunit/phpunit": "^11.5", 63 | "psr/log": "^2 || ^3", 64 | "slim/psr7": "^1.6", 65 | "slim/slim": "^4.11" 66 | }, 67 | "autoload": { 68 | "psr-4": { 69 | "Compwright\\PhpSession\\": "src/" 70 | }, 71 | "files": [ 72 | "src/Frameworks/Slim/registerSessionMiddleware.php" 73 | ] 74 | }, 75 | "autoload-dev": { 76 | "psr-4": { 77 | "Compwright\\PhpSession\\BehaviorTest\\": [ 78 | "tests/behavior" 79 | ], 80 | "App\\": [ 81 | "tests/integration/server/App" 82 | ] 83 | }, 84 | "files": [ 85 | "tests/integration/server/App/app.php" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /features/access.feature: -------------------------------------------------------------------------------- 1 | Feature: Session access 2 | Scenario: Check data does not exist 3 | When data does not exist 4 | Then property check returns false 5 | And array access check returns false 6 | Scenario: Check data exist 7 | When data exists 8 | Then property check returns true 9 | And array access check returns true 10 | Scenario: Read data that exists 11 | When data exists 12 | Then property read returns data 13 | And array access read returns data 14 | Scenario: Read data that does not exist 15 | When data does not exist 16 | Then property read triggers error 17 | And property read returns null 18 | And array access read triggers error 19 | And array access read returns null 20 | Scenario: Null coalesce when data does not exist 21 | When data does not exist 22 | Then property read with null coalesce returns null 23 | And array access read with null coalesce returns null 24 | Scenario: Write property data 25 | When data does not exist 26 | Then property write succeeds 27 | Scenario: Write array access data 28 | When data does not exist 29 | Then array access write succeeds 30 | Scenario: Iterate over populated session 31 | When data exists 32 | Then data is iterated 33 | Scenario: Iterate over non-populated session 34 | When data does not exist 35 | Then data is not iterated 36 | Scenario: Overload existing array 37 | When empty array for overload exists 38 | Then overloading using property access succeeds 39 | And overloading using property access succeeds 40 | Scenario: Overload non existing array 41 | When data does not exist 42 | Then overloading using array access fails 43 | And overloading using property access fails 44 | -------------------------------------------------------------------------------- /features/concurrency.feature: -------------------------------------------------------------------------------- 1 | Feature: Concurrency 2 | Sessions are protected from write contention to avoid corruption. 3 | Sessions changes may be committed early. 4 | 5 | Scenario: Normal write 6 | Given session has started 7 | When session changes 8 | Then commit should succeed 9 | 10 | Scenario: Write conflict 11 | Given session has started 12 | And session has been changed 13 | When session changes 14 | Then commit should fail 15 | -------------------------------------------------------------------------------- /features/gc.feature: -------------------------------------------------------------------------------- 1 | Feature: Garbage Collection 2 | Obsolete session data should be removed as soon as possible, but not instantly. 3 | Garbage collection can be run on a scheduler by a task scheduler. 4 | Garbage collection can run from time to time based on probability. 5 | 6 | Scenario: Garbage should remain until collected 7 | Given there is garbage to collect 8 | | id | last_modified | 9 | | 1 | -3 week | 10 | | 2 | -2 week | 11 | | 3 | -1 week | 12 | | 4 | -3 day | 13 | | 5 | -2 day | 14 | | 6 | -1 day | 15 | | 7 | -3 hour | 16 | | 8 | -2 hour | 17 | | 9 | -1 hour | 18 | | 10 | -3 minute | 19 | Given garbage collection is disabled 20 | When session is started 21 | Then garbage should remain 22 | 23 | Scenario: Run garbage collection on a schedule 24 | Given there is garbage to collect 25 | | id | last_modified | 26 | | 1 | -3 week | 27 | | 2 | -2 week | 28 | | 3 | -1 week | 29 | | 4 | -3 day | 30 | | 5 | -2 day | 31 | | 6 | -1 day | 32 | | 7 | -3 hour | 33 | | 8 | -2 hour | 34 | | 9 | -1 hour | 35 | | 10 | -3 minute | 36 | When garbage collection is run 37 | Then garbage should be collected 38 | 39 | Scenario Outline: Run garbage collection from time to time 40 | Given there is garbage to collect 41 | | id | last_modified | 42 | | 1 | -3 week | 43 | | 2 | -2 week | 44 | | 3 | -1 week | 45 | | 4 | -3 day | 46 | | 5 | -2 day | 47 | | 6 | -1 day | 48 | | 7 | -3 hour | 49 | | 8 | -2 hour | 50 | | 9 | -1 hour | 51 | | 10 | -3 minute | 52 | And probability is set to / 53 | When session is started times 54 | Then prior garbage should be collected 55 | 56 | Examples: 57 | | probability | divisor | requests | 58 | | 1 | 1 | 1 | 59 | | 1 | 2 | 10 | 60 | | 1 | 10 | 100 | 61 | -------------------------------------------------------------------------------- /features/id.feature: -------------------------------------------------------------------------------- 1 | Feature: Session ID 2 | Each session is referenced by a unique ID. 3 | If no ID is provided, one will be generated. 4 | If an invalid ID is provided, a new one will be generated. 5 | Generated IDs may be prefixed with user information. 6 | Generated IDs are guaranteed to not collide with other session IDs. 7 | 8 | Scenario: Default Session ID settings 9 | When default configuration 10 | Then length is 32 and bits is 4 11 | 12 | Scenario Outline: Generate a Session ID 13 | Given default configuration 14 | And , , and 15 | When Generating an ID 16 | Then length must be 17 | And the ID must be allowed characters 18 | And it must start with 19 | Examples: 20 | | bits | length | prefix | 21 | | 4 | 24 | Jo | 22 | | 5 | 32 | Sally | 23 | | 6 | 256 | Marge | 24 | 25 | Scenario: No Session ID is provided 26 | Given no ID 27 | When session is started 28 | Then ID should be generated 29 | 30 | Scenario: Invalid Session ID is provided 31 | Given invalid ID 32 | When session is started 33 | Then ID should be generated 34 | 35 | Scenario: No Session ID collisions 36 | Given 4 bits and 22 characters 37 | And 4000000 IDs already exist 38 | When 100000 IDs are generated 39 | Then there are 4100000 IDs and no collisions 40 | -------------------------------------------------------------------------------- /features/persistence.feature: -------------------------------------------------------------------------------- 1 | Feature: Session Persistence 2 | Sessions store data that persists between page requests. 3 | Session data is stored in a designated place and format. 4 | 5 | Scenario Outline: Session data persists across requests 6 | Given session stored at 7 | Then new session is started 8 | And session is writeable 9 | And session is saved and closed 10 | And further session writes are not saved 11 | Then previous session is started 12 | And session is readable 13 | And session can be reset 14 | Then previous session is started 15 | And session can be erased 16 | And session can be deleted 17 | 18 | Examples: 19 | | handler | location | 20 | | kodus | A | 21 | | scrapbook | B | 22 | | redis | 0 | 23 | | file | C | 24 | -------------------------------------------------------------------------------- /features/regeneration.feature: -------------------------------------------------------------------------------- 1 | Feature: Session ID Regeneration 2 | IDs may be regenerated for security. 3 | Existing session data is copied to the new generated ID. 4 | The old session ID may be deleted when the new ID is generated. 5 | 6 | Scenario Outline: Session ID is regenerated 7 | Given session stored at 8 | And session is started and modified 9 | When session ID is regenerated, delete old session 10 | Then session ID should change 11 | And session data should be preserved 12 | And old session should not remain 13 | 14 | Examples: 15 | | handler | location | 16 | | kodus | A | 17 | | scrapbook | B | 18 | | redis | 0 | 19 | | file | C | 20 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | treatPhpDocTypesAsCertain: false 4 | paths: 5 | - src 6 | - tests 7 | universalObjectCratesClasses: 8 | - Compwright\PhpSession\Session -------------------------------------------------------------------------------- /src/CacheControl.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public static function createHeaders( 17 | string $limiter = 'nocache', 18 | ?int $maxAge = null, 19 | ?int $lastModified = null 20 | ): array { 21 | switch ($limiter) { 22 | case 'public': 23 | return [ 24 | 'Expires' => self::getExpirationTimestamp($maxAge), 25 | 'Cache-Control' => 'public, max-age=' . $maxAge, 26 | 'Last-Modified' => self::getLastModifiedTimestamp($lastModified), 27 | ]; 28 | case 'private_no_expire': 29 | return [ 30 | 'Cache-Control' => "private, max-age={$maxAge}, pre-check={$maxAge}", 31 | 'Last-Modified' => self::getLastModifiedTimestamp($lastModified), 32 | ]; 33 | case 'private': 34 | return [ 35 | 'Expires' => self::EXPIRED, 36 | 'Cache-Control' => "private, max-age={$maxAge}, pre-check={$maxAge}", 37 | 'Last-Modified' => self::getLastModifiedTimestamp($lastModified), 38 | ]; 39 | case 'nocache': 40 | return [ 41 | 'Expires' => self::EXPIRED, 42 | 'Cache-Control' => 'no-store, no-cache, must-revalidate, ' . 43 | 'post-check=0, pre-check=0', 44 | 'Pragma' => 'no-cache', 45 | ]; 46 | default: 47 | throw new InvalidArgumentException('Invalid cache limiter: ' . $limiter); 48 | } 49 | } 50 | 51 | private static function getExpirationTimestamp(?int $maxAge = null): string 52 | { 53 | if (is_null($maxAge)) { 54 | throw new InvalidArgumentException('$maxAge is required'); 55 | } 56 | return gmdate('D, d M Y H:i:s T', time() + $maxAge); // RFC2616 57 | } 58 | 59 | private static function getLastModifiedTimestamp(?int $lastModified = null): string 60 | { 61 | if (is_null($lastModified)) { 62 | throw new InvalidArgumentException('$lastModified is required'); 63 | } 64 | return gmdate('D, d M Y H:i:s T', $lastModified); // RFC2616 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | save_path = $save_path; 19 | return $this; 20 | } 21 | 22 | public function getSavePath(): ?string 23 | { 24 | return $this->save_path ?? null; 25 | } 26 | 27 | protected SessionHandlerInterface $save_handler; 28 | 29 | public function setSaveHandler(SessionHandlerInterface $save_handler): self 30 | { 31 | $this->save_handler = $save_handler; 32 | return $this; 33 | } 34 | 35 | public function getSaveHandler(): ?SessionHandlerInterface 36 | { 37 | return $this->save_handler ?? null; 38 | } 39 | 40 | protected SerializerInterface $serialize_handler; 41 | 42 | public function setSerializeHandler(SerializerInterface $serialize_handler): self 43 | { 44 | $this->serialize_handler = $serialize_handler; 45 | return $this; 46 | } 47 | 48 | public function getSerializeHandler(): SerializerInterface 49 | { 50 | return $this->serialize_handler ?? SerializerFactory::php(); 51 | } 52 | 53 | protected string $name = 'PHPSESSID'; 54 | 55 | public function setName(string $name): self 56 | { 57 | $this->name = $name; 58 | return $this; 59 | } 60 | 61 | public function getName(): string 62 | { 63 | return $this->name; 64 | } 65 | 66 | protected int $gc_probability = 1; 67 | 68 | public function setGcProbability(int $gc_probability): self 69 | { 70 | $this->gc_probability = $gc_probability; 71 | return $this; 72 | } 73 | 74 | public function getGcProbability(): int 75 | { 76 | return $this->gc_probability; 77 | } 78 | 79 | protected int $gc_divisor = 100; 80 | 81 | public function setGcDivisor(int $gc_divisor): self 82 | { 83 | $this->gc_divisor = $gc_divisor; 84 | return $this; 85 | } 86 | 87 | public function getGcDivisor(): int 88 | { 89 | return $this->gc_divisor; 90 | } 91 | 92 | protected int $gc_maxlifetime = 1440; 93 | 94 | public function setGcMaxLifetime(int $gc_maxlifetime): self 95 | { 96 | $this->gc_maxlifetime = $gc_maxlifetime; 97 | return $this; 98 | } 99 | 100 | public function getGcMaxLifetime(): int 101 | { 102 | return $this->gc_maxlifetime; 103 | } 104 | 105 | protected string $sid_prefix = ''; 106 | 107 | public function setSidPrefix(string $sid_prefix): self 108 | { 109 | $this->sid_prefix = $sid_prefix; 110 | return $this; 111 | } 112 | 113 | public function getSidPrefix(): string 114 | { 115 | return $this->sid_prefix; 116 | } 117 | 118 | protected int $sid_length = 32; 119 | 120 | public function setSidLength(int $sid_length): self 121 | { 122 | if ($sid_length < 22 || $sid_length > 256) { 123 | throw new InvalidArgumentException( 124 | '$sid_length must be at least 22 and not greater than 256' 125 | ); 126 | } 127 | 128 | $this->sid_length = $sid_length; 129 | return $this; 130 | } 131 | 132 | public function getSidLength(): int 133 | { 134 | return $this->sid_length; 135 | } 136 | 137 | protected int $sid_bits_per_character = 4; 138 | 139 | public function setSidBitsPerCharacter(int $sid_bits_per_character): self 140 | { 141 | if ($sid_bits_per_character < 4 || $sid_bits_per_character > 6) { 142 | throw new InvalidArgumentException( 143 | '$sid_bits_per_character must be at least 4 and not greater than than 6' 144 | ); 145 | } 146 | 147 | $this->sid_bits_per_character = $sid_bits_per_character; 148 | 149 | if ($sid_bits_per_character >= 5 && $this->sid_length < 26) { 150 | $this->setSidLength(26); 151 | } 152 | 153 | return $this; 154 | } 155 | 156 | public function getSidBitsPerCharacter(): int 157 | { 158 | return $this->sid_bits_per_character; 159 | } 160 | 161 | protected bool $lazy_write = true; 162 | 163 | public function setLazyWrite(bool $lazy_write): self 164 | { 165 | $this->lazy_write = $lazy_write; 166 | return $this; 167 | } 168 | 169 | public function getLazyWrite(): bool 170 | { 171 | return $this->lazy_write; 172 | } 173 | 174 | protected bool $read_and_close = false; 175 | 176 | public function setReadAndClose(bool $read_and_close): self 177 | { 178 | $this->read_and_close = $read_and_close; 179 | return $this; 180 | } 181 | 182 | public function getReadAndClose(): bool 183 | { 184 | return $this->read_and_close; 185 | } 186 | 187 | protected int $cookie_lifetime = 0; 188 | 189 | public function setCookieLifetime(int $cookie_lifetime): self 190 | { 191 | $this->cookie_lifetime = $cookie_lifetime; 192 | return $this; 193 | } 194 | 195 | public function getCookieLifetime(): int 196 | { 197 | return $this->cookie_lifetime; 198 | } 199 | 200 | protected string $cookie_path = '/'; 201 | 202 | public function setCookiePath(string $cookie_path): self 203 | { 204 | $this->cookie_path = $cookie_path; 205 | return $this; 206 | } 207 | 208 | public function getCookiePath(): string 209 | { 210 | return $this->cookie_path; 211 | } 212 | 213 | protected string $cookie_domain = ''; 214 | 215 | public function setCookieDomain(string $cookie_domain): self 216 | { 217 | $this->cookie_domain = $cookie_domain; 218 | return $this; 219 | } 220 | 221 | public function getCookieDomain(): string 222 | { 223 | return $this->cookie_domain; 224 | } 225 | 226 | protected bool $cookie_secure = false; 227 | 228 | public function setCookieSecure(bool $cookie_secure): self 229 | { 230 | $this->cookie_secure = $cookie_secure; 231 | return $this; 232 | } 233 | 234 | public function getCookieSecure(): bool 235 | { 236 | return $this->cookie_secure; 237 | } 238 | 239 | protected bool $cookie_httponly = false; 240 | 241 | public function setCookieHttpOnly(bool $cookie_httponly): self 242 | { 243 | $this->cookie_httponly = $cookie_httponly; 244 | return $this; 245 | } 246 | 247 | public function getCookieHttpOnly(): bool 248 | { 249 | return $this->cookie_httponly; 250 | } 251 | 252 | protected string $cookie_samesite = ''; 253 | 254 | public function setCookieSameSite(string $cookie_samesite): self 255 | { 256 | $this->cookie_samesite = $cookie_samesite; 257 | return $this; 258 | } 259 | 260 | public function getCookieSameSite(): string 261 | { 262 | return $this->cookie_samesite; 263 | } 264 | 265 | protected string $cache_limiter = 'nocache'; 266 | 267 | public function setCacheLimiter(string $cache_limiter): self 268 | { 269 | $this->cache_limiter = $cache_limiter; 270 | return $this; 271 | } 272 | 273 | public function getCacheLimiter(): string 274 | { 275 | return $this->cache_limiter; 276 | } 277 | 278 | protected int $cache_expire = 180; 279 | 280 | public function setCacheExpire(int $cache_expire): self 281 | { 282 | $this->cache_expire = $cache_expire; 283 | return $this; 284 | } 285 | 286 | public function getCacheExpire(): int 287 | { 288 | return $this->cache_expire; 289 | } 290 | 291 | protected int $regenerate_id_interval = 0; 292 | 293 | public function setRegenerateIdInterval(int $regenerate_id_interval): self 294 | { 295 | $this->regenerate_id_interval = $regenerate_id_interval; 296 | return $this; 297 | } 298 | 299 | public function getRegenerateIdInterval(): int 300 | { 301 | return $this->regenerate_id_interval; 302 | } 303 | 304 | /** 305 | * @return array 306 | */ 307 | public function toArray(): array 308 | { 309 | return [ 310 | 'save_path' => $this->getSavePath(), 311 | 'save_handler' => $this->getSaveHandler(), 312 | 'serialize_handler' => $this->getSerializeHandler(), 313 | 'name' => $this->getName(), 314 | 'gc_probability' => $this->getGcProbability(), 315 | 'gc_divisor' => $this->getGcDivisor(), 316 | 'gc_maxlifetime' => $this->getGcMaxLifetime(), 317 | 'sid_prefix' => $this->getSidPrefix(), 318 | 'sid_length' => $this->getSidLength(), 319 | 'sid_bits_per_character' => $this->getSidBitsPerCharacter(), 320 | 'lazy_write' => $this->getLazyWrite(), 321 | 'read_and_close' => $this->getReadAndClose(), 322 | 'cookie_lifetime' => $this->getCookieLifetime(), 323 | 'cookie_path' => $this->getCookiePath(), 324 | 'cookie_domain' => $this->getCookieDomain(), 325 | 'cookie_secure' => $this->getCookieSecure(), 326 | 'cookie_httponly' => $this->getCookieHttpOnly(), 327 | 'cookie_samesite' => $this->getCookieSameSite(), 328 | 'cache_limiter' => $this->getCacheLimiter(), 329 | 'cache_expire' => $this->getCacheExpire(), 330 | 'regenerate_id_interval' => $this->getRegenerateIdInterval(), 331 | ]; 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/ExtraConfig.php: -------------------------------------------------------------------------------- 1 | cookie_lifetime = $cookie_lifetime; 14 | return $this; 15 | } 16 | 17 | public function getCookieLifetime(): int 18 | { 19 | return $this->cookie_lifetime; 20 | } 21 | 22 | protected string $cookie_path = '/'; 23 | 24 | public function setCookiePath(string $cookie_path): self 25 | { 26 | $this->cookie_path = $cookie_path; 27 | return $this; 28 | } 29 | 30 | public function getCookiePath(): string 31 | { 32 | return $this->cookie_path; 33 | } 34 | 35 | protected string $cookie_domain = ''; 36 | 37 | public function setCookieDomain(string $cookie_domain): self 38 | { 39 | $this->cookie_domain = $cookie_domain; 40 | return $this; 41 | } 42 | 43 | public function getCookieDomain(): string 44 | { 45 | return $this->cookie_domain; 46 | } 47 | 48 | protected bool $cookie_secure = false; 49 | 50 | public function setCookieSecure(bool $cookie_secure): self 51 | { 52 | $this->cookie_secure = $cookie_secure; 53 | return $this; 54 | } 55 | 56 | public function getCookieSecure(): bool 57 | { 58 | return $this->cookie_secure; 59 | } 60 | 61 | protected bool $cookie_httponly = false; 62 | 63 | public function setCookieHttpOnly(bool $cookie_httponly): self 64 | { 65 | $this->cookie_httponly = $cookie_httponly; 66 | return $this; 67 | } 68 | 69 | public function getCookieHttpOnly(): bool 70 | { 71 | return $this->cookie_httponly; 72 | } 73 | 74 | protected bool $cookie_samesite = false; 75 | 76 | public function setCookieSameSite(bool $cookie_samesite): self 77 | { 78 | $this->cookie_samesite = $cookie_samesite; 79 | return $this; 80 | } 81 | 82 | public function getCookieSameSite(): bool 83 | { 84 | return $this->cookie_samesite; 85 | } 86 | 87 | protected bool $use_cookies = true; 88 | 89 | public function setUseCookies(bool $use_cookies): self 90 | { 91 | $this->use_cookies = $use_cookies; 92 | return $this; 93 | } 94 | 95 | public function getUseCookies(): bool 96 | { 97 | return $this->use_cookies; 98 | } 99 | 100 | protected bool $use_only_cookies = true; 101 | 102 | public function setUseOnlyCookies(bool $use_only_cookies): self 103 | { 104 | $this->use_only_cookies = $use_only_cookies; 105 | return $this; 106 | } 107 | 108 | public function getUseOnlyCookies(): bool 109 | { 110 | return $this->use_only_cookies; 111 | } 112 | 113 | protected string $cache_limiter = 'nocache'; 114 | 115 | public function setCacheLimiter(string $cache_limiter): self 116 | { 117 | $this->cache_limiter = $cache_limiter; 118 | return $this; 119 | } 120 | 121 | public function getCacheLimiter(): string 122 | { 123 | return $this->cache_limiter; 124 | } 125 | 126 | protected int $cache_expire = 180; 127 | 128 | public function setCacheExpire(int $cache_expire): self 129 | { 130 | $this->cache_expire = $cache_expire; 131 | return $this; 132 | } 133 | 134 | public function getCacheExpire(): int 135 | { 136 | return $this->cache_expire; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | $settings 15 | */ 16 | public function configFromArray(array $settings): Config 17 | { 18 | $config = new Config(); 19 | 20 | if (isset($settings['save_path'])) { 21 | if (!is_string($settings['save_path'])) { 22 | throw new InvalidArgumentException('save_path must be a string'); 23 | } 24 | $config->setSavePath($settings['save_path']); 25 | } 26 | 27 | if (isset($settings['save_handler'])) { 28 | if (! ($settings['save_handler'] instanceof SessionHandlerInterface)) { 29 | throw new InvalidArgumentException('save_handler must implement SessionHandlerInterface'); 30 | } 31 | $config->setSaveHandler($settings['save_handler']); 32 | } 33 | 34 | if (isset($settings['serialize_handler'])) { 35 | if (!is_string($settings['serialize_handler'])) { 36 | throw new InvalidArgumentException('serialize_handler must be a string or null'); 37 | } 38 | $config->setSerializeHandler( 39 | Serializers\Factory::auto($settings['serialize_handler']) 40 | ); 41 | } 42 | 43 | if (isset($settings['name'])) { 44 | if (!is_string($settings['name'])) { 45 | throw new InvalidArgumentException('name must be a string'); 46 | } 47 | $config->setName($settings['name']); 48 | } 49 | 50 | if (isset($settings['cookie_lifetime'])) { 51 | if (!is_int($settings['cookie_lifetime'])) { 52 | throw new InvalidArgumentException('cookie_lifetime must be an integer'); 53 | } 54 | $config->setCookieLifetime($settings['cookie_lifetime']); 55 | } 56 | 57 | if (isset($settings['cookie_path'])) { 58 | if (!is_string($settings['cookie_path'])) { 59 | throw new InvalidArgumentException('cookie_path must be a string'); 60 | } 61 | $config->setCookiePath($settings['cookie_path']); 62 | } 63 | 64 | if (isset($settings['cookie_domain'])) { 65 | if (!is_string($settings['cookie_domain'])) { 66 | throw new InvalidArgumentException('cookie_domain must be a string'); 67 | } 68 | $config->setCookieDomain($settings['cookie_domain']); 69 | } 70 | 71 | if (isset($settings['cookie_secure'])) { 72 | if (!is_bool($settings['cookie_secure'])) { 73 | throw new InvalidArgumentException('cookie_secure must be a boolean'); 74 | } 75 | $config->setCookieSecure($settings['cookie_secure']); 76 | } 77 | 78 | if (isset($settings['cookie_httponly'])) { 79 | if (!is_bool($settings['cookie_httponly'])) { 80 | throw new InvalidArgumentException('cookie_httponly must be a boolean'); 81 | } 82 | $config->setCookieHttpOnly($settings['cookie_httponly']); 83 | } 84 | 85 | if (isset($settings['cookie_samesite'])) { 86 | if (!is_string($settings['cookie_samesite'])) { 87 | throw new InvalidArgumentException('cookie_samesite must be a string'); 88 | } 89 | $config->setCookieSameSite($settings['cookie_samesite']); 90 | } 91 | 92 | if (isset($settings['cache_limiter'])) { 93 | if (!is_string($settings['cache_limiter'])) { 94 | throw new InvalidArgumentException('cache_limiter must be a string'); 95 | } 96 | $config->setCacheLimiter($settings['cache_limiter']); 97 | } 98 | 99 | if (isset($settings['cache_expire'])) { 100 | if (!is_int($settings['cache_expire'])) { 101 | throw new InvalidArgumentException('cache_expire must be an integer'); 102 | } 103 | $config->setCacheExpire($settings['cache_expire']); 104 | } 105 | 106 | if (isset($settings['gc_probability'])) { 107 | if (!is_int($settings['gc_probability'])) { 108 | throw new InvalidArgumentException('gc_probability must be an integer'); 109 | } 110 | $config->setGcProbability($settings['gc_probability']); 111 | } 112 | 113 | if (isset($settings['gc_divisor'])) { 114 | if (!is_int($settings['gc_divisor'])) { 115 | throw new InvalidArgumentException('gc_divisor must be an integer'); 116 | } 117 | $config->setGcDivisor($settings['gc_divisor']); 118 | } 119 | 120 | if (isset($settings['gc_maxlifetime'])) { 121 | if (!is_int($settings['gc_maxlifetime'])) { 122 | throw new InvalidArgumentException('gc_maxlifetime must be an integer'); 123 | } 124 | $config->setGcMaxLifetime($settings['gc_maxlifetime']); 125 | } 126 | 127 | if (isset($settings['sid_length'])) { 128 | if (!is_int($settings['sid_length'])) { 129 | throw new InvalidArgumentException('sid_length must be an integer'); 130 | } 131 | $config->setSidLength($settings['sid_length']); 132 | } 133 | 134 | if (isset($settings['sid_bits_per_character'])) { 135 | if (!is_int($settings['sid_bits_per_character'])) { 136 | throw new InvalidArgumentException('sid_bits_per_character must be an integer'); 137 | } 138 | $config->setSidBitsPerCharacter($settings['sid_bits_per_character']); 139 | } 140 | 141 | if (isset($settings['lazy_write'])) { 142 | if (!is_bool($settings['lazy_write'])) { 143 | throw new InvalidArgumentException('lazy_write must be a boolean'); 144 | } 145 | $config->setLazyWrite($settings['lazy_write']); 146 | } 147 | 148 | return $config; 149 | } 150 | 151 | public function configFromSystem(): Config 152 | { 153 | $config = new Config(); 154 | 155 | $config->setSerializeHandler( 156 | Serializers\Factory::auto(ini_get('session.serialize_handler') ?: null) 157 | ); 158 | 159 | if (ini_get('session.name') !== false) { 160 | $config->setName(ini_get('session.name')); 161 | } 162 | 163 | if (ini_get('session.cookie_lifetime') !== false) { 164 | $config->setCookieLifetime((int) ini_get('session.cookie_lifetime')); 165 | } 166 | 167 | if (ini_get('session.cookie_path') !== false) { 168 | $config->setCookiePath(ini_get('session.cookie_path')); 169 | } 170 | 171 | if (ini_get('session.cookie_domain') !== false) { 172 | $config->setCookieDomain(ini_get('session.cookie_domain')); 173 | } 174 | 175 | $config->setCookieSecure((bool) ini_get('session.cookie_secure')); 176 | $config->setCookieHttpOnly((bool) ini_get('session.cookie_httponly')); 177 | 178 | if (ini_get('session.cookie_samesite') !== false) { 179 | $config->setCookieSameSite(ini_get('session.cookie_samesite')); 180 | } 181 | 182 | if (ini_get('session.cache_limiter') !== false) { 183 | $config->setCacheLimiter(ini_get('session.cache_limiter')); 184 | } 185 | 186 | if (ini_get('session.cache_expire') !== false) { 187 | $config->setCacheExpire((int) ini_get('session.cache_expire')); 188 | } 189 | 190 | if (ini_get('session.gc_probability') !== false) { 191 | $config->setGcProbability((int) ini_get('session.gc_probability')); 192 | } 193 | 194 | if (ini_get('session.gc_divisor') !== false) { 195 | $config->setGcDivisor((int) ini_get('session.gc_divisor')); 196 | } 197 | 198 | if (ini_get('session.gc_maxlifetime') !== false) { 199 | $config->setGcMaxLifetime((int) ini_get('session.gc_maxlifetime')); 200 | } 201 | 202 | if (ini_get('session.sid_length') !== false) { 203 | $config->setSidLength((int) ini_get('session.sid_length')); 204 | } 205 | 206 | if (ini_get('session.sid_bits_per_character') !== false) { 207 | $config->setSidBitsPerCharacter((int) ini_get('session.sid_bits_per_character')); 208 | } 209 | 210 | $config->setLazyWrite((bool) ini_get('session.lazy_write')); 211 | 212 | return $config; 213 | } 214 | 215 | /** 216 | * @param array|Config|null $arrayOrConfig 217 | */ 218 | public function psr16Session(CacheInterface $store, $arrayOrConfig = null): Manager 219 | { 220 | if (is_array($arrayOrConfig)) { 221 | $config = $this->configFromArray($arrayOrConfig); 222 | } elseif (is_null($arrayOrConfig)) { 223 | $config = $this->configFromSystem(); 224 | } elseif ($arrayOrConfig instanceof Config) { 225 | $config = $arrayOrConfig; 226 | } else { 227 | throw new InvalidArgumentException( 228 | '$arrayOrConfig must be an array, instance of Compwright\PhpSession\Config, or null' 229 | ); 230 | } 231 | 232 | $handler = new Handlers\Psr16Handler($config, $store); 233 | $config->setSaveHandler($handler); 234 | 235 | return new Manager($config); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/Frameworks/Slim/registerSessionMiddleware.php: -------------------------------------------------------------------------------- 1 | $app 16 | */ 17 | function registerSessionMiddleware(App $app): void 18 | { 19 | // Slim middleware is executed in reverse order 20 | $app->add(SessionCacheControlMiddleware::class); 21 | $app->add(SessionMiddleware::class); 22 | $app->add(SessionCookieMiddleware::class); 23 | $app->add(SessionBeforeMiddleware::class); 24 | } 25 | -------------------------------------------------------------------------------- /src/Handlers/ArrayHandler.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | private array $store; 36 | 37 | /** 38 | * @param array $store 39 | */ 40 | public function __construct(Config $config, array $store = []) 41 | { 42 | $this->sid = new SessionId($config); 43 | $this->store = $store; 44 | } 45 | 46 | public function open($path, $name): bool 47 | { 48 | return true; 49 | } 50 | 51 | public function close(): bool 52 | { 53 | return true; 54 | } 55 | 56 | public function read(string $id): string|false 57 | { 58 | if ( 59 | !array_key_exists($id, $this->store) 60 | || isset($this->store[$id]['meta']['destroyed']) 61 | ) { 62 | return false; 63 | } 64 | 65 | $value = $this->store[$id]['data']; 66 | if (is_string($value) || $value === false) { 67 | return $value; 68 | } 69 | throw new TypeError('Expected $value to be a string or false'); 70 | } 71 | 72 | /** 73 | * @param string $id 74 | * @return array{mixed, float}|false 75 | */ 76 | public function read_cas($id) 77 | { 78 | $data = $this->read($id); 79 | 80 | if ($data === false) { 81 | return false; 82 | } 83 | 84 | return [$data, $this->store[$id]['meta']['last_modified']]; 85 | } 86 | 87 | public function write($id, $data): bool 88 | { 89 | if (!is_string($data)) { 90 | return false; 91 | } 92 | 93 | $this->store[$id] = [ 94 | 'data' => $data, 95 | 'meta' => [ 96 | 'id' => $id, 97 | 'last_modified' => microtime(true), 98 | ], 99 | ]; 100 | 101 | return true; 102 | } 103 | 104 | /** 105 | * @param float $token 106 | * @param string $id 107 | * @param string $data 108 | */ 109 | public function write_cas($token, $id, $data): bool 110 | { 111 | if ( 112 | array_key_exists($id, $this->store) 113 | && $token !== $this->store[$id]['meta']['last_modified'] 114 | ) { 115 | return false; 116 | } 117 | 118 | return $this->write($id, $data); 119 | } 120 | 121 | public function validateId($id): bool 122 | { 123 | return ( 124 | !empty($id) 125 | && array_key_exists($id, $this->store) 126 | && !isset($this->store[$id]['meta']['destroyed']) 127 | ); 128 | } 129 | 130 | public function updateTimestamp($id, $data): bool 131 | { 132 | if ( 133 | !array_key_exists($id, $this->store) 134 | || isset($this->store[$id]['meta']['destroyed']) 135 | ) { 136 | return false; 137 | } 138 | 139 | $this->store[$id]['meta']['last_modified'] = microtime(true); 140 | 141 | return true; 142 | } 143 | 144 | /** 145 | * @param string $id 146 | * @return float|false 147 | */ 148 | public function getTimestamp($id) 149 | { 150 | if ( 151 | !array_key_exists($id, $this->store) 152 | || isset($this->store[$id]['meta']['destroyed']) 153 | ) { 154 | return false; 155 | } 156 | 157 | return $this->store[$id]['meta']['last_modified']; 158 | } 159 | 160 | public function destroy($id): bool 161 | { 162 | if (!array_key_exists($id, $this->store)) { 163 | return false; 164 | } 165 | 166 | $this->store[$id]['meta']['destroyed'] = true; 167 | 168 | return true; 169 | } 170 | 171 | public function gc($max_lifetime): int|false 172 | { 173 | $garbage = array_filter( 174 | $this->store, 175 | function ($store) use ($max_lifetime) { 176 | return ( 177 | isset($store['meta']['destroyed']) 178 | || $store['meta']['last_modified'] < microtime(true) - $max_lifetime 179 | ); 180 | } 181 | ); 182 | 183 | if (count($garbage) === 0) { 184 | return false; 185 | } 186 | 187 | foreach ($garbage as $session) { 188 | unset($this->store[$session['meta']['id']]); 189 | } 190 | 191 | return count($garbage); 192 | } 193 | 194 | public function count(): int 195 | { 196 | return count($this->store); 197 | } 198 | 199 | /** 200 | * @return array 201 | */ 202 | public function toArray(): array 203 | { 204 | return $this->store; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Handlers/FileHandler.php: -------------------------------------------------------------------------------- 1 | sid = new SessionId($config); 35 | } 36 | 37 | private function getFilePath(string $id): string 38 | { 39 | return $this->savePath . DIRECTORY_SEPARATOR . 'sess_' . $id; 40 | } 41 | 42 | public function open($savePath, $sessionName): bool 43 | { 44 | $this->savePath = $savePath; 45 | 46 | if (!is_dir($this->savePath)) { 47 | mkdir($this->savePath, 0777); 48 | } 49 | 50 | return true; 51 | } 52 | 53 | public function close(): bool 54 | { 55 | return true; 56 | } 57 | 58 | public function read(string $id): string|false 59 | { 60 | if (!$this->validateId($id)) { 61 | return false; 62 | } 63 | 64 | return (string) file_get_contents($this->getFilePath($id)); 65 | } 66 | 67 | public function write($id, $data): bool 68 | { 69 | if (!$this->sid->validate_sid($id)) { 70 | return false; 71 | } 72 | 73 | return file_put_contents($this->getFilePath($id), $data) !== false; 74 | } 75 | 76 | public function destroy($id): bool 77 | { 78 | if (!$this->validateId($id)) { 79 | return false; 80 | } 81 | 82 | return unlink($this->getFilePath($id)); 83 | } 84 | 85 | public function gc(int $maxlifetime): int|false 86 | { 87 | $files = glob($this->getFilePath('*')) ?: []; 88 | 89 | foreach ($files as $file) { 90 | if (file_exists($file) && time() > filemtime($file) + $maxlifetime) { 91 | unlink($file); 92 | } 93 | } 94 | 95 | return count($files); 96 | } 97 | 98 | public function validateId($id): bool 99 | { 100 | return ( 101 | !empty($id) 102 | && $this->sid->validate_sid($id) 103 | && file_exists($this->getFilePath($id)) 104 | ); 105 | } 106 | 107 | public function updateTimestamp($id, $data): bool 108 | { 109 | if (!$this->validateId($id)) { 110 | return false; 111 | } 112 | 113 | touch($this->getFilePath($id)); 114 | 115 | return true; 116 | } 117 | 118 | public function count(): int 119 | { 120 | return count(glob($this->getFilePath('*')) ?: []); 121 | } 122 | 123 | /** 124 | * @param string $id 125 | * @return float|false 126 | */ 127 | public function getTimestamp($id) 128 | { 129 | $timestamp = filemtime($this->getFilePath($id)); 130 | return $timestamp === false 131 | ? false 132 | : (float) $timestamp; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Handlers/Psr16Handler.php: -------------------------------------------------------------------------------- 1 | store = $store; 37 | $this->sid = new SessionId($config); 38 | } 39 | 40 | public function open($path, $name): bool 41 | { 42 | return true; 43 | } 44 | 45 | public function close(): bool 46 | { 47 | return true; 48 | } 49 | 50 | public function read(string $id): string|false 51 | { 52 | if (!$this->store->has($id)) { 53 | return false; 54 | } 55 | 56 | $value = $this->store->get($id); 57 | if (is_string($value) || $value === false) { 58 | return $value; 59 | } 60 | throw new TypeError('Expected $value to be a string or false'); 61 | } 62 | 63 | public function write($id, $data): bool 64 | { 65 | if (!is_string($data)) { 66 | return false; 67 | } 68 | 69 | $this->lastWriteTimestamp = microtime(true); 70 | 71 | return $this->store->set($id, $data); 72 | } 73 | 74 | public function validateId($id): bool 75 | { 76 | return !empty($id) && $this->store->has($id); 77 | } 78 | 79 | public function updateTimestamp($id, $data): bool 80 | { 81 | return $this->write($id, $data); 82 | } 83 | 84 | public function destroy($id): bool 85 | { 86 | if (!$this->store->has($id)) { 87 | return false; 88 | } 89 | 90 | return $this->store->delete($id); 91 | } 92 | 93 | public function gc(int $max_lifetime): int|false 94 | { 95 | return 0; 96 | } 97 | 98 | /** 99 | * @param string $id 100 | * @return float|false 101 | */ 102 | public function getTimestamp($id) 103 | { 104 | return $this->lastWriteTimestamp ?? false; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Handlers/RedisHandler.php: -------------------------------------------------------------------------------- 1 | config = $config; // still required by SessionIdTrait 26 | $this->sid = new SessionId($config); 27 | 28 | if (!extension_loaded('redis')) { 29 | throw new RuntimeException('Missing redis extension'); 30 | } 31 | } 32 | 33 | public function open($path, $name): bool 34 | { 35 | if (isset($this->redis)) { 36 | return true; 37 | } 38 | 39 | // Parse redis connection settings from save path 40 | $query = []; 41 | $config = parse_url($path); 42 | if ($config === false) { 43 | throw new InvalidArgumentException('Invalid $path'); 44 | } 45 | if (!empty($config['query'])) { 46 | parse_str($config['query'], $query); 47 | } 48 | 49 | $redis = new Redis(); 50 | 51 | if (empty($config['host'])) { 52 | throw new InvalidArgumentException('Missing host or socket in $path'); 53 | } 54 | 55 | $port = isset($config['port']) 56 | ? (int) $config['port'] 57 | : 6379; 58 | 59 | if (!$redis->connect($config['host'], $port)) { 60 | unset($redis); 61 | return false; 62 | } 63 | 64 | $database = isset($query['database']) 65 | ? (int) $query['database'] 66 | : 0; 67 | 68 | if (!$redis->select($database)) { 69 | $redis->close(); 70 | unset($redis); 71 | return false; 72 | } 73 | 74 | if (!$redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE)) { 75 | return false; 76 | } 77 | 78 | $this->redis = $redis; 79 | $this->store = new RedisKeyValueStore($redis); 80 | return true; 81 | } 82 | 83 | public function close(): bool 84 | { 85 | unset($this->store); 86 | 87 | if (!isset($this->redis)) { 88 | return false; 89 | } 90 | 91 | $success = $this->redis->close(); 92 | unset($this->redis); 93 | 94 | return $success; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Handlers/ScrapbookHandler.php: -------------------------------------------------------------------------------- 1 | config = $config; // still required by SessionIdTrait 47 | $this->sid = new SessionId($config); 48 | $this->parentStore = $store; 49 | $this->store = $store; 50 | $this->disableCollections = $disableCollections; 51 | } 52 | 53 | public function open($path, $name): bool 54 | { 55 | $this->store = $this->disableCollections 56 | ? $this->parentStore 57 | : $this->parentStore->getCollection($name); 58 | return true; 59 | } 60 | 61 | public function close(): bool 62 | { 63 | $this->store = $this->parentStore; 64 | return true; 65 | } 66 | 67 | public function read(string $id): string|false 68 | { 69 | $value = $this->store->get($id); 70 | if (is_string($value) || $value === false) { 71 | return $value; 72 | } 73 | throw new TypeError('Expected $value to be a string or false'); 74 | } 75 | 76 | /** 77 | * @param string $id 78 | * @return array{mixed, string}|false 79 | */ 80 | public function read_cas($id) 81 | { 82 | $data = $this->store->get($id); 83 | 84 | if ($data === false) { 85 | return $data; 86 | } 87 | 88 | return [$data, serialize($data)]; 89 | } 90 | 91 | public function write($id, $data): bool 92 | { 93 | if (!is_string($data)) { 94 | return false; 95 | } 96 | 97 | $this->lastWriteTimestamp = microtime(true); 98 | 99 | return $this->store->set($id, $data, $this->config->getGcMaxLifetime()); 100 | } 101 | 102 | /** 103 | * @param mixed $token 104 | * @param string $id 105 | * @param mixed $data 106 | */ 107 | public function write_cas($token, $id, $data): bool 108 | { 109 | $this->lastWriteTimestamp = microtime(true); 110 | 111 | return $this->store->cas($token, $id, $data, $this->config->getGcMaxLifetime()); 112 | } 113 | 114 | public function validateId($id): bool 115 | { 116 | return !empty($id) && $this->store->get($id) !== false; 117 | } 118 | 119 | public function updateTimestamp($id, $data): bool 120 | { 121 | return $this->store->touch($id, $this->config->getGcMaxLifetime()); 122 | } 123 | 124 | public function destroy($id): bool 125 | { 126 | return $this->store->delete($id); 127 | } 128 | 129 | public function gc(int $max_lifetime): int|false 130 | { 131 | return 0; 132 | } 133 | 134 | /** 135 | * @param string $id 136 | * @return float|false 137 | */ 138 | public function getTimestamp($id) 139 | { 140 | return $this->lastWriteTimestamp ?? false; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Handlers/SessionCasHandlerInterface.php: -------------------------------------------------------------------------------- 1 | sid->create_sid(); 15 | } while ($this->sid->validate_sid($id) && $this->validateId($id)); 16 | 17 | return $id; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Handlers/SessionLastModifiedTimestampHandlerInterface.php: -------------------------------------------------------------------------------- 1 | config = $config; 24 | $this->sid = new SessionId($config); 25 | } 26 | 27 | /** 28 | * Get the session config 29 | */ 30 | public function getConfig(): Config 31 | { 32 | return $this->config; 33 | } 34 | 35 | /** 36 | * Get the current session 37 | */ 38 | public function getCurrentSession(): ?Session 39 | { 40 | return $this->currentSession ?? null; 41 | } 42 | 43 | /** 44 | * Discard session array changes and finish session 45 | */ 46 | public function abort(): bool 47 | { 48 | if (!isset($this->currentSession)) { 49 | return false; 50 | } 51 | $this->currentSession->close(); 52 | 53 | $handler = $this->config->getSaveHandler(); 54 | if (!$handler) { 55 | return false; 56 | } 57 | 58 | return $handler->close(); 59 | } 60 | 61 | /** 62 | * Write session changes 63 | */ 64 | public function commit(): bool 65 | { 66 | if (!isset($this->currentSession)) { 67 | return false; 68 | } 69 | 70 | $handler = $this->config->getSaveHandler(); 71 | if (!$handler) { 72 | return false; 73 | } 74 | 75 | $id = $this->currentSession->getId(); 76 | $contents = $this->encode(); 77 | 78 | if ($contents === false) { 79 | $handler->destroy($id); 80 | $handler->close(); 81 | $this->currentSession->close(); 82 | return false; 83 | } 84 | 85 | if (!$this->currentSession->isModified() && $this->config->getLazyWrite()) { 86 | return false; 87 | } 88 | 89 | if ($handler instanceof Handlers\SessionCasHandlerInterface) { 90 | $token = $this->currentSession->getCasToken(); 91 | return $handler->write_cas($token, $id, $contents); 92 | } 93 | 94 | return $handler->write($id, $contents); 95 | } 96 | 97 | /** 98 | * Create new session id 99 | * 100 | * @throws InvalidArgumentException if $prefix is invalid 101 | */ 102 | public function create_id(string $prefix = ''): string 103 | { 104 | if ($prefix && preg_match('/^[a-zA-Z0-9,-]+$/', $prefix) === 0) { 105 | throw new InvalidArgumentException('$prefix contains disallowed characters'); 106 | } 107 | 108 | $this->config->setSidPrefix($prefix); 109 | 110 | $handler = $this->config->getSaveHandler(); 111 | 112 | if ($handler instanceof SessionIdInterface) { 113 | return $handler->create_sid(); 114 | } 115 | 116 | $id = $this->sid->create_sid(); 117 | 118 | return $id; 119 | } 120 | 121 | /** 122 | * Decodes session data from a session encoded string 123 | */ 124 | public function decode(string $data): bool 125 | { 126 | try { 127 | $serializer = $this->config->getSerializeHandler(); 128 | $this->currentSession->setContents($serializer->unserialize($data)); 129 | return true; 130 | } catch (Throwable $e) { 131 | return false; 132 | } 133 | } 134 | 135 | /** 136 | * Destroys all data registered to a session 137 | */ 138 | public function destroy(): bool 139 | { 140 | $handler = $this->config->getSaveHandler(); 141 | if ($handler) { 142 | return $handler->destroy($this->currentSession->getId()); 143 | } 144 | return false; 145 | } 146 | 147 | /** 148 | * Encodes the current session data as a session encoded string 149 | * 150 | * @return string|false 151 | */ 152 | public function encode() 153 | { 154 | try { 155 | $serializer = $this->config->getSerializeHandler(); 156 | return $serializer->serialize($this->currentSession->toArray()); 157 | } catch (Throwable $e) { 158 | return false; 159 | } 160 | } 161 | 162 | /** 163 | * Perform session data garbage collection 164 | * 165 | * @return int|false 166 | */ 167 | public function gc() 168 | { 169 | $handler = $this->config->getSaveHandler(); 170 | if ($handler) { 171 | return $handler->gc($this->config->getGcMaxLifetime()); 172 | } 173 | return false; 174 | } 175 | 176 | /** 177 | * Get and/or set the current session id 178 | */ 179 | public function id(?string $id = null): string 180 | { 181 | $returnId = isset($this->currentSession) 182 | ? $this->currentSession->getId() 183 | : ''; 184 | 185 | if (is_null($id)) { 186 | return $returnId; 187 | } 188 | 189 | $this->currentSession = new Session($this->config->getName(), $id); 190 | 191 | return $returnId; 192 | } 193 | 194 | /** 195 | * Get and/or set the current session name 196 | * 197 | * @return string|false 198 | */ 199 | public function name(?string $name = null) 200 | { 201 | $currentName = $this->config->getName(); 202 | 203 | if ($name) { 204 | /** 205 | * The session name can't consist of digits only, at least one letter must be present. 206 | * Otherwise a new session id is generated every time. 207 | */ 208 | if (ctype_digit($name)) { 209 | return false; 210 | } 211 | 212 | $this->config->setName($name); 213 | } 214 | 215 | return $currentName; 216 | } 217 | 218 | /** 219 | * Update the current session id with a newly generated one 220 | */ 221 | public function regenerate_id(bool $delete_old_session = false): bool 222 | { 223 | $oldId = $this->currentSession->getId(); 224 | $handler = $this->config->getSaveHandler(); 225 | 226 | if (!$handler) { 227 | return false; 228 | } 229 | 230 | $newId = $this->create_id(); 231 | $contents = $this->encode(); 232 | 233 | if ($newId === '' || $contents === false) { 234 | return false; 235 | } 236 | 237 | $isSaved = $handler->write($newId, $contents); 238 | 239 | if (!$isSaved) { 240 | return false; 241 | } 242 | 243 | $this->currentSession->open($newId); 244 | 245 | if ($handler instanceof Handlers\SessionCasHandlerInterface) { 246 | list($contents, $token) = $handler->read_cas($newId); 247 | $this->currentSession->setCasToken($token); 248 | } 249 | 250 | if ($delete_old_session) { 251 | return $handler->destroy($oldId); 252 | } 253 | 254 | return true; 255 | } 256 | 257 | /** 258 | * Session shutdown function 259 | */ 260 | public function register_shutdown(): void 261 | { 262 | register_shutdown_function([$this, 'write_close']); 263 | } 264 | 265 | /** 266 | * Re-initialize session array with original values 267 | */ 268 | public function reset(): bool 269 | { 270 | if (!isset($this->currentSession)) { 271 | return false; 272 | } 273 | 274 | $handler = $this->config->getSaveHandler(); 275 | if (!$handler) { 276 | return false; 277 | } 278 | 279 | if ($handler instanceof Handlers\SessionCasHandlerInterface) { 280 | list($contents, $token) = $handler->read_cas($this->currentSession->getId()); 281 | } else { 282 | $contents = $handler->read($this->currentSession->getId()); 283 | } 284 | 285 | if ($contents === false || !is_string($contents)) { 286 | return false; 287 | } 288 | 289 | $isDecoded = $this->decode($contents); 290 | 291 | if (!$isDecoded) { 292 | return false; 293 | } 294 | 295 | if ($handler instanceof Handlers\SessionCasHandlerInterface && isset($token)) { 296 | $this->currentSession->setCasToken($token); 297 | } 298 | 299 | return true; 300 | } 301 | 302 | /** 303 | * Get and/or set the current session save path 304 | * 305 | * @return null|string|true 306 | */ 307 | public function save_path(?string $save_path = null) 308 | { 309 | if (is_null($save_path)) { 310 | return $this->config->getSavePath(); 311 | } 312 | 313 | $this->config->setSavePath($save_path); 314 | return true; 315 | } 316 | 317 | /** 318 | * Sets user-level session storage functions 319 | */ 320 | public function set_save_handler( 321 | \SessionHandlerInterface $save_handler, 322 | bool $register_shutdown = true 323 | ): bool { 324 | $this->config->setSaveHandler($save_handler); 325 | 326 | if ($register_shutdown) { 327 | $this->register_shutdown(); 328 | } 329 | 330 | return true; 331 | } 332 | 333 | /** 334 | * Start new or resume existing session 335 | * 336 | * @return bool returns true if a session was successfully started, otherwise false 337 | */ 338 | public function start(): bool 339 | { 340 | $handler = $this->config->getSaveHandler(); 341 | if (!$handler) { 342 | return false; 343 | } 344 | 345 | $isOpen = $handler->open( 346 | $this->config->getSavePath() ?? '', 347 | $this->config->getName() 348 | ); 349 | 350 | if (!$isOpen) { 351 | $handler->close(); 352 | return false; 353 | } 354 | 355 | if ($this->config->getGcProbability() > 0) { 356 | $rnd = rand(1, 100); 357 | $probability = 100 * $this->config->getGcProbability() / $this->config->getGcDivisor(); 358 | if ($rnd <= $probability) { 359 | $handler->gc($this->config->getGcMaxLifetime()); 360 | } 361 | } 362 | 363 | if ( 364 | !isset($this->currentSession) 365 | || ( 366 | $handler instanceof SessionUpdateTimestampHandlerInterface 367 | && !$handler->validateId($this->currentSession->getId()) 368 | ) 369 | ) { 370 | $this->currentSession = new Session( 371 | $this->config->getName(), 372 | $this->sid->create_sid(), 373 | [] 374 | ); 375 | $encoded = $this->encode(); 376 | if ($encoded === false) { 377 | return false; 378 | } 379 | $handler->write($this->currentSession->getId(), $encoded); 380 | } 381 | 382 | $id = $this->currentSession->getId(); 383 | 384 | if ($handler instanceof Handlers\SessionCasHandlerInterface) { 385 | list($contents, $token) = $handler->read_cas($id); 386 | } else { 387 | $contents = $handler->read($id); 388 | } 389 | 390 | if ($contents === false) { 391 | $handler->close(); 392 | return false; 393 | } 394 | 395 | if (is_string($contents)) { 396 | $isDecoded = $this->decode($contents); 397 | 398 | if (!$isDecoded) { 399 | $handler->destroy($id); 400 | $handler->close(); 401 | $this->currentSession->close(); 402 | return false; 403 | } 404 | } 405 | 406 | if ($handler instanceof Handlers\SessionCasHandlerInterface && isset($token)) { 407 | $this->currentSession->setCasToken($token); 408 | } 409 | 410 | /** 411 | * In addition to the normal set of configuration directives, a read_and_close option may 412 | * also be provided. If set to true, this will result in the session being closed 413 | * immediately after being read, thereby avoiding unnecessary locking if the session data 414 | * won't be changed. 415 | */ 416 | if ($this->config->getReadAndClose()) { 417 | $this->currentSession->close(); 418 | return $handler->close(); 419 | } 420 | 421 | return true; 422 | } 423 | 424 | /** 425 | * Returns the current session status 426 | */ 427 | public function status(): int 428 | { 429 | if (!$this->config->getSaveHandler()) { 430 | return \PHP_SESSION_DISABLED; 431 | } 432 | 433 | if (!isset($this->currentSession) || !$this->currentSession->isInitialized()) { 434 | return \PHP_SESSION_NONE; 435 | } 436 | 437 | return \PHP_SESSION_ACTIVE; 438 | } 439 | 440 | /** 441 | * Free all session variables 442 | */ 443 | public function unset(): bool 444 | { 445 | if (!isset($this->currentSession)) { 446 | return false; 447 | } 448 | 449 | $keys = array_keys($this->currentSession->toArray()); 450 | foreach ($keys as $key) { 451 | unset($this->currentSession->$key); 452 | } 453 | 454 | return true; 455 | } 456 | 457 | /** 458 | * Write session data and end session 459 | */ 460 | public function write_close(): bool 461 | { 462 | if (!isset($this->currentSession)) { 463 | return false; 464 | } 465 | 466 | $handler = $this->config->getSaveHandler(); 467 | if (!$handler) { 468 | return false; 469 | } 470 | 471 | $id = $this->currentSession->getId(); 472 | $contents = $this->encode(); 473 | 474 | if ($contents === false) { 475 | $handler->destroy($id); 476 | $handler->close(); 477 | $this->currentSession->close(); 478 | throw new RuntimeException('Data serialization failure'); 479 | } 480 | 481 | if (!$this->currentSession->isModified() && $this->config->getLazyWrite()) { 482 | $this->currentSession->close(); 483 | return true; 484 | } 485 | 486 | if ($handler instanceof Handlers\SessionCasHandlerInterface) { 487 | $token = $this->currentSession->getCasToken(); 488 | $success = $handler->write_cas($token, $id, $contents); 489 | } else { 490 | $success = $handler->write($id, $contents); 491 | } 492 | 493 | if ($success) { 494 | $this->currentSession->close(); 495 | return true; 496 | } 497 | 498 | return false; 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /src/Middleware/SessionBeforeMiddleware.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 20 | } 21 | 22 | public function process(Request $request, Handler $handler): Response 23 | { 24 | $request = $request->withAttribute('sessionManager', $this->manager); 25 | return $handler->handle($request); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Middleware/SessionCacheControlMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 23 | 24 | /** @var ?Manager */ 25 | $manager = $request->getAttribute('sessionManager'); 26 | 27 | if (!$manager || !$manager instanceof Manager) { 28 | throw new RuntimeException('Missing session manager'); 29 | } 30 | 31 | if ($manager->status() === \PHP_SESSION_ACTIVE) { 32 | $config = $manager->getConfig(); 33 | 34 | $handler = $config->getSaveHandler(); 35 | 36 | if ($handler instanceof SessionLastModifiedTimestampHandlerInterface) { 37 | $lastUpdated = $handler->getTimestamp($manager->id()) ?: time(); 38 | if (is_float($lastUpdated)) { 39 | $lastUpdated = (int) ceil($lastUpdated); 40 | } 41 | } else { 42 | $lastUpdated = time(); 43 | } 44 | 45 | $cacheControl = CacheControl::createHeaders( 46 | $config->getCacheLimiter(), 47 | $config->getCacheExpire(), 48 | $lastUpdated ?: time() 49 | ); 50 | 51 | foreach ($cacheControl as $header => $value) { 52 | $response = $response->withHeader($header, $value); 53 | } 54 | } 55 | 56 | return $response; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Middleware/SessionCookieMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('sessionManager'); 23 | 24 | if (!$manager || !$manager instanceof Manager) { 25 | throw new RuntimeException('Missing session manager'); 26 | } 27 | 28 | // Read the session cookie 29 | $cookies = $request->getCookieParams(); 30 | $sid = $cookies[$manager->name()] ?? null; 31 | $manager->id($sid); 32 | 33 | // Handle the request 34 | $response = $handler->handle($request); 35 | 36 | // If the session ID changed, write a new session cookie 37 | if ($manager->id() !== $sid) { 38 | $config = $manager->getConfig(); 39 | return FigResponseCookies::set($response, SessionCookie::create( 40 | $manager->name() ?: '', 41 | $manager->id(), 42 | $config->getCookieLifetime(), 43 | $config->getCookieDomain(), 44 | $config->getCookiePath(), 45 | $config->getCookieSecure(), 46 | $config->getCookieHttpOnly(), 47 | $config->getCookieSameSite() 48 | )); 49 | } 50 | 51 | return $response; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Middleware/SessionMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('sessionManager'); 25 | 26 | if (!$manager || !$manager instanceof Manager) { 27 | throw new RuntimeException('Missing session manager'); 28 | } 29 | 30 | if ($manager->status() === \PHP_SESSION_DISABLED) { 31 | throw new RuntimeException( 32 | 'Session is disabled, check if your save handler is properly configured.' 33 | ); 34 | } 35 | 36 | // Start the session 37 | if ($manager->status() === \PHP_SESSION_NONE) { 38 | $manager->start(); 39 | } 40 | 41 | $request = $request->withAttribute('session', $manager->getCurrentSession()); 42 | 43 | // Rotate the session ID 44 | $interval = $manager->getConfig()->getRegenerateIdInterval(); 45 | if ($interval > 0) { 46 | $expiry = time() + $interval; 47 | /** @var Session $session */ 48 | $session = $manager->getCurrentSession(); 49 | $key = self::EXPIRATION_KEY; 50 | if (!isset($session->$key)) { 51 | $session->$key = $expiry; 52 | } elseif ($session->$key < time() || $session->$key > $expiry) { 53 | $manager->regenerate_id(true); 54 | /** @var Session $session */ 55 | $session = $manager->getCurrentSession(); 56 | $session->$key = $expiry; 57 | } 58 | } 59 | 60 | // Handle the request 61 | $response = $handler->handle($request); 62 | 63 | // Save and close the session 64 | if ($manager->status() === \PHP_SESSION_ACTIVE) { 65 | $isSaved = $manager->write_close(); 66 | 67 | if (!$isSaved) { 68 | throw new RuntimeException( 69 | 'Failed to save and close session, data may have been lost' 70 | ); 71 | } 72 | } 73 | 74 | return $response; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Serializers/BaseSerializer.php: -------------------------------------------------------------------------------- 1 | $contents 15 | */ 16 | abstract public function serialize(array $contents): string; 17 | 18 | /** 19 | * @return array 20 | */ 21 | abstract public function unserialize(string $contents): array; 22 | 23 | public function getLastError(): ?Throwable 24 | { 25 | return $this->lastError ?? null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Serializers/CallbackSerializer.php: -------------------------------------------------------------------------------- 1 | ): string 14 | */ 15 | private $serialize; 16 | 17 | /** 18 | * @var callable(string): array 19 | */ 20 | private $unserialize; 21 | 22 | /** 23 | * @param callable(array $contents): string $serialize 24 | * @param callable(string $contents): array $unserialize 25 | */ 26 | public function __construct(callable $serialize, callable $unserialize) 27 | { 28 | $this->serialize = $serialize; 29 | $this->unserialize = $unserialize; 30 | } 31 | 32 | /** 33 | * @param array $contents 34 | */ 35 | public function serialize(array $contents): string 36 | { 37 | try { 38 | $encoded = call_user_func($this->serialize, $contents); 39 | if (is_string($encoded)) { 40 | return $encoded; 41 | } 42 | throw new TypeError('$serialize must return a string when invoked'); 43 | } catch (Throwable $e) { 44 | $this->lastError = $e; 45 | throw $e; 46 | } 47 | } 48 | 49 | /** 50 | * @return array 51 | */ 52 | public function unserialize(string $contents): array 53 | { 54 | try { 55 | $decoded = call_user_func($this->unserialize, $contents); 56 | if (is_array($decoded)) { 57 | /** @var array */ 58 | return $decoded; 59 | } 60 | throw new TypeError('$unserialize must return an array when invoked'); 61 | } catch (Throwable $e) { 62 | $this->lastError = $e; 63 | throw $e; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Serializers/Factory.php: -------------------------------------------------------------------------------- 1 | $contents 13 | */ 14 | public function serialize(array $contents): string 15 | { 16 | try { 17 | return json_encode($contents, JSON_THROW_ON_ERROR) ?: ''; 18 | } catch (Throwable $e) { 19 | $this->lastError = $e; 20 | throw $e; 21 | } 22 | } 23 | 24 | /** 25 | * @return array 26 | */ 27 | public function unserialize(string $contents): array 28 | { 29 | try { 30 | /** @var array */ 31 | $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); 32 | return $decoded; 33 | } catch (Throwable $e) { 34 | $this->lastError = $e; 35 | throw $e; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Serializers/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | $contents 13 | */ 14 | public function serialize(array $contents): string; 15 | 16 | /** 17 | * @return array 18 | */ 19 | public function unserialize(string $contents): array; 20 | 21 | public function getLastError(): ?Throwable; 22 | } 23 | -------------------------------------------------------------------------------- /src/Session.php: -------------------------------------------------------------------------------- 1 | 15 | * @implements ArrayAccess 16 | */ 17 | class Session implements ArrayAccess, Iterator, Countable 18 | { 19 | protected string $name; 20 | 21 | protected string $id; 22 | 23 | /** 24 | * @var mixed[]|null 25 | */ 26 | protected ?array $contents = null; 27 | 28 | /** 29 | * @var int|float|string 30 | */ 31 | protected $casToken; 32 | 33 | protected bool $writeable = true; 34 | 35 | protected bool $modified = false; 36 | 37 | /** 38 | * @param mixed[]|null $contents 39 | */ 40 | public function __construct(string $name, ?string $id = null, ?array $contents = null) 41 | { 42 | $this->name = $name; 43 | 44 | if ($id) { 45 | $this->open($id, $contents); 46 | } 47 | } 48 | 49 | /** 50 | * @return mixed|null 51 | * 52 | * @throws RuntimeException if not initialized 53 | */ 54 | public function &__get(string $name) 55 | { 56 | if (!$this->isInitialized()) { 57 | throw new RuntimeException('Session not initialized'); 58 | } 59 | 60 | if(!isset($this->contents[$name])) { 61 | \trigger_error("Array key not found: '$name'", \E_USER_NOTICE); 62 | return null; 63 | } 64 | 65 | return $this->contents[$name]; 66 | } 67 | 68 | public function __isset(string $name): bool 69 | { 70 | if (!$this->isInitialized()) { 71 | throw new RuntimeException('Session not initialized'); 72 | } 73 | 74 | return isset($this->contents[$name]); 75 | } 76 | 77 | /** 78 | * @param mixed $value 79 | */ 80 | public function __set(string $name, $value): void 81 | { 82 | if (!$this->isInitialized()) { 83 | throw new RuntimeException('Session not initialized'); 84 | } 85 | 86 | if (!$this->writeable) { 87 | throw new RuntimeException('Cannot alter session after it is closed'); 88 | } 89 | 90 | $this->modified = true; 91 | $this->contents[$name] = $value; 92 | } 93 | 94 | public function __unset(string $name): void 95 | { 96 | if (!$this->isInitialized()) { 97 | throw new RuntimeException('Session not initialized'); 98 | } 99 | 100 | if (!$this->writeable) { 101 | throw new RuntimeException('Cannot alter session after it is closed'); 102 | } 103 | 104 | $this->modified = true; 105 | unset($this->contents[$name]); 106 | } 107 | 108 | public function offsetSet($name, $value): void 109 | { 110 | if (!$this->isInitialized()) { 111 | throw new RuntimeException('Session not initialized'); 112 | } 113 | 114 | if (!$this->writeable) { 115 | throw new RuntimeException('Cannot alter session after it is closed'); 116 | } 117 | 118 | $this->modified = true; 119 | $this->contents[$name] = $value; 120 | } 121 | 122 | public function offsetExists($name): bool 123 | { 124 | if (!$this->isInitialized()) { 125 | throw new RuntimeException('Session not initialized'); 126 | } 127 | 128 | return isset($this->contents[$name]); 129 | } 130 | 131 | public function offsetUnset($name): void 132 | { 133 | if (!$this->isInitialized()) { 134 | throw new RuntimeException('Session not initialized'); 135 | } 136 | 137 | if (!$this->writeable) { 138 | throw new RuntimeException('Cannot alter session after it is closed'); 139 | } 140 | 141 | $this->modified = true; 142 | unset($this->contents[$name]); 143 | } 144 | 145 | public function &offsetGet($name): mixed 146 | { 147 | if (!$this->isInitialized()) { 148 | throw new RuntimeException('Session not initialized'); 149 | } 150 | 151 | 152 | if(!isset($this->contents[$name])) { 153 | if($name === null || \is_scalar($name) || $name instanceof Stringable) { 154 | \trigger_error("Array key not found: '$name'", \E_USER_NOTICE); 155 | } 156 | return null; 157 | } 158 | 159 | return $this->contents[$name]; 160 | } 161 | 162 | /** 163 | * @param ?array $contents 164 | */ 165 | public function open(string $id, ?array $contents = null): void 166 | { 167 | $this->id = $id; 168 | $this->modified = false; 169 | $this->writeable = true; 170 | if (!is_null($contents)) { 171 | $this->setContents($contents); 172 | } 173 | } 174 | 175 | public function getName(): string 176 | { 177 | return $this->name ?? ''; 178 | } 179 | 180 | public function getId(): string 181 | { 182 | return $this->id ?? ''; 183 | } 184 | 185 | /** 186 | * @return int|float|string 187 | */ 188 | public function getCasToken() 189 | { 190 | return $this->casToken; 191 | } 192 | 193 | /** 194 | * @param int|float|string $token 195 | */ 196 | public function setCasToken($token): void 197 | { 198 | $this->casToken = $token; 199 | } 200 | 201 | /** 202 | * @param array $contents 203 | */ 204 | public function setContents(array $contents): void 205 | { 206 | $this->modified = true; 207 | $this->contents = $contents; 208 | } 209 | 210 | public function isInitialized(): bool 211 | { 212 | return !is_null($this->contents); 213 | } 214 | 215 | public function isWriteable(): bool 216 | { 217 | return $this->writeable; 218 | } 219 | 220 | public function isModified(): bool 221 | { 222 | return $this->modified; 223 | } 224 | 225 | public function close(): void 226 | { 227 | $this->writeable = false; 228 | } 229 | 230 | /** 231 | * @return array 232 | * 233 | * @throws RuntimeException if not initialized 234 | */ 235 | public function toArray(): array 236 | { 237 | if (!$this->isInitialized()) { 238 | throw new RuntimeException('Session not initialized'); 239 | } 240 | 241 | return $this->contents ?? []; 242 | } 243 | 244 | public function rewind(): void 245 | { 246 | if (!$this->isInitialized()) { 247 | throw new RuntimeException('Session not initialized'); 248 | } 249 | 250 | // @phpstan-ignore-next-line 251 | reset($this->contents); 252 | } 253 | 254 | public function current(): mixed 255 | { 256 | if (!$this->isInitialized()) { 257 | throw new RuntimeException('Session not initialized'); 258 | } 259 | 260 | // @phpstan-ignore-next-line 261 | return current($this->contents); 262 | } 263 | 264 | public function key(): mixed 265 | { 266 | if (!$this->isInitialized()) { 267 | throw new RuntimeException('Session not initialized'); 268 | } 269 | 270 | // @phpstan-ignore-next-line 271 | return key($this->contents); 272 | } 273 | 274 | public function next(): void 275 | { 276 | if (!$this->isInitialized()) { 277 | throw new RuntimeException('Session not initialized'); 278 | } 279 | 280 | // @phpstan-ignore-next-line 281 | next($this->contents); 282 | } 283 | 284 | public function valid(): bool 285 | { 286 | if (!$this->isInitialized()) { 287 | throw new RuntimeException('Session not initialized'); 288 | } 289 | 290 | // @phpstan-ignore-next-line 291 | return key($this->contents) !== null; 292 | } 293 | 294 | public function count(): int 295 | { 296 | return count($this->contents ?? []); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/SessionCookie.php: -------------------------------------------------------------------------------- 1 | withDomain($domain) 28 | ->withPath($path) 29 | ->withExpires($expires) 30 | ->withMaxAge($maxAge) 31 | ->withSecure($secure) 32 | ->withHttpOnly($httpOnly); 33 | 34 | if (!empty($sameSite)) { 35 | return $cookie->withSameSite(SameSite::fromString($sameSite)); 36 | } 37 | 38 | return $cookie; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/SessionId.php: -------------------------------------------------------------------------------- 1 | config = $config; 16 | } 17 | 18 | public function __toString(): string 19 | { 20 | return $this->create_sid(); 21 | } 22 | 23 | public function create_sid(): string 24 | { 25 | $prefix = $this->config->getSidPrefix(); 26 | $desiredOutputLength = $this->config->getSidLength() - strlen($prefix); 27 | $bitsPerCharacter = $this->config->getSidBitsPerCharacter(); 28 | 29 | $bytesNeeded = (int) ceil($desiredOutputLength * $bitsPerCharacter / 8); 30 | $randomInputBytes = random_bytes(max(1, $bytesNeeded)); 31 | 32 | // The below is translated from function bin_to_readable in the PHP source 33 | // (ext/session/session.c) 34 | static $hexconvtab = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,-'; 35 | 36 | $out = ''; 37 | 38 | $p = 0; 39 | $q = strlen($randomInputBytes); 40 | $w = 0; 41 | $have = 0; 42 | 43 | $mask = (1 << $bitsPerCharacter) - 1; 44 | 45 | $charsRemaining = $desiredOutputLength; 46 | while ($charsRemaining--) { 47 | if ($have < $bitsPerCharacter) { 48 | if ($p < $q) { 49 | $byte = ord($randomInputBytes[$p++]); 50 | $w |= ($byte << $have); 51 | $have += 8; 52 | } else { 53 | // Should never happen. Input must be large enough. 54 | break; 55 | } 56 | } 57 | 58 | // consume $bitsPerCharacter bits 59 | $out .= $hexconvtab[$w & $mask]; 60 | $w >>= $bitsPerCharacter; 61 | $have -= $bitsPerCharacter; 62 | } 63 | 64 | return $prefix . $out; 65 | } 66 | 67 | public function validate_sid(string $id): bool 68 | { 69 | if (strlen($id) !== $this->config->getSidLength()) { 70 | return false; 71 | } 72 | 73 | // Prefix might not validate under the rules for bits=4 or bits=5 74 | $prefix = $this->config->getSidPrefix(); 75 | if ($prefix) { 76 | $id = substr($id, strlen($prefix)); 77 | } 78 | 79 | switch ($this->config->getSidBitsPerCharacter()) { 80 | case 4: 81 | // 0123456789abcdef 82 | return preg_match('/^[0-9a-f]+$/', $id) === 1; 83 | 84 | case 5: 85 | // 0123456789abcdefghijklmnopqrstuv 86 | return preg_match('/^[0-9a-v]+$/', $id) === 1; 87 | 88 | case 6: 89 | // 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,- 90 | return preg_match('/^[0-9a-zA-Z,-]+$/', $id) === 1; 91 | } 92 | 93 | return false; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/behavior/AccessContext.php: -------------------------------------------------------------------------------- 1 | session = new Session('foo', 'bar', []); 23 | Assert::assertTrue($this->session->isWriteable()); 24 | Assert::assertCount(0, $this->session); 25 | } 26 | 27 | /** 28 | * @When data exists 29 | */ 30 | public function dataExists(): void 31 | { 32 | $this->session = new Session('foo', 'bar', ['foo' => 'bar']); 33 | Assert::assertTrue($this->session->isWriteable()); 34 | Assert::assertCount(1, $this->session); 35 | } 36 | 37 | /** 38 | * @When empty array for overload exists 39 | */ 40 | public function emptyArrayForOverloadExists(): void 41 | { 42 | $this->session = new Session('foo', 'bar', ['foo' => []]); 43 | Assert::assertTrue($this->session->isWriteable()); 44 | Assert::assertCount(1, $this->session); 45 | } 46 | 47 | /** 48 | * @Then property check returns false 49 | */ 50 | public function propertyCheckReturnsFalse(): void 51 | { 52 | Assert::assertFalse(isset($this->session->foo)); 53 | } 54 | 55 | /** 56 | * @Then property check returns true 57 | */ 58 | public function propertyCheckReturnsTrue(): void 59 | { 60 | Assert::assertTrue(isset($this->session->foo)); 61 | } 62 | 63 | /** 64 | * @Then property read returns data 65 | */ 66 | public function propertyReadReturnsData(): void 67 | { 68 | Assert::assertEquals('bar', $this->session->foo); 69 | } 70 | 71 | /** 72 | * @Then property read triggers error 73 | */ 74 | public function propertyReadTriggersNoticeError(): void 75 | { 76 | try { 77 | $errorThrown = false; 78 | $bar = $this->session->bar; 79 | // @phpstan-ignore-next-line 80 | } catch (Throwable $e) { 81 | $errorThrown = true; 82 | } 83 | 84 | // @phpstan-ignore-next-line 85 | Assert::assertTrue($errorThrown); 86 | } 87 | 88 | /** 89 | * @Then property read returns null 90 | */ 91 | public function propertyReadReturnsNull(): void 92 | { 93 | $bar = @$this->session->bar; 94 | Assert::assertSame(null, $bar); 95 | } 96 | 97 | /** 98 | * @Then property read with null coalesce returns null 99 | */ 100 | public function propertyReadWithNullCoalesceReturnsNull(): void 101 | { 102 | $bar = $this->session->bar ?? null; 103 | Assert::assertSame(null, $bar); 104 | } 105 | 106 | /** 107 | * @Then property write succeeds 108 | */ 109 | public function propertyWriteSucceeds(): void 110 | { 111 | $this->session->bar = 'baz'; 112 | Assert::assertCount(1, $this->session); 113 | Assert::assertTrue(isset($this->session->bar)); 114 | } 115 | 116 | /** 117 | * @Then array access check returns true 118 | */ 119 | public function arrayAccessCheckReturnsTrue(): void 120 | { 121 | Assert::assertTrue(isset($this->session['foo'])); 122 | } 123 | 124 | /** 125 | * @Then array access check returns false 126 | */ 127 | public function arrayAccessCheckReturnsFalse(): void 128 | { 129 | Assert::assertFalse(isset($this->session['foo'])); 130 | } 131 | 132 | /** 133 | * @Then array access read returns data 134 | */ 135 | public function arrayAccessReadReturnsData(): void 136 | { 137 | Assert::assertEquals('bar', $this->session['foo']); 138 | } 139 | 140 | /** 141 | * @Then array access read triggers error 142 | */ 143 | public function arrayAccessReadTriggersNoticeError(): void 144 | { 145 | try { 146 | $errorThrown = false; 147 | $bar = $this->session['bar']; 148 | // @phpstan-ignore-next-line 149 | } catch (Throwable $e) { 150 | $errorThrown = true; 151 | } 152 | 153 | // @phpstan-ignore-next-line 154 | Assert::assertTrue($errorThrown); 155 | } 156 | 157 | /** 158 | * @Then array access read returns null 159 | */ 160 | public function arrayAccessReadReturnsNull(): void 161 | { 162 | $bar = @$this->session['foo']; 163 | Assert::assertSame(null, $bar); 164 | } 165 | 166 | /** 167 | * @Then array access read with null coalesce returns null 168 | */ 169 | public function arrayAccessReadWithNullCoalesceReturnsNull(): void 170 | { 171 | $bar = $this->session['foo'] ?? null; 172 | Assert::assertSame(null, $bar); 173 | } 174 | 175 | /** 176 | * @Then array access write succeeds 177 | */ 178 | public function arrayAccessWriteSucceeds(): void 179 | { 180 | $this->session['bar'] = 'baz'; 181 | Assert::assertCount(1, $this->session); 182 | Assert::assertTrue(isset($this->session['bar'])); 183 | } 184 | 185 | /** 186 | * @Then data is iterated 187 | */ 188 | public function iteratorSucceeds(): void 189 | { 190 | $counter = 0; 191 | 192 | foreach ($this->session as $var => $val) { 193 | Assert::assertSame('foo', $var); 194 | Assert::assertSame('bar', $val); 195 | $counter++; 196 | } 197 | 198 | Assert::assertSame(1, $counter); 199 | } 200 | 201 | /** 202 | * @Then data is not iterated 203 | */ 204 | public function iteratorFails(): void 205 | { 206 | $counter = 0; 207 | 208 | foreach ($this->session as $var => $val) { 209 | $counter++; 210 | } 211 | 212 | Assert::assertSame(0, $counter); 213 | } 214 | 215 | /** 216 | * @Then overloading using array access succeeds 217 | */ 218 | public function arrayOverloadSucceeds(): void 219 | { 220 | // @phpstan-ignore-next-line 221 | $this->session['foo'][] = 'baz'; 222 | // @phpstan-ignore-next-line 223 | Assert::assertSame('baz', $this->session['foo'][0]); 224 | } 225 | 226 | /** 227 | * @Then overloading using property access succeeds 228 | */ 229 | public function objectOverloadSucceeds(): void 230 | { 231 | // @phpstan-ignore-next-line 232 | $this->session->foo[] = 'baz'; 233 | // @phpstan-ignore-next-line 234 | Assert::assertSame('baz', $this->session->foo[0]); 235 | } 236 | 237 | /** 238 | * @Then overloading using array access fails 239 | */ 240 | public function arrayOverloadFails(): void 241 | { 242 | try { 243 | $errorThrown = false; 244 | // @phpstan-ignore-next-line 245 | $this->session['foo'][] = 'baz'; 246 | } catch (Throwable $e) { 247 | $errorThrown = true; 248 | } finally { 249 | Assert::assertTrue($errorThrown); 250 | } 251 | } 252 | 253 | /** 254 | * @Then overloading using property access fails 255 | */ 256 | public function objectOverloadFails(): void 257 | { 258 | try { 259 | $errorThrown = false; 260 | // @phpstan-ignore-next-line 261 | $this->session->foo[] = 'baz'; 262 | } catch (Throwable $e) { 263 | $errorThrown = true; 264 | } finally { 265 | Assert::assertTrue($errorThrown); 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /tests/behavior/ConcurrencyContext.php: -------------------------------------------------------------------------------- 1 | config = new Config(); 26 | $handler = new ArrayHandler($this->config); 27 | $this->config->setSaveHandler($handler); 28 | } 29 | 30 | /** 31 | * @Given session has started 32 | */ 33 | public function sessionHasStarted(): void 34 | { 35 | $this->manager = new Manager($this->config); 36 | $this->manager->start(); 37 | $this->sid = $this->manager->id(); 38 | } 39 | 40 | /** 41 | * @When session changes 42 | */ 43 | public function sessionChanges(): void 44 | { 45 | /** @var Session $session */ 46 | $session = $this->manager->getCurrentSession(); 47 | $session->foo = 'bar'; 48 | } 49 | 50 | /** 51 | * @Then commit should succeed 52 | */ 53 | public function commitShouldSucceed(): void 54 | { 55 | $commitSucceeded = $this->manager->commit(); 56 | Assert::assertTrue($commitSucceeded, 'Session commit failed'); 57 | } 58 | 59 | /** 60 | * @Given session has been changed 61 | */ 62 | public function sessionHasBeenChanged(): void 63 | { 64 | $manager = new Manager($this->config); 65 | $manager->id($this->sid); 66 | $manager->start(); 67 | /** @var Session $session */ 68 | $session = $manager->getCurrentSession(); 69 | $session->foo = 'baz'; 70 | $commitSucceeded = $manager->commit(); 71 | Assert::assertTrue($commitSucceeded, 'Session commit failed'); 72 | } 73 | 74 | /** 75 | * @Then commit should fail 76 | */ 77 | public function commitShouldFail(): void 78 | { 79 | $commitSucceeded = $this->manager->commit(); 80 | Assert::assertFalse($commitSucceeded, 'Session commit failed'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/behavior/GarbageCollectionContext.php: -------------------------------------------------------------------------------- 1 | > 25 | */ 26 | private array $priorSessions = []; 27 | 28 | public function __construct() 29 | { 30 | $this->config = new Config(); 31 | $this->manager = new Manager($this->config); 32 | } 33 | 34 | /** 35 | * @Given there is garbage to collect 36 | */ 37 | public function thereIsGarbageToCollect(TableNode $table): void 38 | { 39 | Assert::assertGreaterThan(0, count($table->getTable())); 40 | 41 | $this->priorSessions = array_reduce( 42 | $table->getHash(), 43 | function (array $sessions, array $row) { 44 | // Skip the first row 45 | if ($row['id'] === 'id') { 46 | return $sessions; 47 | } 48 | 49 | $sessions[$row['id']] = [ 50 | 'data' => '', 51 | 'meta' => [ 52 | 'id' => $row['id'], 53 | 'last_modified' => strtotime($row['last_modified']), 54 | ], 55 | ]; 56 | 57 | return $sessions; 58 | }, 59 | [] 60 | ); 61 | 62 | Assert::assertCount(count($table->getHash()), $this->priorSessions); 63 | 64 | $this->handler = new ArrayHandler($this->config, $this->priorSessions); 65 | $this->config->setSaveHandler($this->handler); 66 | $this->config->setGcMaxLifetime(4 * 60 * 60); // 4 hours 67 | $this->config->setReadAndClose(true); 68 | 69 | Assert::assertCount(count($this->priorSessions), $this->handler); 70 | } 71 | 72 | /** 73 | * @Given garbage collection is disabled 74 | */ 75 | public function garbageCollectionIsDisabled(): void 76 | { 77 | $this->config->setGcProbability(0); 78 | } 79 | 80 | /** 81 | * @When session is started 82 | */ 83 | public function sessionIsStarted(): void 84 | { 85 | $isStarted = $this->manager->start(); 86 | Assert::assertTrue($isStarted, 'The session failed to start'); 87 | } 88 | 89 | /** 90 | * @Then garbage should remain 91 | */ 92 | public function garbageShouldRemain(): void 93 | { 94 | Assert::assertCount(count($this->priorSessions) + 1, $this->handler); 95 | } 96 | 97 | /** 98 | * @When garbage collection is run 99 | */ 100 | public function garbageCollectionIsRun(): void 101 | { 102 | $this->manager->gc(); 103 | } 104 | 105 | /** 106 | * @Then garbage should be collected 107 | */ 108 | public function garbageShouldBeCollected(): void 109 | { 110 | Assert::assertLessThan(count($this->priorSessions), count($this->handler)); 111 | Assert::assertGreaterThan(0, count($this->handler)); 112 | Assert::assertCount(4, $this->handler); 113 | } 114 | 115 | /** 116 | * @Then prior garbage should be collected 117 | */ 118 | public function priorGarbageShouldBeCollected(): void 119 | { 120 | Assert::assertLessThan(count($this->priorSessions), count($this->handler)); 121 | Assert::assertGreaterThan(0, count($this->handler)); 122 | Assert::assertCount(5, $this->handler); 123 | } 124 | 125 | /** 126 | * @Given probability is set to :probability / :divisor 127 | */ 128 | public function probabilityIsSetTo(int $probability, int $divisor): void 129 | { 130 | $this->config->setGcProbability($probability); 131 | $this->config->setGcDivisor($divisor); 132 | } 133 | 134 | /** 135 | * @When session is started :requests times 136 | */ 137 | public function sessionIsStartedTimes(int $n): void 138 | { 139 | $id = null; 140 | for ($i = 0; $i < $n; $i++) { 141 | $manager = new Manager($this->config); 142 | $manager->id($id); 143 | $manager->start(); 144 | $id = $manager->id(); 145 | unset($manager); 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/behavior/GivenHandlerContextTrait.php: -------------------------------------------------------------------------------- 1 | config = new Config(); 27 | 28 | $this->config->setSavePath($location); 29 | 30 | switch ($handler) { 31 | case 'kodus': 32 | $cache = new \Kodus\Cache\FileCache($location, $this->config->getGcMaxLifetime()); 33 | $handler = new Psr16Handler($this->config, $cache); 34 | break; 35 | case 'scrapbook': 36 | $fs = new \League\Flysystem\Filesystem( 37 | new \League\Flysystem\Local\LocalFilesystemAdapter($location, null, LOCK_EX) 38 | ); 39 | $cache = new \MatthiasMullie\Scrapbook\Adapters\Flysystem($fs); 40 | $handler = new ScrapbookHandler($this->config, $cache); 41 | break; 42 | case 'redis': 43 | $this->config->setSavePath('tcp://localhost:6379?database=0'); 44 | $handler = new RedisHandler($this->config); 45 | break; 46 | case 'file': 47 | $handler = new FileHandler($this->config); 48 | break; 49 | default: 50 | throw new RuntimeException('Not implemented: ' . $handler); 51 | } 52 | 53 | $this->config->setSaveHandler($handler); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/behavior/IdContext.php: -------------------------------------------------------------------------------- 1 | config = new Config(); 27 | $handler = new ArrayHandler($this->config); 28 | $this->config->setSaveHandler($handler); 29 | $this->manager = new Manager($this->config); 30 | } 31 | 32 | /** 33 | * @When default configuration 34 | */ 35 | public function defaultConfiguration(): void 36 | { 37 | $this->config = new Config(); 38 | $this->manager = new Manager($this->config); 39 | } 40 | 41 | /** 42 | * @Then length is :length and bits is :bits 43 | */ 44 | public function lengthIsAndBitsIs(int $length, int $bits): void 45 | { 46 | Assert::assertSame($length, $this->config->getSidLength()); 47 | Assert::assertSame($bits, $this->config->getSidBitsPerCharacter()); 48 | } 49 | 50 | /** 51 | * @Given :bits, :length, and :prefix 52 | */ 53 | public function bitsAndLengthAndPrefix(int $bits, int $length, string $prefix): void 54 | { 55 | $this->config->setSidBitsPerCharacter($bits); 56 | $this->config->setSidLength($length); 57 | $this->prefix = $prefix; 58 | } 59 | 60 | /** 61 | * @Given no save handler 62 | */ 63 | public function noSaveHandler(): void 64 | { 65 | Assert::assertNull($this->config->getSaveHandler()); 66 | } 67 | 68 | /** 69 | * @When Generating an ID 70 | */ 71 | public function generatingAnId(): void 72 | { 73 | $this->id = $this->manager->create_id($this->prefix); 74 | } 75 | 76 | /** 77 | * @Then length must be :length 78 | */ 79 | public function lengthMustBe(int $length): void 80 | { 81 | Assert::assertSame($length, strlen($this->id), 'Incorrect length'); 82 | } 83 | 84 | /** 85 | * @Then the ID must be allowed characters 86 | */ 87 | public function theIdMustBeAllowedCharacters(): void 88 | { 89 | $id = substr($this->id, strlen($this->prefix)); 90 | switch ($this->config->getSidBitsPerCharacter()) { 91 | case 4: 92 | // 0123456789abcdef 93 | Assert::assertTrue( 94 | preg_match('/^[0-9a-f]+$/', $id) === 1, 95 | 'Invalid characters, ' . $id 96 | ); 97 | break; 98 | 99 | case 5: 100 | // 0123456789abcdefghijklmnopqrstuv 101 | Assert::assertTrue( 102 | preg_match('/^[0-9a-v]+$/', $id) === 1, 103 | 'Invalid characters, ' . $id 104 | ); 105 | break; 106 | 107 | case 6: 108 | // 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,- 109 | Assert::assertTrue( 110 | preg_match('/^[0-9a-zA-Z,-]+$/', $id) === 1, 111 | 'Invalid characters, ' . $id 112 | ); 113 | } 114 | } 115 | 116 | /** 117 | * @Then it must start with :prefix 118 | */ 119 | public function itMustStartWith(string $prefix): void 120 | { 121 | // @phpstan-ignore-next-line 122 | Assert::assertStringStartsWith($prefix, $this->id); 123 | } 124 | 125 | /** 126 | * @Given no ID 127 | */ 128 | public function noId(): void 129 | { 130 | $this->id = $this->manager->id(); 131 | Assert::assertEmpty($this->id); 132 | Assert::assertIsString($this->id); 133 | } 134 | 135 | /** 136 | * @When session is started 137 | */ 138 | public function sessionIsStarted(): void 139 | { 140 | $started = $this->manager->start(); 141 | Assert::assertTrue($started, 'Session failed to start'); 142 | } 143 | 144 | /** 145 | * @Then ID should be generated 146 | */ 147 | public function idShouldBeGenerated(): void 148 | { 149 | $id = $this->manager->id(); 150 | Assert::assertNotEmpty($id); 151 | Assert::assertNotEquals($this->id, $id); 152 | } 153 | 154 | /** 155 | * @Given invalid ID 156 | */ 157 | public function invalidId(): void 158 | { 159 | $this->manager->id('#$%^'); 160 | $this->id = $this->manager->id(); 161 | Assert::assertNotEmpty($this->id); 162 | Assert::assertIsString($this->id); 163 | } 164 | 165 | /** 166 | * @Given :bits bits and :length characters 167 | */ 168 | public function bitsAndCharacters(int $bits, int $length): void 169 | { 170 | $this->config->setSidBitsPerCharacter($bits); 171 | $this->config->setSidLength($length); 172 | } 173 | 174 | /** 175 | * @Given :n IDs already exist 176 | */ 177 | public function idsAlreadyExist(int $n): void 178 | { 179 | $handler = new ArrayHandler($this->config); 180 | for ($i = 0; $i < $n; $i++) { 181 | $id = $handler->create_sid(); 182 | $handler->write($id, ''); 183 | } 184 | Assert::assertCount($n, $handler); 185 | $this->config->setSaveHandler($handler); 186 | } 187 | 188 | /** 189 | * @When :n IDs are generated 190 | */ 191 | public function idsAreGenerated(int $n): void 192 | { 193 | /** @var ArrayHandler $handler */ 194 | $handler = $this->config->getSaveHandler(); 195 | for ($i = 0; $i < $n; $i++) { 196 | $id = $handler->create_sid(); 197 | $handler->write($id, ''); 198 | } 199 | } 200 | 201 | /** 202 | * @Then there are :n IDs and no collisions 203 | */ 204 | public function thereAreNoCollisions(int $n): void 205 | { 206 | /** @var ArrayHandler $handler */ 207 | $handler = $this->config->getSaveHandler(); 208 | Assert::assertCount($n, $handler); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /tests/behavior/PersistenceContext.php: -------------------------------------------------------------------------------- 1 | manager = new Manager($this->config); 34 | $isStarted = $this->manager->start(); 35 | Assert::assertTrue($isStarted, 'Session failed to start'); 36 | /** @var Session $session */ 37 | $session = $this->manager->getCurrentSession(); 38 | $this->session = $session; 39 | } 40 | 41 | /** 42 | * @Then session is writeable 43 | */ 44 | public function sessionIsWriteable(): void 45 | { 46 | Assert::assertCount(0, $this->session); 47 | Assert::assertTrue($this->session->isWriteable(), 'Session not writeable'); 48 | $this->session->foo = 'bar'; 49 | Assert::assertCount(1, $this->session); 50 | Assert::assertTrue($this->session->isModified(), 'Session not modified'); 51 | } 52 | 53 | /** 54 | * @Then session is saved and closed 55 | */ 56 | public function sessionIsSavedAndClosed(): void 57 | { 58 | $this->previousSessionId = $this->session->getId(); 59 | $isClosed = $this->manager->write_close(); 60 | Assert::assertTrue($isClosed, 'Session save failed'); 61 | } 62 | 63 | /** 64 | * @Then further session writes are not saved 65 | */ 66 | public function furtherSessionWritesAreNotSaved(): void 67 | { 68 | Assert::assertFalse($this->session->isWriteable()); 69 | 70 | try { 71 | $this->session->__set('bar', 'baz'); 72 | } catch (RuntimeException $e) { 73 | // ignore 74 | } finally { 75 | Assert::assertFalse($this->session->__isset('bar')); 76 | } 77 | } 78 | 79 | /** 80 | * @Then previous session is started 81 | */ 82 | public function previousSessionStarted(): void 83 | { 84 | /** @var SessionHandlerInterface $handler */ 85 | $handler = $this->config->getSaveHandler(); 86 | $handler->close(); 87 | $this->manager = new Manager($this->config); 88 | $this->manager->id($this->previousSessionId); 89 | $isStarted = $this->manager->start(); 90 | Assert::assertTrue($isStarted, 'Previous session failed to start'); 91 | Assert::assertSame($this->previousSessionId, $this->manager->id()); 92 | /** @var Session $session */ 93 | $session = $this->manager->getCurrentSession(); 94 | $this->session = $session; 95 | } 96 | 97 | /** 98 | * @Then session is readable 99 | */ 100 | public function sessionIsReadable(): void 101 | { 102 | Assert::assertCount(1, $this->session); 103 | Assert::assertTrue(isset($this->session->foo), 'Session data not persisted'); 104 | Assert::assertSame('bar', $this->session->foo, 'Session data unexpected'); 105 | } 106 | 107 | /** 108 | * @Then session can be reset 109 | */ 110 | public function sessionCanBeReset(): void 111 | { 112 | $isReset = $this->manager->reset(); 113 | Assert::assertTrue($isReset, 'Session reset failed'); 114 | /** @var Session $session */ 115 | $session = $this->manager->getCurrentSession(); 116 | $this->session = $session; 117 | Assert::assertCount(1, $this->session); 118 | } 119 | 120 | /** 121 | * @Then session can be erased 122 | */ 123 | public function sessionCanBeErased(): void 124 | { 125 | Assert::assertCount(1, $this->session); 126 | $isErased = $this->manager->unset(); 127 | Assert::assertTrue($isErased, 'Session reset failed'); 128 | Assert::assertCount(0, $this->session); 129 | } 130 | 131 | /** 132 | * @Then session can be deleted 133 | */ 134 | public function sessionCanBeDeleted(): void 135 | { 136 | $isDeleted = $this->manager->destroy(); 137 | Assert::assertTrue($isDeleted, 'Session delete failed'); 138 | $isReset = $this->manager->reset(); 139 | Assert::assertFalse($isReset, 'Session reset should not have succeeded'); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/behavior/RegenerationContext.php: -------------------------------------------------------------------------------- 1 | config = new Config(); 27 | $this->config->setGcProbability(0); 28 | } 29 | 30 | /** 31 | * @Given session is started and modified 32 | */ 33 | public function sessionIsStartedAndModified(): void 34 | { 35 | $this->manager = new Manager($this->config); 36 | $isStarted = $this->manager->start(); 37 | Assert::assertTrue($isStarted, 'Session failed to start'); 38 | 39 | $this->sid = $this->manager->id(); 40 | 41 | /** @var Session $session */ 42 | $session = $this->manager->getCurrentSession(); 43 | $session->foo = 'bar'; 44 | $isCommitted = $this->manager->commit(); 45 | Assert::assertTrue($isCommitted); 46 | } 47 | 48 | /** 49 | * @When session ID is regenerated, delete old session 50 | */ 51 | public function sessionIdIsRegeneratedDeleteOldSession(bool $delete = true): void 52 | { 53 | $isRegenerated = $this->manager->regenerate_id($delete); 54 | Assert::assertTrue($isRegenerated, 'Session failed to regenerate'); 55 | } 56 | 57 | /** 58 | * @Then session ID should change 59 | */ 60 | public function sessionIdShouldChange(): void 61 | { 62 | Assert::assertNotSame($this->sid, $this->manager->id()); 63 | } 64 | 65 | /** 66 | * @Then session data should be preserved 67 | */ 68 | public function sessionDataShouldBePreserved(): void 69 | { 70 | $manager = new Manager($this->config); 71 | $manager->id($this->manager->id()); 72 | $isStarted = $manager->start(); 73 | Assert::assertTrue($isStarted, 'Session failed to start'); 74 | 75 | /** @var Session $session */ 76 | $session = $manager->getCurrentSession(); 77 | Assert::assertTrue(isset($session->foo)); 78 | Assert::assertSame('bar', $session->foo); 79 | } 80 | 81 | /** 82 | * @Then old session should not remain 83 | */ 84 | public function oldSessionRemains(bool $remains = false): void 85 | { 86 | $manager = new Manager($this->config); 87 | $manager->id($this->sid); 88 | $manager->start(); 89 | /** @var Session $session */ 90 | $session = $manager->getCurrentSession(); 91 | if ($remains) { 92 | Assert::assertSame($this->sid, $session->getId()); 93 | Assert::assertTrue(isset($session->foo)); 94 | Assert::assertSame('bar', $session->foo); 95 | } else { 96 | Assert::assertNotSame($this->sid, $session->getId()); 97 | Assert::assertFalse(isset($session->foo)); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/integration/server/App/Routes/SessionRoutes.php: -------------------------------------------------------------------------------- 1 | config = $config; 20 | } 21 | 22 | public function readSession( 23 | ServerRequestInterface $request, 24 | ResponseInterface $response 25 | ): ResponseInterface { 26 | /** @var Session $session */ 27 | $session = $request->getAttribute('session'); 28 | 29 | /** @var int $count */ 30 | $count = $session->counter ?? 0; 31 | $body = 'Hello, world: ' . $session->getId() . ', ' . strval($count); 32 | 33 | $config = $this->config->toArray(); 34 | /** @var SessionHandlerInterface $handler */ 35 | $handler = $config['save_handler']; 36 | $config['save_handler'] = get_class($handler); 37 | 38 | $body .= "\n
" . print_r($config, true);
39 | 
40 |         $response->getBody()->write($body);
41 | 
42 |         return $response;
43 |     }
44 | 
45 |     public function writeSession(
46 |         ServerRequestInterface $request,
47 |         ResponseInterface $response
48 |     ): ResponseInterface {
49 |         /** @var Session $session */
50 |         $session = $request->getAttribute('session');
51 |         if (!isset($session->counter)) {
52 |             $session->counter = 0;
53 |         } else {
54 |             /** @var int $count */
55 |             $count = $session->counter;
56 |             $session->counter = ++$count;
57 |         }
58 |         $body = 'Hello, world: ' . $session->getId() . ', ' . strval($count ?? 0);
59 |         $response->getBody()->write($body);
60 |         return $response;
61 |     }
62 | }
63 | 


--------------------------------------------------------------------------------
/tests/integration/server/App/app.php:
--------------------------------------------------------------------------------
 1 | 
20 |  */
21 | function app(): App
22 | {
23 |     $builder = new ContainerBuilder();
24 |     $builder->addDefinitions(__DIR__ . '/config.php');
25 |     $container = $builder->build();
26 | 
27 |     AppFactory::setContainer($container);
28 |     $app = AppFactory::create();
29 | 
30 |     /** @var LoggerInterface $logger */
31 |     $logger = $container->get(LoggerInterface::class);
32 | 
33 |     // Middleware
34 |     $app->add(AccessLogMiddleware::class);
35 |     registerSessionMiddleware($app);
36 |     $app->addRoutingMiddleware(); // must come before ErrorMiddleware
37 |     $app->addErrorMiddleware( // must come last
38 |         true,  // display error details
39 |         true,  // log errors
40 |         false, // log error details
41 |         $logger
42 |     );
43 | 
44 |     // App routes
45 |     /** @var Routes\SessionRoutes $routes */
46 |     $routes = $container->get(Routes\SessionRoutes::class);
47 |     $app->get('/', fn(ServerRequestInterface $request, ResponseInterface $response) => $routes->readSession($request, $response));
48 |     $app->post('/', fn(ServerRequestInterface $request, ResponseInterface $response) => $routes->writeSession($request, $response));
49 | 
50 |     return $app;
51 | }
52 | 


--------------------------------------------------------------------------------
/tests/integration/server/App/config.php:
--------------------------------------------------------------------------------
 1 |  DI\factory(function () {
15 |         $logger = new Logger('access');
16 |         $logHandler = new StreamHandler('php://stdout');
17 |         $logHandler->setFormatter(new ColoredLineFormatter());
18 |         $logger->pushHandler($logHandler);
19 |         return $logger;
20 |     }),
21 | 
22 |     SessionConfig::class => DI\factory(function () {
23 |         $config = (new SessionConfig())
24 |             ->setRegenerateIdInterval(180)
25 |             ->setCookieLifetime(3600)
26 |             ->setCookiePath('/')
27 |             ->setCookieSecure(false)
28 |             ->setCookieHttpOnly(true)
29 |             ->setCookieSameSite('strict')
30 |             ->setCacheLimiter('nocache')
31 |             ->setGcProbability(1)
32 |             ->setGcDivisor(1)
33 |             ->setGcMaxLifetime(7200)
34 |             ->setSidLength(48)
35 |             ->setSidBitsPerCharacter(5)
36 |             ->setLazyWrite(true);
37 | 
38 |         $config->setSavePath(sys_get_temp_dir() . DIRECTORY_SEPARATOR . $config->getName());
39 | 
40 |         $config->setSaveHandler(
41 |             new SessionSaveHandler($config, new FileCache(
42 |                 $config->getSavePath() ?? sys_get_temp_dir(),
43 |                 $config->getGcMaxLifetime()
44 |             ))
45 |         );
46 | 
47 |         return $config;
48 |     })
49 | ];
50 | 


--------------------------------------------------------------------------------
/tests/integration/server/index.php:
--------------------------------------------------------------------------------
 1 | run();
10 | 


--------------------------------------------------------------------------------