From 6c01ecddf29ed663c4e8633bc140084c8a0dacfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 20:13:37 +0300 Subject: [PATCH] feat: absorb initphp/cli-table; document table rendering The Console package's Utils/Table has been structurally identical to the standalone initphp/cli-table package for some time. Formalise the consolidation: - Declare composer 'replace' for initphp/cli-table so the deprecated package is not installed alongside this one. - Add aliases.php with a class_alias keeping \InitPHP\CLITable\Table resolvable for transitive consumers. - Document the table renderer in the README and add a migration section for users coming from initphp/cli-table. --- .github/workflows/ci.yml | 85 ++++++++ .gitignore | 3 + .php-cs-fixer.dist.php | 35 ++++ README.md | 302 ++++++++++++++------------- composer.json | 41 +++- docs/README.md | 33 +++ docs/adapters/cookie.md | 156 ++++++++++++++ docs/adapters/custom.md | 274 ++++++++++++++++++++++++ docs/adapters/null.md | 69 ++++++ docs/adapters/session.md | 108 ++++++++++ docs/getting-started.md | 84 ++++++++ docs/permissions.md | 110 ++++++++++ docs/recipes/basic-auth-example.md | 100 +++++++++ docs/recipes/multi-segment.md | 91 ++++++++ docs/upgrading-from-v1.md | 230 ++++++++++++++++++++ phpstan.neon.dist | 7 + phpunit.xml.dist | 33 +++ src/AbstractAdapter.php | 61 ++---- src/AdapterInterface.php | 65 +++--- src/Cookie/CookieWriterInterface.php | 28 +++ src/Cookie/InMemoryCookieWriter.php | 65 ++++++ src/Cookie/NativeCookieWriter.php | 23 ++ src/CookieAdapter.php | 204 +++++++++++++----- src/NullAdapter.php | 62 +++--- src/Permission.php | 175 ++++++++++++---- src/Segment.php | 205 ++++++++++++++---- src/SessionAdapter.php | 86 ++++---- tests/CookieAdapterTest.php | 287 +++++++++++++++++++++++++ tests/Fixture/NotAnAdapter.php | 13 ++ tests/Fixture/RecordingAdapter.php | 77 +++++++ tests/NullAdapterTest.php | 68 ++++++ tests/PermissionTest.php | 164 +++++++++++++++ tests/SegmentTest.php | 189 +++++++++++++++++ tests/SessionAdapterTest.php | 174 +++++++++++++++ 34 files changed, 3284 insertions(+), 423 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 docs/README.md create mode 100644 docs/adapters/cookie.md create mode 100644 docs/adapters/custom.md create mode 100644 docs/adapters/null.md create mode 100644 docs/adapters/session.md create mode 100644 docs/getting-started.md create mode 100644 docs/permissions.md create mode 100644 docs/recipes/basic-auth-example.md create mode 100644 docs/recipes/multi-segment.md create mode 100644 docs/upgrading-from-v1.md create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Cookie/CookieWriterInterface.php create mode 100644 src/Cookie/InMemoryCookieWriter.php create mode 100644 src/Cookie/NativeCookieWriter.php create mode 100644 tests/CookieAdapterTest.php create mode 100644 tests/Fixture/NotAnAdapter.php create mode 100644 tests/Fixture/RecordingAdapter.php create mode 100644 tests/NullAdapterTest.php create mode 100644 tests/PermissionTest.php create mode 100644 tests/SegmentTest.php create mode 100644 tests/SessionAdapterTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14e61a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: [main, "feat/*", "fix/*"] + pull_request: + branches: [main] + +jobs: + tests: + name: PHP ${{ matrix.php }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: ["8.0", "8.1", "8.2", "8.3", "8.4"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, hash + coverage: xdebug + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php${{ matrix.php }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run test suite + run: vendor/bin/phpunit --testdox + + static-analysis: + name: Static analysis (PHPStan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress + + code-style: + name: Code style (PHP-CS-Fixer) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: PHP-CS-Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.gitignore b/.gitignore index e834a7d..6b9e56a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ /.vscode/ /vendor/ /composer.lock +/build/ +/.phpunit.cache/ +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..99c949d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,35 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PHP80Migration' => true, + 'declare_strict_types' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + 'no_trailing_whitespace' => true, + 'no_whitespace_in_blank_line' => true, + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => ['statements' => ['return']], + 'method_chaining_indentation' => true, + 'native_function_invocation' => false, + 'phpdoc_align' => ['align' => 'vertical'], + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'no_superfluous_phpdoc_tags' => false, + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/README.md b/README.md index 397698a..16d853e 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,211 @@ # InitPHP Auth -This library makes logged in user data more organized and easily accessible. +A small PHP authentication & authorization library with pluggable storage +adapters (session, signed cookie, or custom) and a tiny case-insensitive +permission set. + +[![Latest Stable Version](https://poser.pugx.org/initphp/auth/v)](https://packagist.org/packages/initphp/auth) +[![Total Downloads](https://poser.pugx.org/initphp/auth/downloads)](https://packagist.org/packages/initphp/auth) +[![CI](https://github.com/InitPHP/Auth/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/Auth/actions/workflows/ci.yml) +[![License](https://poser.pugx.org/initphp/auth/license)](https://packagist.org/packages/initphp/auth) +[![PHP Version Require](https://poser.pugx.org/initphp/auth/require/php)](https://packagist.org/packages/initphp/auth) + +--- ## Features -- Easy to use user permissions manager. -- Ability to use user authorization data on cookies or sessions. -- Ability to write and use your own authorization class. +- **Pluggable storage** — pick `SessionAdapter`, `CookieAdapter`, or roll + your own by implementing `AdapterInterface`. +- **Signed cookies** — JSON payload sealed with constant-time HMAC-SHA256; + tampered values are dropped before decoding ever runs. +- **Strict cookie defaults** — `Secure`, `SameSite=Lax`, `HttpOnly`, and + refusal of the unsafe `SameSite=None + Secure=false` combination. +- **Testable** — inject a `CookieWriterInterface` to capture every + `setcookie()` call in unit tests instead of touching response headers. +- **Tiny permission set** — `Permission` does case-insensitive membership + checks and ships magic accessors (`$perm->is_admin`). +- **Honest contracts** — typed properties, return types, `@throws` on + every implementation-defined exception, PHPStan level 8 clean. ## Requirements -- PHP 7.4 or later -- [InitPHP ParameterBag Library](https://github.com/InitPHP/ParameterBag) +- PHP 8.0 or later (tested on 8.0 – 8.4) +- ext-json, ext-hash (both bundled with default PHP builds) +- [`initphp/parameterbag`](https://github.com/InitPHP/ParameterBag) `^2.0` ## Installation -``` +```bash composer require initphp/auth ``` -## Usage +## Quick start -### Use of Permissions - -It is a small but capable library that you can use to define user permissions. +### Session-backed auth ```php -require_once 'vendor/autoload.php'; +use InitPHP\Auth\Segment; -$perm = new \InitPHP\Auth\Permission([ - 'editor', - 'post_list', 'post_edit', 'post_add', 'post_delete' -]); +session_start(); + +$auth = Segment::session('auth'); +$auth->set('user_id', 42)->set('role', 'editor'); -if($perm->is('editor')){ - // has "editor" authority - $perm->remove('editor'); // remove "editor" permissions - $perm->push('user'); // added "user" permission +if ($auth->has('user_id')) { + $user = loadUser($auth->get('user_id')); } + +$auth->destroy(); // unsets $_SESSION['auth'] ``` -**Multiple use :** +### Signed-cookie auth ```php -/** @var \InitPHP\Auth\Permission $perm */ +use InitPHP\Auth\Segment; -$perm->is('admin', 'editor'); // True if "admin" or "editor" privileges. Returns false if none of the specified are present. +$auth = Segment::cookie('auth', [ + // 32+ byte secret. Generate with bin2hex(random_bytes(32)) and + // load it from configuration — never hard-code it in source. + 'salt' => $_ENV['AUTH_COOKIE_SECRET'], + 'path' => '/', + 'domain' => 'example.com', +]); -$perm->remove('admin', 'editor'); // Removes the specified permissions. And returns the actual number of permissions removed. +$auth->set('user_id', 42); +echo $auth->get('user_id'); // 42 -$perm->push('admin', 'editor'); // Adds the specified permissions. Returns the number of permissions added. +$auth->destroy(); // emits a deletion cookie with matching path/domain ``` -### Cookie Adapter - -It manages session data on `$_COOKIE` provided by PHP. +### Permissions ```php -require_once 'vendor/autoload.php'; -use InitPHP\Auth\Segment; +use InitPHP\Auth\Permission; -$auth = Segment::create('authorization', Segment::ADAPTER_COOKIE, [ - 'salt' => 'QO.@zeZiFgSvQd-:' // It is used to verify that the data in this cookie has not changed. Define a unique and secret string of at least 8 characters. -]); +// Comparison is case-insensitive: 'Editor', 'EDITOR', and 'editor' +// are the same permission. The constructor normalizes its input the +// same way push() and remove() do. +$perm = new Permission(['Editor', 'post_list', 'post_edit']); + +if ($perm->is('editor')) { + $perm->push('user'); // returns 1 + $perm->remove('post_edit'); // returns 1 +} + +$perm->is('admin', 'editor'); // true if any of the names is present +isset($perm->is_admin); // magic accessor for templates ``` -### Session Adapter -It manages session data on `$_SESSION` provided by PHP. +## Public API -```php -session_start(); -require_once 'vendor/autoload.php'; -use InitPHP\Auth\Segment; +### `Segment` -$auth = Segment::create('authorization', Segment::ADAPTER_SESSION); -``` +| Method | Purpose | +| --- | --- | +| `Segment::session(string $name, array $options = []): self` | Build a segment backed by `$_SESSION`. | +| `Segment::cookie(string $name, array $options): self` | Build a segment backed by a signed cookie (`salt` required). | +| `Segment::custom(string $name, class-string $adapterClass, array $options = []): self` | Build a segment backed by your own adapter. | +| `Segment::create(string $name, int\|string $adapter, array $options = []): self` | Legacy v1 factory; kept for BC. | +| `adapter(): AdapterInterface` | Escape hatch for adapter-specific methods. | +| `get/set/has/remove/collective/destroy` | Forwarded to the underlying adapter. | -### Write and use your own adapter. +### `AdapterInterface` -In the example below you can see an example of a simple adapter for basic auth with the help of a database connection. +| Method | Purpose | +| --- | --- | +| `get(string $key, mixed $default = null): mixed` | Look up a value or fall back to `$default`. | +| `set(string $key, mixed $value): static` | Assign / replace a value. | +| `collective(array $data): static` | Atomic bulk write. Cookie adapters emit one `Set-Cookie` instead of N. | +| `has(string $key): bool` | Existence check (a stored `null` still counts as present). | +| `remove(string ...$keys): static` | Drop one or more keys (missing keys are a no-op). | +| `destroy(): bool` | Tear down the backing store. Subsequent calls raise `RuntimeException`. | -**_Note :_** The example below is purely for instructional purposes. Using the code below directly will cause serious security vulnerabilities. +### `Permission` -```php -namespace App; - -class BasicAuthAdapter extends InitPHP\Auth\AbstractAdapter -{ - /** @var \PDO */ - protected $pdo; - - protected array $userInfo = []; - - public function __construct(string $name, array $options = []) - { - $this->pdo = new \PDO($options['dsn'], $options['username'], $options['password']); - $statement = $this->pdo->prepare("SELECT * FROM `ùsers` WHERE `user_name` = :user_name AND `password` = :password LIMIT 1"); - $statement->execute([ - ':user_name' => ($_SERVER['PHP_AUTH_USER'] ?? ''), - ':password' => md5(($_SERVER['PHP_AUTH_PW'] ?? '')) - ]); - if($statement->rowCount() > 0){ - $this->userInfo = $statement->fetch(\PDO::FETCH_ASSOC); - }else{ - header("WWW-Authenticate: Basic realm=\"Privare Area\""); - header("HTTP/1.0 401 Unauthorized"); - echo "Sorry, you need proper credendtials"; - exit; - } - } - - public function get(string $key, $default = null) - { - return $this->userInfo[$key] ?? $default; - } - - public function set(string $key, $value): self - { - if($key == 'user_name'){ - return $this; - } - $statement = $this->pdo->query("UPDATE `ùsers` SET `" . $key . "` = '" . (string)$value . "' WHERE `ùser_name` = " . $this->userInfo['user_name']); - if($statement !== FALSE){ - unset($this->userInfo[$key]); - } - return $this; - } - - public function collective(array $data): self - { - if(isset($data['user_name'])){ - unset($data['user_name']); - } - if(empty($data)){ - return $this; - } - $sql = "UPDATE `ùsers` SET"; - foreach ($data as $key => $value) { - $sql .= " `" . $key . "` = '" . $value . "'"; - } - $sql .= " WHERE `ùser_name` = '" . $this->userInfo['user_name'] . "'"; - if($this->pdo->query($sql) !== FALSE){ - $this->userInfo = array_merge($this->userInfo, $data); - } - return $this; - } - - public function has(string $key): bool - { - return isset($this->userInfo[$key]); - } - - public function remove(string ...$key): self - { - foreach ($key as $name) { - if($key == 'user_name'){ - continue; - } - if(isset($this->userInfo[$key])){ - $this->userInfo[$key]; - $this->pdo->query("UPDATE `ùsers` SET `" . $key . "` = NULL WHERE `ùser_name` = '".$this->userInfo['user_name']."'"); - } - } - return $this; - } - - public function destroy(): bool - { - $this->userInfo = []; - return true; - } +| Method | Purpose | +| --- | --- | +| `is(string ...$names): bool` | True when **any** of the names is present. Case-insensitive. | +| `push(string ...$names): int` | Adds names, returns the count actually inserted. | +| `remove(string ...$names): int` | Removes names, returns the count actually removed; the list is reindexed. | +| `getPermissions(): list` | Snapshot of the current permission list. | -} +Magic accessors: `$perm->is_admin` (call), `isset($perm->is_admin)`, +`unset($perm->is_admin)`. + +## CookieAdapter options + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `salt` | `string` | — required | At least 32 bytes. Use `bin2hex(random_bytes(32))`. | +| `expires` | `int\|null` | now + 86 400 s | Unix timestamp. `null` resets to the default. | +| `path` | `string` | `'/'` | RFC 6265 path scope. | +| `domain` | `string` | `''` | Empty disables the `Domain` attribute. | +| `secure` | `bool` | `true` | When false, modern browsers reject `SameSite=None`. | +| `httponly` | `bool` | `true` | Blocks JS access via `document.cookie`. | +| `samesite` | `'Lax'\|'Strict'\|'None'` | `'Lax'` | `'None'` is rejected unless `secure=true`. | + +### Cookie wire format + +``` +base64url(json_encode($data)) . "." . hash_hmac('sha256', $json, $salt) ``` -```php -$segment = new \InitPHP\Auth\Segment('', \App\BasicAuthAdapter::class, [ - 'dsn' => 'mysql:host=localhost;dbname=test_database;charset=utf8mb4', - 'username' => 'root', - 'password' => '' -]); +The signature is verified with `hash_equals()` **before** the JSON is +decoded, so a forged or modified cookie never reaches the parser. + +## Exceptions + +| Exception | Raised when | +| --- | --- | +| `InvalidArgumentException` | Missing/short/non-string `salt`, `SameSite=None` without `Secure`, unknown adapter constant, missing adapter class, class that does not extend `AbstractAdapter`. | +| `RuntimeException` | `SessionAdapter` constructed with no active session, or any read/write on an adapter whose `destroy()` has been called. | +| `BadMethodCallException` | `Permission::__call()` invoked with a name that does not start with `is_`. | + +## Development + +```bash +composer install +composer test # PHPUnit +composer analyse # PHPStan (level 8) +composer cs:check # PHP-CS-Fixer dry-run +composer cs:fix # PHP-CS-Fixer apply ``` +CI runs the matrix across PHP 8.0, 8.1, 8.2, 8.3, and 8.4. + +## Documentation + +- [docs/getting-started.md](docs/getting-started.md) — five-minute tour +- [docs/permissions.md](docs/permissions.md) — `Permission` recipes +- [docs/adapters/session.md](docs/adapters/session.md) — `SessionAdapter` +- [docs/adapters/cookie.md](docs/adapters/cookie.md) — `CookieAdapter`, + salt generation, SameSite/Secure guidance +- [docs/adapters/custom.md](docs/adapters/custom.md) — building your own + adapter (with a safe PDO-backed example) +- [docs/adapters/null.md](docs/adapters/null.md) — `NullAdapter` and when + to use it +- [docs/upgrading-from-v1.md](docs/upgrading-from-v1.md) — v1 → v2 + migration notes + +## Upgrading from v1 + +v2 ships intentional behaviour changes — most notably a new cookie +format (old cookies become unreadable and are rolled), case-folding moved +into the `Permission` constructor, a stricter cookie default profile, +`NullAdapter::has()` returning `false` instead of `true`, and a clean +adapter interface that no longer enforces a constructor signature. See +[docs/upgrading-from-v1.md](docs/upgrading-from-v1.md). + +## Contributing & Security + +- [Contributing guidelines](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) +- [Code of Conduct](https://github.com/InitPHP/.github/blob/main/CODE_OF_CONDUCT.md) +- [Security policy](https://github.com/InitPHP/.github/blob/main/SECURITY.md) + ## Credits -- [Muhammet ŞAFAK](https://github.com/muhammetsafak) <> +- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> ## License -Copyright © 2022 [MIT License](./LICENSE) +Released under the [MIT License](./LICENSE). diff --git a/composer.json b/composer.json index 11ad463..9e42a38 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,31 @@ { "name": "initphp/auth", - "description": "PHP Authorization Library", + "description": "PHP authentication & authorization library with pluggable storage adapters (session, cookie, custom) and a small permission manager.", "type": "library", "license": "MIT", + "keywords": [ + "auth", + "authentication", + "authorization", + "permissions", + "session", + "cookie", + "initphp" + ], "autoload": { "psr-4": { "InitPHP\\Auth\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "InitPHP\\Auth\\Tests\\": "tests/" + } + }, + "support": { + "source": "https://github.com/InitPHP/Auth", + "issues": "https://github.com/InitPHP/Auth/issues" + }, "authors": [ { "name": "Muhammet ŞAFAK", @@ -18,7 +36,24 @@ ], "minimum-stability": "stable", "require": { - "php": ">=7.4", - "initphp/parameterbag": "^1.0" + "php": "^8.0", + "ext-json": "*", + "ext-hash": "*", + "initphp/parameterbag": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.12", + "friendsofphp/php-cs-fixer": "^3.64" + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-html=build/coverage", + "analyse": "phpstan analyse", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..39ede60 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,33 @@ +# Documentation + +Developer documentation for the `initphp/auth` package. The project +[README](../README.md) gives a one-page overview; this directory goes +deeper. + +## Index + +- [Getting started](getting-started.md) — install, instantiate, and + read your first segment. +- [Permissions](permissions.md) — case-insensitive permission set, + magic accessors, serialization. +- **Adapters** + - [SessionAdapter](adapters/session.md) — `$_SESSION`-backed storage. + - [CookieAdapter](adapters/cookie.md) — signed-cookie storage, + salt generation, `SameSite`/`Secure` guidance, custom writers. + - [Custom adapters](adapters/custom.md) — implement + `AdapterInterface` for databases, Redis, JWT, anything. + - [NullAdapter](adapters/null.md) — no-op adapter for tests and + feature flags. +- **Recipes** + - [Multiple segments per request](recipes/multi-segment.md) — auth, + cart, and CSRF state side by side. + - [Basic-auth credential cache](recipes/basic-auth-example.md) — the + PDO-backed example from the v1 README, rewritten without SQL + injection. +- [Upgrading from v1](upgrading-from-v1.md) — BC notes for v2. + +## How to read these docs + +Every page is structured as **Goal → Working example → Expected output +→ Common mistakes**. Snippets are copy-paste ready against the released +package; outputs were generated against the test suite. diff --git a/docs/adapters/cookie.md b/docs/adapters/cookie.md new file mode 100644 index 0000000..0101add --- /dev/null +++ b/docs/adapters/cookie.md @@ -0,0 +1,156 @@ +# CookieAdapter + +`InitPHP\Auth\CookieAdapter` stores auth state in a signed, JSON-encoded +cookie. It is the backing store for `Segment::cookie()`. + +## How the cookie is built + +``` +base64url(json_encode($data)) . "." . hash_hmac('sha256', $json, $salt) +``` + +The signature is verified with `hash_equals()` **before** the JSON is +decoded, so a forged or tampered cookie never reaches the parser. There +is no encryption — the payload is plain JSON. **Do not put secrets in +it.** Put a session id, a user id, a CSRF token. Anything you would not +write into a server log probably does not belong here. + +## Goal + +Keep the logged-in user's id and role on the client without holding a +server-side session. The cookie travels with every request, is signed +with a secret only the server knows, and is rejected on any +modification. + +## Working example + +```php + $_ENV['AUTH_COOKIE_SECRET'], // 32+ bytes, loaded from env + 'path' => '/', + 'domain' => 'example.com', +]); + +$auth->set('user_id', 42)->set('role', 'editor'); + +echo $auth->get('user_id'), PHP_EOL; // 42 +``` + +Expected output (first response writes the cookie; subsequent requests +read it back): + +``` +42 +``` + +## Generating a salt + +The salt is the HMAC key. Treat it as you would any other secret. + +```php +// Run once, store the result in your environment (.env, secrets manager). +echo bin2hex(random_bytes(32)), PHP_EOL; +// e.g. "9f6c1a7d3e8b50…" — 64 hex characters, 32 bytes of entropy +``` + +Rotating the salt invalidates every existing cookie. Plan for a logout +of all users when you rotate. + +## Constructor options + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `salt` | `string` | **required** | At least 32 bytes. Shorter values throw `InvalidArgumentException`. | +| `expires` | `int\|null` | `time() + 86400` | Unix timestamp. `null` resets to the default. | +| `path` | `string` | `'/'` | RFC 6265 path scope. | +| `domain` | `string` | `''` | Empty disables the `Domain` attribute. | +| `secure` | `bool` | `true` | When false, modern browsers reject `SameSite=None`. | +| `httponly` | `bool` | `true` | Blocks JS access via `document.cookie`. | +| `samesite` | `'Lax'\|'Strict'\|'None'` | `'Lax'` | `'None'` requires `secure=true`. | + +The defaults are deliberately strict. The pre-flight check rejects the +unsafe combination eagerly: + +```php +Segment::cookie('auth', [ + 'salt' => $secret, + 'samesite' => 'None', + 'secure' => false, +]); +// InvalidArgumentException: SameSite=None requires the cookie to be marked Secure. +``` + +## Cleaning up: `destroy()` + +Deleting a cookie is not just "set an empty value". The browser only +honours the deletion when the headers carry the **same** `path` and +`domain` as the original. The adapter reuses `$this->options` and only +overrides `expires`, so the cookie set with `path=/admin` is removed +with `path=/admin`. + +```php +$auth = Segment::cookie('auth', [ + 'salt' => $secret, + 'path' => '/admin', +]); + +$auth->destroy(); +// Set-Cookie: auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/admin; ... +``` + +After `destroy()`, any read or write on the adapter raises +`RuntimeException`. + +## Testing without touching response headers + +`CookieAdapter` accepts a third constructor argument: a +`CookieWriterInterface`. The default is `NativeCookieWriter` (delegates +to `setcookie()`); a test can swap in `InMemoryCookieWriter`, which +records every call instead of emitting a header. + +```php +use InitPHP\Auth\Cookie\InMemoryCookieWriter; +use InitPHP\Auth\CookieAdapter; + +$writer = new InMemoryCookieWriter(); +$adapter = new CookieAdapter('auth', ['salt' => $secret], $writer); + +$adapter->set('user_id', 42); + +$last = $writer->lastCall(); +// $last === ['name' => 'auth', 'value' => 'eyJ1c2VyX2lkIjo0Mn0.abc…', 'options' => [...]] +``` + +Use this in unit tests to assert that `destroy()` actually emits the +matching path/domain, or that `collective()` only fires one +`Set-Cookie` for a bulk write. + +You can also implement your own writer — for example to route cookies +through a PSR-7 response object instead of PHP's `setcookie()`. + +## Legacy v1 cookies + +v1 cookies were `base64(serialize([...]))`. v2 cannot read them: they +have no dot separator, so the decoder treats them as malformed and +returns an empty bag. Users will be issued fresh v2 cookies on their +next write — there is no decode path that would risk running an +`unserialize()` against attacker-controlled bytes. + +## Common mistakes + +- **Hard-coding the salt in source.** It belongs in your secrets + manager / environment, not in a committed file. A leaked salt + invalidates the entire signature scheme. +- **Reusing one salt across applications.** Use a different secret per + application so a leak does not cascade. +- **Storing secrets in the cookie.** Sign, do not encrypt. The cookie + is readable by anyone who intercepts it — keep it limited to ids + and tokens. +- **Setting `expires` to a relative number.** The option is a Unix + timestamp, not a TTL. Use `time() + 3600`, not `3600`. diff --git a/docs/adapters/custom.md b/docs/adapters/custom.md new file mode 100644 index 0000000..9815206 --- /dev/null +++ b/docs/adapters/custom.md @@ -0,0 +1,274 @@ +# Custom adapters + +Anything that implements `InitPHP\Auth\AdapterInterface` can sit behind +a `Segment`. Extending `InitPHP\Auth\AbstractAdapter` is the quickest +path — it ships a sensible default `collective()` that iterates `set()`, +so you only have to implement the operations that matter. + +## Goal + +Back auth state with a real data store (a database, Redis, JWT, your +favourite key/value service) while still benefiting from the +`Segment` facade and the `AdapterInterface` contract. + +## A minimal working adapter + +```php + */ + private array $store = []; + + public function get(string $key, $default = null) + { + return $this->store[$key] ?? $default; + } + + public function set(string $key, $value): AdapterInterface + { + $this->store[$key] = $value; + return $this; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->store); + } + + public function remove(string ...$key): AdapterInterface + { + foreach ($key as $name) { + unset($this->store[$name]); + } + return $this; + } + + public function destroy(): bool + { + $this->store = []; + return true; + } +} +``` + +`collective()` is not implemented — the parent class provides a default +that calls `set()` per pair. Override it if your backing store can +commit atomically (the way `CookieAdapter` overrides it to emit one +`Set-Cookie` header for a bulk write). + +## Using it through `Segment` + +```php +use InitPHP\Auth\Segment; + +$auth = Segment::custom('auth', App\Auth\ArrayAdapter::class); + +$auth->set('user_id', 42); +$auth->get('user_id'); // 42 +``` + +The custom factory requires the class to extend `AbstractAdapter`. +Passing a class that does not raises `InvalidArgumentException` — see +[Exceptions](../README.md#exceptions). + +## Constructor signature + +`AdapterInterface` deliberately does **not** include a constructor. +Different backing stores need different dependencies: a salt, a PDO +handle, a Redis client. Sign your constructor however the store needs. + +The `Segment::custom()` factory will, however, invoke your constructor +as `new YourClass($name, $options)`, so the convention if you want it +to be `Segment`-compatible is to accept those two arguments. + +```php +final class DatabaseAdapter extends AbstractAdapter +{ + private \PDO $pdo; + private string $segment; + + /** + * @param array{pdo: \PDO} $options + */ + public function __construct(string $name, array $options) + { + if (!isset($options['pdo']) || !$options['pdo'] instanceof \PDO) { + throw new \InvalidArgumentException('A PDO handle is required.'); + } + $this->segment = $name; + $this->pdo = $options['pdo']; + } + + // ... get/set/has/remove/destroy ... +} + +$auth = Segment::custom('auth', App\Auth\DatabaseAdapter::class, [ + 'pdo' => $pdo, +]); +``` + +## A safer PDO-backed example + +The v1 README shipped a `BasicAuthAdapter` sample that concatenated +user input into SQL strings. That example is replaced here with a +prepared-statement version that uses `password_verify()` and never +trusts the request directly. + +```php +pdo = new PDO($options['dsn'], $options['username'], $options['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $this->user = $this->authenticate(); + } + + public function get(string $key, $default = null) + { + return $this->user[$key] ?? $default; + } + + public function set(string $key, $value): AdapterInterface + { + $this->guardWritableColumn($key); + + $stmt = $this->pdo->prepare( + // Column name is whitelisted above; the value is bound. + \sprintf('UPDATE users SET %s = :value WHERE id = :id', $key) + ); + $stmt->execute([ + ':value' => $value, + ':id' => $this->user['id'], + ]); + $this->user[$key] = $value; + + return $this; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->user); + } + + public function remove(string ...$key): AdapterInterface + { + foreach ($key as $column) { + $this->guardWritableColumn($column); + $stmt = $this->pdo->prepare( + \sprintf('UPDATE users SET %s = NULL WHERE id = :id', $column) + ); + $stmt->execute([':id' => $this->user['id']]); + unset($this->user[$column]); + } + return $this; + } + + public function destroy(): bool + { + $this->user = ['id' => 0, 'username' => '', 'password_hash' => '']; + return true; + } + + /** + * @return UserRow + */ + private function authenticate(): array + { + $username = $_SERVER['PHP_AUTH_USER'] ?? ''; + $password = $_SERVER['PHP_AUTH_PW'] ?? ''; + + $stmt = $this->pdo->prepare( + 'SELECT id, username, password_hash, role FROM users WHERE username = :username LIMIT 1' + ); + $stmt->execute([':username' => $username]); + /** @var UserRow|false $row */ + $row = $stmt->fetch(); + + if ($row === false || !\password_verify($password, $row['password_hash'])) { + \header('WWW-Authenticate: Basic realm="Private Area"'); + \header('HTTP/1.1 401 Unauthorized'); + exit('Authentication required.'); + } + + return $row; + } + + private function guardWritableColumn(string $column): void + { + if (!\in_array($column, self::ALLOWED_COLUMNS, true)) { + throw new \InvalidArgumentException(\sprintf( + 'Column "%s" is not writable through %s.', + $column, + self::class + )); + } + } +} +``` + +Key differences from the v1 example: + +- **No string concatenation of values.** Every value is bound through a + prepared statement. +- **No string concatenation of column names either.** Column names that + PDO cannot bind are whitelisted in `ALLOWED_COLUMNS`; anything else + raises an exception before the SQL is built. +- **`password_verify()` instead of `md5()`.** Stored hashes should come + from `password_hash($plain, PASSWORD_DEFAULT)` at registration time. +- **Strict PDO mode.** `ERRMODE_EXCEPTION` + `EMULATE_PREPARES=false` + means PDO will refuse to silently degrade. + +## Common mistakes + +- **Implementing the interface directly without `AbstractAdapter`.** + You can — but you lose the default `collective()` and have to + implement six methods instead of five. +- **Throwing from `destroy()`.** Callers expect `destroy()` to be + idempotent. Return `true` once the store is clean and let subsequent + reads fail with `RuntimeException` via your `getStore()` guard. +- **Forwarding raw user input as SQL column names.** Always whitelist. + Prepared statements bind values, not identifiers. diff --git a/docs/adapters/null.md b/docs/adapters/null.md new file mode 100644 index 0000000..e4c4aa9 --- /dev/null +++ b/docs/adapters/null.md @@ -0,0 +1,69 @@ +# NullAdapter + +`InitPHP\Auth\NullAdapter` is a [Null Object](https://en.wikipedia.org/wiki/Null_object_pattern) +implementation of `AdapterInterface`. Every operation succeeds and +stores nothing. + +## Goal + +You have code that expects an `AdapterInterface` but you do not want to +materialise a real backing store — typically in tests, CLI scripts, or +behind a feature flag. + +## Working example + +```php +set('user_id', 42); +var_export($adapter->get('user_id')); // NULL +var_export($adapter->has('user_id')); // false +var_export($adapter->destroy()); // true +``` + +Expected output: + +``` +NULL +false +true +``` + +## Behaviour summary + +| Method | Returns | +| --- | --- | +| `get($key, $default = null)` | `$default` (always). | +| `set($key, $value)` | `$this` (no-op). | +| `collective($data)` | `$this` (no-op). | +| `has($key)` | `false` (always). | +| `remove(...$keys)` | `$this` (no-op). | +| `destroy()` | `true`. | + +## When to use it + +- **Unit tests** for code that depends on an adapter but should not + exercise session / cookie machinery. +- **CLI scripts** where you want the code path to run without a real + user. +- **Feature flags** — wrap the real adapter behind a check and fall + back to `NullAdapter` when the feature is off. + +## v1 → v2 behaviour change + +In v1, `NullAdapter::has()` returned `true`, which combined with +`get()` always returning `$default` produced the inconsistent pair +`has(x) === true && get(x) === null`. v2 makes `has()` honest: nothing +is ever present in a Null Object store. + +If you relied on the old behaviour to satisfy a guard like +`if (!$adapter->has('user'))`, the guard will now fire correctly under +`NullAdapter`. In most cases that is the bug fix you wanted; if it is +not, the call site should not have been using `NullAdapter` to begin +with. diff --git a/docs/adapters/session.md b/docs/adapters/session.md new file mode 100644 index 0000000..e8057ae --- /dev/null +++ b/docs/adapters/session.md @@ -0,0 +1,108 @@ +# SessionAdapter + +`InitPHP\Auth\SessionAdapter` stores auth state under a single key inside +`$_SESSION`. It is the default backing store for `Segment::session()`. + +## Goal + +Persist the logged-in user's id and role across requests using PHP's +native session machinery, without manually keying into `$_SESSION` at +every call site. + +## Working example + +```php +set('user_id', 42)->set('role', 'editor'); + +echo $auth->get('user_id'), PHP_EOL; +var_export(isset($_SESSION['auth'])); +``` + +Expected output: + +``` +42 +true +``` + +The state lives at `$_SESSION['auth']` — `'auth'` is the segment name +passed to the factory. Multiple segments coexist happily under different +names: + +```php +$auth = Segment::session('auth'); +$cart = Segment::session('cart'); + +$auth->set('user_id', 42); +$cart->set('items', 3); + +$_SESSION; // ['auth' => ['user_id' => 42], 'cart' => ['items' => 3]] +``` + +## Active session is mandatory + +The adapter refuses to operate against an inactive session because doing +so would silently drop every subsequent write. + +```php +// session_status() === PHP_SESSION_NONE +new SessionAdapter('auth'); +// RuntimeException: Sessions must be started. +``` + +Call `session_start()` before instantiating the adapter (or the segment +factory). + +## Constructor options + +The second argument is forwarded straight to the internal +[`ParameterBag`](https://github.com/InitPHP/ParameterBag). The useful +knobs are: + +| Key | Type | Default | Effect | +| --- | --- | --- | --- | +| `isMulti` | `bool` | `false` | Enables dotted-path access (`$auth->get('user.profile.name')`). | +| `separator` | `string` | `'.'` | Path delimiter when `isMulti` is on. | +| `caseInsensitive` | `bool` | `false` | Folds every key to lower-case on storage and lookup. | + +```php +$_SESSION['auth'] = ['db' => ['host' => 'localhost', 'port' => 3306]]; + +$auth = new SessionAdapter('auth', ['isMulti' => true]); +$auth->get('db.host'); // 'localhost' +$auth->get('db.port'); // 3306 +``` + +## Lifecycle + +| Call | Effect on `$_SESSION` | +| --- | --- | +| `set($k, $v)` | Writes `$_SESSION[$name]` with the updated bag. | +| `collective([...])` | Same, in one go (does not emit per-key writes). | +| `remove($k)` | Drops the key, then re-syncs `$_SESSION[$name]`. | +| `destroy()` | `unset($_SESSION[$name])`; returns `true` if the slot existed. | + +After `destroy()`, any read or write on the adapter raises +`RuntimeException`. Create a fresh adapter if you need to start over. + +## Common mistakes + +- **Calling `session_destroy()` instead of `$auth->destroy()`.** The + adapter only touches its own slot — that is the point of segmenting + the session. `session_destroy()` would wipe every segment plus any + unrelated state PHP holds for the user. +- **Sharing one `SessionAdapter` instance across forks/queues.** PHP + sessions are tied to the current request. Move state through your + job payload, not through the session bag. +- **Forgetting `session_start()` when running CLI tests.** The CLI SAPI + does not seed `session.save_path`; supply it via `ini_set()` or a + `phpunit.xml` `` entry. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d7ac770 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,84 @@ +# Getting started + +## Install + +```bash +composer require initphp/auth +``` + +Requires PHP 8.0 or later (tested up to 8.4) and the bundled +[`initphp/parameterbag`](https://github.com/InitPHP/ParameterBag) `^2.0`. + +## Your first segment + +A `Segment` is a named slice of auth state. Pick a backing adapter, +write some keys, read them back. + +```php +set('user_id', 42)->set('role', 'editor'); + +echo $auth->get('user_id'), PHP_EOL; // 42 +var_export($auth->has('role')); // true +$auth->destroy(); +``` + +Expected output: + +``` +42 +true +``` + +## Picking an adapter + +| Adapter | Pick when | +| --- | --- | +| `SessionAdapter` | You already use PHP sessions and trust them to live as long as the user does. | +| `CookieAdapter` | You want a stateless server, or you want auth state to survive a session restart. The cookie is signed but **not** encrypted — do not put secrets in it. | +| Custom (implements `AdapterInterface`) | You want to keep the state in a database, Redis, JWT, or anywhere else. | +| `NullAdapter` | Tests, CLI scripts, feature flags — a drop-in that throws nothing and stores nothing. | + +The factory methods on `Segment` mirror those: + +```php +Segment::session('auth'); +Segment::cookie('auth', ['salt' => $secret]); +Segment::custom('auth', App\Auth\DatabaseAdapter::class, [...]); +``` + +The legacy `Segment::create($name, $adapter, $options)` factory is also +kept for v1 callers. + +## A note on permissions + +The `Permission` class is a separate, dependency-free helper. It is +case-insensitive on the way in **and** on the way out, so the v1 footgun +where `new Permission(['Editor'])->is('editor')` returned `false` no +longer exists. + +```php +use InitPHP\Auth\Permission; + +$perm = new Permission(['Editor', 'POST_LIST']); +$perm->is('editor'); // true +$perm->is('post_list'); // true +``` + +## Next steps + +- Walk through the [Permission API](permissions.md). +- Read the adapter you picked: + [Session](adapters/session.md) · + [Cookie](adapters/cookie.md) · + [Custom](adapters/custom.md) · + [Null](adapters/null.md). +- Coming from v1? See [Upgrading from v1](upgrading-from-v1.md). diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..8cbc047 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,110 @@ +# Permissions + +`InitPHP\Auth\Permission` is a small, dependency-free set of named +permissions. It does case-insensitive membership checks, deduplicates +on insertion, and exposes a couple of magic accessors that read well in +templates. + +## Goal + +You have a list of permission strings attached to a user. You want to +ask "does this user have *editor* rights?" without worrying about whether +the database row stored the value as `Editor`, `EDITOR`, or `editor`. + +## Working example + +```php +use InitPHP\Auth\Permission; + +$perm = new Permission(['Editor', 'POST_LIST', 'post_edit']); + +$perm->is('editor'); // true +$perm->is('admin'); // false +$perm->is('viewer', 'editor'); // true — any match wins + +$perm->push('user', 'Editor'); // returns 1 (the duplicate is skipped) +$perm->remove('post_edit'); // returns 1 + +print_r($perm->getPermissions()); +``` + +Expected output: + +``` +Array +( + [0] => editor + [1] => post_list + [2] => user +) +``` + +## API reference + +| Method | Returns | Notes | +| --- | --- | --- | +| `__construct(array $permissions = [])` | — | Non-string entries are silently skipped. Duplicates are dropped post-normalization. | +| `is(string ...$names): bool` | `bool` | True when **any** name is present. | +| `push(string ...$names): int` | `int` | Number of names actually inserted (already-present names return 0). | +| `remove(string ...$names): int` | `int` | Number of names actually removed. The list is reindexed afterwards. | +| `getPermissions(): list` | `list` | Snapshot of the current set (already lower-cased and trimmed). | +| `getPermission(): list` | `list` | **Deprecated** v1 alias. Use `getPermissions()`. | + +### Normalization rules + +Every name — whether supplied at construction time, to `is()`, or to +`push()` / `remove()` — passes through the same internal pipeline: + +1. `strtolower()` +2. `trim()` + +So `' Admin '`, `'admin'`, and `'ADMIN'` all refer to the same +permission. + +## Magic accessors + +| Expression | Equivalent to | +| --- | --- | +| `$perm->is_admin()` | `$perm->is('admin')` | +| `isset($perm->admin)` | `$perm->is('admin')` | +| `isset($perm->is_admin)` | `$perm->is('admin')` (the `is_` prefix is stripped) | +| `unset($perm->is_admin)` | `$perm->remove('admin')` | + +These are convenient inside templates (Twig, Blade, plain PHP). In code +that runs through an IDE or PHPStan, prefer the explicit methods so +auto-completion and static analysis keep working. + +A call that does not start with `is_` raises `BadMethodCallException`: + +```php +$perm->doSomething(); // BadMethodCallException +``` + +## Serializing the permission set + +`__sleep()` keeps only the permission list, so it is safe to drop a +`Permission` straight into `$_SESSION`: + +```php +$_SESSION['perm'] = serialize(new Permission(['Editor', 'viewer'])); + +// later, in another request +$perm = unserialize($_SESSION['perm']); +$perm->is('editor'); // true +``` + +## Common mistakes + +- **Forgetting normalization happens in the constructor.** In v1 the + constructor stored values verbatim and only `push()` / `is()` + lower-cased the *needle*, which meant a permission supplied at + construction time with mixed case could never match. v2 fixes this: + the constructor uses the same normalization as `push()`. If you are + porting a v1 codebase, you do not need to lower-case input yourself + any more. +- **Treating `is()` like a "has all" check.** `is('editor', 'admin')` + is **any-match**, not all-match. To require every name, call `is()` + per name and combine with `&&`. +- **Reading from the magic accessors in static analysis.** PHPStan and + Psalm cannot see the `is_*` methods. Use `$perm->is('admin')` + instead in code that needs to type-check. diff --git a/docs/recipes/basic-auth-example.md b/docs/recipes/basic-auth-example.md new file mode 100644 index 0000000..0e81c32 --- /dev/null +++ b/docs/recipes/basic-auth-example.md @@ -0,0 +1,100 @@ +# HTTP Basic-auth + database lookup + +This recipe ports the `BasicAuthAdapter` example from the v1 README into +something you would actually deploy: prepared statements, hashed +passwords, and a clean separation between authentication +(who are you?) and authorization (what may you do?). + +The adapter class itself is documented in +[Custom adapters](../adapters/custom.md#a-safer-pdo-backed-example) — +this page shows the full request lifecycle around it. + +## Goal + +A protected admin endpoint that: + +1. Requires HTTP Basic credentials. +2. Looks the user up in a `users` table. +3. Verifies the password against `password_hash()` output. +4. Exposes the matched row as an auth segment. +5. Builds a `Permission` set from the user's role. +6. Gates the endpoint on `is('admin')`. + +## Schema + +```sql +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin', 'editor', 'viewer') NOT NULL DEFAULT 'viewer' +); +``` + +Generate password hashes when you create the user: + +```php +$pdo->prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') + ->execute(['alice', password_hash('s3cret', PASSWORD_DEFAULT), 'admin']); +``` + +## Working example + +```php + 'mysql:host=localhost;dbname=app;charset=utf8mb4', + 'username' => $_ENV['DB_USER'], + 'password' => $_ENV['DB_PASS'], +]); + +// At this point either the request authenticated successfully or the +// adapter has already sent a 401 and exit()ed. + +$perm = new Permission([$auth->get('role')]); + +if (!$perm->is('admin')) { + http_response_code(403); + exit('Admins only.'); +} + +printf("Welcome, %s.\n", $auth->get('username')); +``` + +## How the pieces fit + +- **`BasicAuthAdapter`** owns the authentication step. Its constructor + reads `$_SERVER['PHP_AUTH_USER']` / `$_SERVER['PHP_AUTH_PW']`, looks + the user up, and either populates the in-memory row or sends a `401` + and exits. +- **`Segment::custom()`** wires the adapter into the standard + `AdapterInterface` so that the rest of your code does not have to + know it talks to a database. You can swap the adapter for a Redis + one later without touching the call sites. +- **`Permission`** translates the database role into a permission set. + Case-insensitivity means the database can store `'Admin'` and the + call site still asks for `'admin'`. + +## Common mistakes + +- **Calling `md5()` on the password.** Use `password_verify()` against + the stored `password_hash()` output. The v1 README example used + `md5(...)`, which is unsuitable for password storage. +- **Concatenating the username into SQL.** Bind it. The + [adapter source](../adapters/custom.md#a-safer-pdo-backed-example) + shows how — never write `... WHERE username = '" . $username . "'"`. +- **Re-authenticating on every request without caching.** A real + deployment would put the matched user id in a session or signed + cookie after the first Basic auth, so subsequent requests skip the + database round-trip. Use `Segment::cookie()` or `Segment::session()` + for that hop, with `BasicAuthAdapter` only as the initial credential + check. diff --git a/docs/recipes/multi-segment.md b/docs/recipes/multi-segment.md new file mode 100644 index 0000000..5c87a8b --- /dev/null +++ b/docs/recipes/multi-segment.md @@ -0,0 +1,91 @@ +# Multiple segments per request + +A `Segment` is just a named slice of state. Nothing stops you from +having several side by side — one for auth, one for the shopping cart, +one for the CSRF token — each backed by whatever store fits it best. + +## Goal + +A typical e-commerce request needs three independent pieces of state: + +- **Auth** — durable, lives across browser sessions. Cookie. +- **Cart** — page-scoped, lives only as long as the browser tab. Session. +- **CSRF** — per-request token, rotated on login. Session. + +Each segment has its own name; they cannot collide because the +underlying adapters key into different slots. + +## Working example + +```php + $_ENV['AUTH_COOKIE_SECRET'], + 'path' => '/', + 'domain' => 'shop.example.com', +]); + +$cart = Segment::session('cart'); +$csrf = Segment::session('csrf'); + +// Hydrate the user from the auth cookie. +$userId = $auth->get('user_id'); +if ($userId !== null) { + $perm = new Permission($auth->get('permissions', [])); +} else { + $perm = new Permission(); +} + +// Mutate the cart. +$cart->set('items', [ + ['sku' => 'ABC-123', 'qty' => 2], + ['sku' => 'XYZ-007', 'qty' => 1], +]); + +// Rotate the CSRF token at the start of every authenticated request. +$csrf->set('token', bin2hex(random_bytes(16))); +``` + +After this script runs, the request response carries: + +- a `Set-Cookie: auth=…` header signed with your secret; +- the cart and CSRF token in `$_SESSION['cart']` and `$_SESSION['csrf']`. + +## Logout + +Tear down only what belongs to that flow. The auth cookie goes, the +cart can survive, the CSRF token rotates: + +```php +$auth->destroy(); +$csrf->set('token', bin2hex(random_bytes(16))); +// $cart is untouched +``` + +`SessionAdapter::destroy()` only `unset()`s its own slot — it never +calls `session_destroy()`, so a logout that uses `$auth->destroy()` on +a session-backed auth segment will not wipe `cart` or `csrf`. That is +the point of segmenting state. + +## Common mistakes + +- **Reusing one segment name across storage backends.** The name lives + inside the store, not across them. Calling `Segment::session('auth')` + and `Segment::cookie('auth')` in the same request gives you two + unrelated stores that happen to share a label. +- **Calling `session_destroy()` in a logout.** That nukes every + segment plus any unrelated session state. Use the specific + `$segment->destroy()` calls for the segments you actually want to + clear. +- **Storing the entire `Permission` object in the auth cookie.** + Cookies are signed but not encrypted; serialised PHP objects are + also a much larger payload than a flat array. Store the list: + `$auth->set('permissions', $perm->getPermissions())`. diff --git a/docs/upgrading-from-v1.md b/docs/upgrading-from-v1.md new file mode 100644 index 0000000..af4b36d --- /dev/null +++ b/docs/upgrading-from-v1.md @@ -0,0 +1,230 @@ +# Upgrading from v1 + +v2 ships intentional behaviour changes. Most are bug fixes that have a +visible BC impact; a handful are deliberate hardening of defaults. +Below is the full list, what changes from your side, and how to migrate. + +## Requirements + +| | v1 | v2 | +| --- | --- | --- | +| PHP | `>=7.4` | `^8.0` (tested on 8.0–8.4) | +| `initphp/parameterbag` | `^1.0` | `^2.0` | + +Bump your `composer.json` constraint to `^2.0` and run +`composer update initphp/auth`. + +## `Permission` — case-folding moved into the constructor + +**v1 bug:** the constructor stored permissions verbatim while +`is()`/`push()`/`remove()` lower-cased the *needle*, so a mixed-case +permission supplied at construction time could never match. + +```php +// v1 +$perm = new Permission(['Editor']); +$perm->is('editor'); // false +``` + +**v2 fix:** the constructor normalizes (lower-case + trim) and +deduplicates, the same way `push()` does. + +```php +// v2 +$perm = new Permission(['Editor']); +$perm->is('editor'); // true +``` + +**Action:** if you worked around the bug by lower-casing input before +passing it in, that workaround now becomes a no-op (still correct). +Nothing to change. + +## `Permission::getPermission()` deprecated + +Renamed for plural consistency. The old method survives as a deprecated +alias and will be removed in v3. + +```php +$perm->getPermission(); // still works in v2, raises @deprecated notice in IDEs +$perm->getPermissions(); // preferred +``` + +**Action:** find/replace at your leisure. + +## `Permission::$_perms` renamed to `$permissions` + +Underscore-prefixed properties were a v1 PSR-12 violation. Anything +that touched `$_perms` directly (subclasses, reflection, serialized +payloads from v1) will need to be updated. + +`__sleep()` now lists `permissions`, so v1 serialized blobs cannot be +reinflated under v2 — re-serialize them when you next hydrate. + +## `NullAdapter::has()` returns `false` + +**v1:** always returned `true`, which combined with `get()` always +returning the default produced the inconsistent pair +`has(x) === true && get(x) === null`. + +**v2:** `has()` returns `false` — nothing is ever present in a Null +Object store. + +**Action:** verify that no production code relies on the buggy +`has() === true` to satisfy a guard. The kind of guard you wrote was +almost certainly meant to fall through, which is exactly what v2 makes +it do. + +## `CookieAdapter` — new wire format + +**v1:** `base64(serialize([data, hash]))` with `md5(sha1(...))`. + +**v2:** `base64url(json_encode($data)) . "." . hash_hmac('sha256', $json, $salt)`. + +Why: + +- HMAC + `hash_equals()` instead of a hand-rolled hash with `!=` + (constant-time comparison, no timing side-channel). +- JSON instead of `serialize()` (no object instantiation path during + decode, no POP-gadget risk). +- Hash verified **before** the payload is parsed. + +**Action:** v2 cannot read v1 cookies — they are silently dropped and +the user is issued a fresh v2 cookie on their next write. Plan for a +quiet logout of everyone holding a v1 cookie. There is no migration +path that would risk running `unserialize()` against attacker bytes. + +## `CookieAdapter` — stricter salt + +**v1:** minimum 8 characters. + +**v2:** minimum **32 bytes** (matches the SHA-256 output length). + +```php +// Generate one +echo bin2hex(random_bytes(32)); +``` + +**Action:** if your existing salt is shorter, generate a longer one +and update your environment. (You will need to do this anyway because +the wire format changed.) + +## `CookieAdapter` — stricter defaults + +| Option | v1 default | v2 default | +| --- | --- | --- | +| `secure` | `false` | `true` | +| `samesite` | `'None'` | `'Lax'` | + +`SameSite=None` requires `Secure=true` per the modern cookie spec. +v2 rejects the unsafe combination with `InvalidArgumentException` +instead of silently emitting a cookie the browser will drop. + +**Action:** if you were running on plain HTTP in development, opt +back into `secure=false` explicitly **and** drop `SameSite` back to +`Lax`/`Strict`. Production should run on HTTPS. + +## `CookieAdapter` — `destroy()` now reuses path/domain + +**v1 bug:** the deletion `setcookie()` call only set `expires`, so the +browser refused to delete a cookie originally written with a +non-default path. + +**v2 fix:** the deletion reuses `$this->options` and only overrides +`expires`. No action required; cookies that previously refused to +delete will now delete. + +## `CookieAdapter` — injectable writer + +The constructor gained an optional third argument: + +```php +new CookieAdapter( + string $name, + array $options = [], + ?CookieWriterInterface $writer = null, +); +``` + +Default behaviour is unchanged (`NativeCookieWriter` wraps `setcookie()`). +Tests can inject `InMemoryCookieWriter` to capture calls without +touching response headers. + +## `SessionAdapter` — `__call` magic delegation removed + +**v1:** `SessionAdapter::__call($name, $args)` forwarded to the +internal `ParameterBag`. Calls like `$adapter->merge([...])` mutated +the bag but never synced back to `$_SESSION` — silent data loss. + +**v2:** the magic is gone. Use the documented `get/set/has/remove/collective/destroy` +methods, all of which sync `$_SESSION` after every write. + +**Action:** if you reached into bag methods through `__call`, switch +to `$adapter->collective([...])` or to one of the explicit methods. + +## `SessionAdapter` — options forwarded to ParameterBag + +The constructor's second argument is now forwarded straight to the +underlying `ParameterBag`. The biggest practical effect is that you can +opt into dotted-path access: + +```php +$auth = new SessionAdapter('auth', ['isMulti' => true]); +$auth->get('profile.name'); +``` + +In v1 the options array was accepted and ignored. + +## `Segment` — new typed factory methods + +`Segment::create()` and the constructor still take an `int|string` +adapter (kept for v1 BC), but new code should use the typed factories: + +```php +Segment::session($name, $options); +Segment::cookie($name, $options); +Segment::custom($name, $adapterClass, $options); +``` + +The error messages on misuse are also more helpful — passing an +unknown integer constant or a class that does not extend +`AbstractAdapter` now tells you exactly which case it hit. + +## `AdapterInterface` — no longer requires a constructor + +**v1:** the interface declared `__construct(string $name, array $options = [])`, +which is a PSR anti-pattern and forced every implementation to take +options through an array even when it wanted to inject a PDO handle +or a Redis client directly. + +**v2:** constructors are out of the contract. `Segment::custom()` still +invokes `new YourClass($name, $options)`, so the convention if you want +`Segment` compatibility is unchanged — but you can hand-build adapters +with any constructor signature now. + +**Action:** existing adapters that extend `AbstractAdapter` keep +working. Adapters that implemented the interface directly without +extending the abstract can drop the `__construct` declaration if they +want. + +## `AbstractAdapter` — fewer abstract methods + +v1 redeclared every interface method as `abstract` in the base class +without adding any shared behaviour. v2 keeps only a default +`collective()` that iterates `set()` for adapters that cannot commit +atomically; everything else is satisfied by implementing the interface. + +## Tooling + +v2 ships with the same dev workflow as the rest of the InitPHP +ecosystem: + +```bash +composer install +composer test # PHPUnit +composer analyse # PHPStan level 8 +composer cs:check # PHP-CS-Fixer dry-run +composer cs:fix # PHP-CS-Fixer apply +``` + +The test suite covers `Permission`, `SessionAdapter`, `CookieAdapter`, +`Segment`, and `NullAdapter`. CI runs on PHP 8.0 through 8.4. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..8130d1b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: 8 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9c62803 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + + + + + + + tests + + + + + src + + + diff --git a/src/AbstractAdapter.php b/src/AbstractAdapter.php index 14764e4..5e9a758 100644 --- a/src/AbstractAdapter.php +++ b/src/AbstractAdapter.php @@ -1,53 +1,30 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Auth; +/** + * Common base for {@see AdapterInterface} implementations. + * + * Provides a default {@see self::collective()} that routes through + * {@see self::set()}. Adapters that can commit atomically (CookieAdapter + * would otherwise emit one Set-Cookie header per key) should override + * it for efficiency. + */ abstract class AbstractAdapter implements AdapterInterface { - - abstract public function __construct(string $name, array $options = []); - - /** - * @inheritDoc - */ - abstract public function get(string $key, $default = null); - /** - * @inheritDoc + * @param array $data + * + * @return static */ - abstract public function set(string $key, $value): AdapterInterface; - - /** - * @inheritDoc - */ - abstract public function collective(array $data): AdapterInterface; - - /** - * @inheritDoc - */ - abstract public function has(string $key): bool; - - /** - * @inheritDoc - */ - abstract public function remove(string ...$key): AdapterInterface; - - /** - * @inheritDoc - */ - abstract public function destroy(): bool; - + public function collective(array $data): AdapterInterface + { + foreach ($data as $key => $value) { + $this->set((string) $key, $value); + } + + return $this; + } } diff --git a/src/AdapterInterface.php b/src/AdapterInterface.php index bb07882..f4478b4 100644 --- a/src/AdapterInterface.php +++ b/src/AdapterInterface.php @@ -1,60 +1,69 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Auth; +/** + * Storage contract for auth state. + * + * Each implementation owns one backing store (session, signed cookie, + * database, in-memory, …). Constructors are intentionally NOT part of + * the contract — different stores need different dependencies (a salt, + * a PDO handle, a writer object), and forcing a single signature would + * defeat the purpose of the abstraction. + * + * `set()`, `collective()` and `remove()` return `static` so calls can be + * chained without losing the concrete type to the implementing class. + */ interface AdapterInterface { - - public function __construct(string $name, array $options = []); - /** - * @param string $key + * Return the value stored under $key, or $default when absent. + * * @param mixed $default + * * @return mixed */ public function get(string $key, $default = null); /** - * @param string $key + * Assign $value to $key in the backing store. + * * @param mixed $value - * @return AdapterInterface + * + * @return static */ - public function set(string $key, $value): AdapterInterface; + public function set(string $key, $value): self; /** - * @param array $data

Associative array

- * @return AdapterInterface + * Apply every (key, value) pair from $data in one logical operation. + * Implementations are free to commit atomically (one Set-Cookie / + * one $_SESSION write) instead of iterating set() N times. + * + * @param array $data + * + * @return static */ - public function collective(array $data): AdapterInterface; + public function collective(array $data): self; /** - * @param string $key - * @return bool + * Whether $key is present in the backing store. A stored null is + * considered present. */ public function has(string $key): bool; /** - * @param string ...$key - * @return AdapterInterface + * Drop one or more keys. Missing keys are a no-op. + * + * @return static */ - public function remove(string ...$key): AdapterInterface; + public function remove(string ...$key): self; /** - * @return bool + * Tear down the backing store. Behaviour after destroy() is + * implementation-defined — most adapters will throw on subsequent + * get/set calls. */ public function destroy(): bool; - } diff --git a/src/Cookie/CookieWriterInterface.php b/src/Cookie/CookieWriterInterface.php new file mode 100644 index 0000000..b83b785 --- /dev/null +++ b/src/Cookie/CookieWriterInterface.php @@ -0,0 +1,28 @@ + $options Same shape accepted by PHP's + * setcookie() options array + * (expires, path, domain, secure, + * httponly, samesite). + * + * @return bool True when the header was queued for delivery, false + * when output had already been started or the cookie was + * rejected. + */ + public function send(string $name, string $value, array $options): bool; +} diff --git a/src/Cookie/InMemoryCookieWriter.php b/src/Cookie/InMemoryCookieWriter.php new file mode 100644 index 0000000..ca6cef1 --- /dev/null +++ b/src/Cookie/InMemoryCookieWriter.php @@ -0,0 +1,65 @@ +}> */ + private array $calls = []; + + /** + * Controls the boolean returned by {@see self::send()}. Useful for + * simulating "headers already sent" failures. + */ + private bool $returnValue = true; + + /** + * @param array $options + */ + public function send(string $name, string $value, array $options): bool + { + $this->calls[] = [ + 'name' => $name, + 'value' => $value, + 'options' => $options, + ]; + + return $this->returnValue; + } + + /** + * @return list}> + */ + public function calls(): array + { + return $this->calls; + } + + /** + * @return array{name: string, value: string, options: array}|null + */ + public function lastCall(): ?array + { + return $this->calls === [] ? null : $this->calls[\count($this->calls) - 1]; + } + + public function reset(): void + { + $this->calls = []; + } + + public function returnValue(bool $value): void + { + $this->returnValue = $value; + } +} diff --git a/src/Cookie/NativeCookieWriter.php b/src/Cookie/NativeCookieWriter.php new file mode 100644 index 0000000..8aeb756 --- /dev/null +++ b/src/Cookie/NativeCookieWriter.php @@ -0,0 +1,23 @@ + $options + */ + public function send(string $name, string $value, array $options): bool + { + return \setcookie($name, $value, $options); + } +} diff --git a/src/CookieAdapter.php b/src/CookieAdapter.php index a3042af..c12b9fd 100644 --- a/src/CookieAdapter.php +++ b/src/CookieAdapter.php @@ -1,60 +1,106 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Auth; +use InitPHP\Auth\Cookie\CookieWriterInterface; +use InitPHP\Auth\Cookie\NativeCookieWriter; use InitPHP\ParameterBag\ParameterBag; +use JsonException; +/** + * Stores auth state in a signed cookie. + * + * The cookie value is `base64url(json) . hmac_sha256(json, salt)`. Signing + * happens with a constant-time HMAC and is verified BEFORE the JSON + * payload is parsed, so a tampered cookie is dropped on the floor without + * any deserialisation taking place. + * + * The encoder no longer uses PHP serialize(); upgrading from v1 cookies + * is therefore a hard cut — old cookies become unreadable and clients + * will be issued fresh ones on next write. + */ class CookieAdapter extends AbstractAdapter { + /** + * Minimum salt length, in bytes. SHA-256 produces 32-byte digests, so + * a key that is at least as long denies an attacker any structural + * shortcut. Use `bin2hex(random_bytes(32))` to generate one. + */ + public const MIN_SALT_LENGTH = 32; + protected string $name; protected ParameterBag $cookie; protected string $salt; + /** + * Default cookie attributes. Defaults are deliberately strict: + * - `secure=true` so cookies are never sent over plain HTTP. + * - `samesite=Lax` because modern browsers reject `None` unless + * `Secure` is also set, and `Strict` breaks ordinary navigations. + * + * @var array + */ protected array $options = [ 'expires' => null, 'path' => '/', - 'secure' => false, // [true|false] - 'httponly' => true, // [true|false] - 'samesite' => 'None', // [None|Lax|Strict] + 'domain' => '', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', ]; - public function __construct(string $name, array $options = []) + private CookieWriterInterface $writer; + + /** + * @param array $options Cookie attributes plus a + * required `salt` (see + * {@see self::MIN_SALT_LENGTH}). + * @param CookieWriterInterface|null $writer Defaults to + * {@see NativeCookieWriter}. + * Inject {@see InMemoryCookieWriter} + * from tests. + */ + public function __construct(string $name, array $options = [], ?CookieWriterInterface $writer = null) { $this->name = $name; + $this->writer = $writer ?? new NativeCookieWriter(); - if(!isset($options['salt']) || !\is_string($options['salt']) || \strlen(\trim($options['salt'])) < 8){ - throw new \InvalidArgumentException('A "salt" with a minimum of 8 characters must be defined.'); + if (!isset($options['salt']) || !\is_string($options['salt']) || \strlen($options['salt']) < self::MIN_SALT_LENGTH) { + throw new \InvalidArgumentException(\sprintf( + 'A "salt" of at least %d bytes must be supplied. Generate one with bin2hex(random_bytes(32)).', + self::MIN_SALT_LENGTH + )); } $this->salt = $options['salt']; unset($options['salt']); - if(!isset($options['expires'])){ + if (!\array_key_exists('expires', $options) || $options['expires'] === null) { $options['expires'] = \time() + 86400; } $this->options = \array_merge($this->options, $options); - $this->cookie = new ParameterBag(($this->decoder() ?? []), [ - 'isMulti' => false + // SameSite=None requires Secure (Chrome 80+, Firefox 69+, Safari 13.1+). + // Reject the combination eagerly rather than silently issuing a + // cookie the browser will drop. + if (\strcasecmp((string) $this->options['samesite'], 'None') === 0 && $this->options['secure'] !== true) { + throw new \InvalidArgumentException('SameSite=None requires the cookie to be marked Secure.'); + } + + $this->cookie = new ParameterBag($this->decoder(), [ + 'isMulti' => false, ]); } /** - * @inheritDoc + * @param mixed $default + * + * @return mixed + * + * @throws \RuntimeException When the cookie has been destroyed. */ public function get(string $key, $default = null) { @@ -62,29 +108,37 @@ public function get(string $key, $default = null) } /** - * @inheritDoc + * @param mixed $value + * + * @throws \RuntimeException When the cookie has been destroyed, or + * when $value cannot be JSON-encoded. */ - public function set(string $key, $value): self + public function set(string $key, $value): AdapterInterface { $this->getBag()->set($key, $value); $this->save(); + return $this; } /** - * @inheritDoc + * @param array $data + * + * @throws \RuntimeException When the cookie has been destroyed, or + * when $data cannot be JSON-encoded. */ - public function collective(array $data): self + public function collective(array $data): AdapterInterface { foreach ($data as $key => $value) { - $this->getBag()->set($key, $value); + $this->getBag()->set((string) $key, $value); } $this->save(); + return $this; } /** - * @inheritDoc + * @throws \RuntimeException When the cookie has been destroyed. */ public function has(string $key): bool { @@ -92,32 +146,45 @@ public function has(string $key): bool } /** - * @inheritDoc + * @throws \RuntimeException When the cookie has been destroyed. */ - public function remove(string ...$key): self + public function remove(string ...$key): AdapterInterface { foreach ($key as $name) { $this->getBag()->remove($name); } $this->save(); + return $this; } /** - * @inheritDoc + * Emits a deletion cookie that matches the original attributes and + * returns whether the Set-Cookie header was queued successfully. + * After destroy() any further get/set/has/remove/collective call + * raises {@see \RuntimeException}. */ public function destroy(): bool { $this->getBag()->close(); unset($this->cookie); - \setcookie($this->name, '', [ - 'expires' => (\time() - 86400) - ]); + + // RFC 6265: the browser only deletes a cookie when the deletion + // header carries the same path/domain (and SameSite/Secure when + // applicable) as the cookie being removed. Reuse the original + // options and only override the expiry. + $deleteOptions = $this->options; + $deleteOptions['expires'] = \time() - 86400; + $ok = $this->writer->send($this->name, '', $deleteOptions); + + // setcookie() does not update $_COOKIE for the current request; + // sync the superglobal so later code in the same request does + // not see stale data. if (isset($_COOKIE[$this->name])) { unset($_COOKIE[$this->name]); - return true; } - return false; + + return $ok; } private function getBag(): ParameterBag @@ -132,43 +199,78 @@ private function save(): bool { $data = $this->getBag()->all(); $value = $this->encoder($data); - return \setcookie($this->name, $value, $this->options); + + return $this->writer->send($this->name, $value, $this->options); } + /** + * Read and verify the cookie payload. + * + * Order of operations matters: the HMAC is checked BEFORE the JSON is + * parsed so that a forged or tampered cookie never reaches the + * decoder. Any failure (missing cookie, malformed format, bad + * signature, invalid JSON, non-array root) yields an empty array. + * + * @return array + */ private function decoder(): array { - if(!isset($_COOKIE[$this->name])){ + if (!isset($_COOKIE[$this->name]) || !\is_string($_COOKIE[$this->name])) { return []; } - if(($cookie = \base64_decode($_COOKIE[$this->name])) === FALSE){ + $raw = $_COOKIE[$this->name]; + if (\strpos($raw, '.') === false) { return []; } - if(($cookie = \unserialize($cookie)) === FALSE){ + [$encodedPayload, $signature] = \explode('.', $raw, 2); + + $payload = $this->base64UrlDecode($encodedPayload); + if ($payload === null) { return []; } - if (!isset($cookie['data']) || !\is_array($cookie['data']) || empty($cookie['hash'])) { + if (!\hash_equals($this->generateSignature($payload), $signature)) { return []; } - if ($cookie['hash'] != $this->hash_generator($cookie['data'])) { + + try { + $data = \json_decode($payload, true, 512, \JSON_THROW_ON_ERROR); + } catch (JsonException $e) { return []; } - return $cookie['data']; + + return \is_array($data) ? $data : []; } + /** + * Build the wire format: `base64url(json).hex(hmac)`. + * + * @param array $data + */ private function encoder(array $data): string { - $cookie = [ - 'data' => $data, - 'hash' => $this->hash_generator($data), - ]; - return \base64_encode(\serialize($cookie)); + try { + $payload = \json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES); + } catch (JsonException $e) { + throw new \RuntimeException('Failed to encode auth payload as JSON: ' . $e->getMessage(), 0, $e); + } + + return $this->base64UrlEncode($payload) . '.' . $this->generateSignature($payload); + } + + private function generateSignature(string $payload): string + { + return \hash_hmac('sha256', $payload, $this->salt); } - private function hash_generator($data): string + private function base64UrlEncode(string $data): string { - $data = \sha1((\serialize($data) . \strrev($this->salt))); - $data = $this->salt . $data; - return \md5($data); + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); } + private function base64UrlDecode(string $data): ?string + { + $decoded = \base64_decode(\strtr($data, '-_', '+/'), true); + + return $decoded === false ? null : $decoded; + } } diff --git a/src/NullAdapter.php b/src/NullAdapter.php index 38483e3..fca5e70 100644 --- a/src/NullAdapter.php +++ b/src/NullAdapter.php @@ -1,73 +1,61 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Auth; -class NullAdapter extends AbstractAdapter +/** + * Null Object adapter — accepts every operation and stores nothing. + * + * Useful as a default when a higher layer expects an + * {@see AdapterInterface} but auth state should be ignored (testing, + * feature flags, CLI scripts). + * + * Note (v2 behaviour change): {@see self::has()} now returns `false`. + * In v1 it returned `true`, which combined with {@see self::get()} + * always returning the default produced the inconsistent pair + * `has(x) === true && get(x) === null`. + */ +final class NullAdapter extends AbstractAdapter { - - public function __construct(string $name, array $options = []) + /** + * @param array $options Accepted for signature parity + * with other adapters and + * silently ignored. + * + * @phpstan-ignore-next-line constructor.unusedParameter + */ + public function __construct(string $name = '', array $options = []) { } - /** - * @inheritDoc - */ public function get(string $key, $default = null) { return $default; } - /** - * @inheritDoc - */ - public function set(string $key, $value): self + public function set(string $key, $value): AdapterInterface { return $this; } - /** - * @inheritDoc - */ - public function collective(array $data): self + public function collective(array $data): AdapterInterface { return $this; } - /** - * @inheritDoc - */ public function has(string $key): bool { - return true; + return false; } - /** - * @inheritDoc - */ - public function remove(string ...$key): self + public function remove(string ...$key): AdapterInterface { return $this; } - /** - * @inheritDoc - */ public function destroy(): bool { return true; } - } diff --git a/src/Permission.php b/src/Permission.php index a8fbbe0..0c26fdd 100644 --- a/src/Permission.php +++ b/src/Permission.php @@ -1,70 +1,142 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Auth; +use BadMethodCallException; + +/** + * Small case-insensitive permission set. + * + * Permissions are normalized to lower-case on the way in and compared + * case-insensitively, so `new Permission(['Editor'])->is('editor')` + * matches. The internal list is always a 0-indexed + * {@link https://phpstan.org/writing-php-code/phpdoc-types#list `list`} + * — `remove()` reindexes after `unset()` to keep that invariant. + * + * Magic `is_*` accessors (`$perm->is_admin`, `isset($perm->is_admin)`, + * `unset($perm->is_admin)`) are wired through {@see self::__call()}, + * {@see self::__isset()} and {@see self::__unset()} for convenience in + * templates; the explicit {@see self::is()}, {@see self::push()} and + * {@see self::remove()} methods are preferred in code that has access + * to an IDE or a static analyser. + */ class Permission { - - protected array $_perms = []; - - public function __construct(array $perms = []) + /** @var list */ + protected array $permissions = []; + + /** + * @param array $permissions Values that are not strings + * are silently skipped. + * Duplicates after normalization + * are dropped. + */ + public function __construct(array $permissions = []) { - $this->_perms = $perms; + foreach ($permissions as $perm) { + if (!\is_string($perm)) { + continue; + } + $normalized = $this->normalize($perm); + if (\in_array($normalized, $this->permissions, true)) { + continue; + } + $this->permissions[] = $normalized; + } } - public function __call($name, $arguments) + /** + * Magic dispatch for `is_*` accessors. `$perm->is_admin()` becomes + * `$perm->is('admin')`. Any other method name raises + * {@see BadMethodCallException}. + * + * @param array $arguments Ignored — the accessor takes no + * parameters. + * + * @throws BadMethodCallException When $name does not start with `is_`. + */ + public function __call(string $name, array $arguments): bool { - if (\substr($name, 0, 3) == 'is_') { - $perm = \substr($name, 3); - return !empty($perm) && $this->is($perm); + if (\strncmp($name, 'is_', 3) === 0) { + $permission = \substr($name, 3); + + return $permission !== '' && $this->is($permission); } - throw new \RuntimeException('The ' . $name . ' method is not available in the ' . __CLASS__ . ' class.'); + + throw new BadMethodCallException(\sprintf( + 'Method %s::%s() does not exist.', + static::class, + $name + )); } - public function __isset($name) + /** + * Magic dispatch for `isset($perm->some_role)` and + * `isset($perm->is_some_role)`. + */ + public function __isset(string $name): bool { - if(\substr($name, 0, 3) == 'is_'){ + if (\strncmp($name, 'is_', 3) === 0) { $name = \substr($name, 3); } - return $this->is($name); + + return $name !== '' && $this->is($name); } - public function __unset($name) + /** + * Magic dispatch for `unset($perm->some_role)` / + * `unset($perm->is_some_role)`. + */ + public function __unset(string $name): void { - if(\substr($name, 0, 3) == 'is_'){ + if (\strncmp($name, 'is_', 3) === 0) { $name = \substr($name, 3); } - $this->remove($name); - return null; + if ($name !== '') { + $this->remove($name); + } } - public function __sleep() + /** + * Only the permission list is serialized; anything else is implementation + * detail that should not be persisted across requests. + * + * @return array + */ + public function __sleep(): array { - return ['_perms']; + return ['permissions']; } + /** + * @return list + */ + public function getPermissions(): array + { + return $this->permissions; + } + + /** + * @deprecated since 2.0 — use {@see self::getPermissions()}. Kept + * as a v1 BC shim and removed in v3. + * + * @return list + */ public function getPermission(): array { - return $this->_perms; + return $this->permissions; } + /** + * True when any of the supplied names is present in the set. + * Comparison is case-insensitive. + */ public function is(string ...$permission_name): bool { foreach ($permission_name as $name) { - if(\in_array(\strtolower($name), $this->_perms, true)){ + if (\in_array($this->normalize($name), $this->permissions, true)) { return true; } } @@ -72,33 +144,50 @@ public function is(string ...$permission_name): bool return false; } + /** + * Add one or more permissions. Returns the count of names that were + * actually inserted (names already present are skipped). + */ public function push(string ...$permissions): int { - $res = 0; + $added = 0; foreach ($permissions as $perm) { - $lowercase = \strtolower($perm); - if(\in_array($lowercase, $this->_perms, true)){ + $normalized = $this->normalize($perm); + if (\in_array($normalized, $this->permissions, true)) { continue; } - ++$res; - $this->_perms[] = $lowercase; + $this->permissions[] = $normalized; + ++$added; } - return $res; + return $added; } + /** + * Remove one or more permissions. Returns the count of names that + * were actually removed. The internal list is reindexed so the + * `list` invariant holds. + */ public function remove(string ...$permissions): int { - $res = 0; + $removed = 0; foreach ($permissions as $perm) { - if (($search = \array_search(\strtolower($perm), $this->_perms, true)) === FALSE) { + $search = \array_search($this->normalize($perm), $this->permissions, true); + if ($search === false) { continue; } - ++$res; - unset($this->_perms[$search]); + unset($this->permissions[$search]); + ++$removed; + } + if ($removed > 0) { + $this->permissions = \array_values($this->permissions); } - return $res; + return $removed; } + private function normalize(string $name): string + { + return \strtolower(\trim($name)); + } } diff --git a/src/Segment.php b/src/Segment.php index caa2c31..d8f8315 100644 --- a/src/Segment.php +++ b/src/Segment.php @@ -1,76 +1,195 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Auth; +use InvalidArgumentException; +use ReflectionClass; + /** - * @mixin AbstractAdapter + * Facade in front of a single {@see AdapterInterface}. + * + * Exists for two reasons: + * - centralises adapter resolution (constant -> SessionAdapter / CookieAdapter, + * class name -> reflection-instantiated) so callers do not have to + * repeat the wiring; + * - implements {@see AdapterInterface} itself so the facade is a drop- + * in replacement wherever the interface is expected. + * + * For one-off adapter methods that are not part of the interface (e.g. + * a custom adapter exposes a `refreshToken()` call), reach the + * underlying instance via {@see self::adapter()} or rely on the + * {@see self::__call()} delegation. */ -class Segment +class Segment implements AdapterInterface { public const ADAPTER_SESSION = 0; public const ADAPTER_COOKIE = 1; - /** @var AdapterInterface */ protected AdapterInterface $adapter; /** - * @param string $name - * @param int|string $adapter - * @param array $options + * @param int|string $adapter One of the ADAPTER_* constants + * or a FQCN that extends + * {@see AbstractAdapter}. + * @param array $options Forwarded to the adapter + * constructor. + * + * @throws InvalidArgumentException When $adapter cannot be resolved. */ public function __construct(string $name, $adapter = self::ADAPTER_SESSION, array $options = []) { - if (!\is_int($adapter) && !\is_string($adapter)) { - throw new \InvalidArgumentException('$adapter can be a string or an integer.'); - } - $this->_initialize($name, $adapter, $options); + $this->adapter = $this->resolveAdapter($name, $adapter, $options); } - public function __call($name, $arguments) + /** + * Generic factory mirroring the constructor — kept for v1 BC. Prefer + * the typed {@see self::session()} / {@see self::cookie()} / + * {@see self::custom()} factories in new code. + * + * @param int|string $adapter + * @param array $options + */ + public static function create(string $name, $adapter = self::ADAPTER_SESSION, array $options = []): self { - return $this->adapter->{$name}(...$arguments); + return new self($name, $adapter, $options); } - public static function create(string $name, $adapter = self::ADAPTER_SESSION, array $options = []): Segment + /** + * @param array $options + */ + public static function session(string $name, array $options = []): self { - return new self($name, $adapter, $options); + return new self($name, self::ADAPTER_SESSION, $options); + } + + /** + * @param array $options Must contain `salt`. See + * {@see CookieAdapter} for the + * full options matrix. + */ + public static function cookie(string $name, array $options = []): self + { + return new self($name, self::ADAPTER_COOKIE, $options); + } + + /** + * @param class-string $adapterClass + * @param array $options + */ + public static function custom(string $name, string $adapterClass, array $options = []): self + { + return new self($name, $adapterClass, $options); + } + + /** + * Escape hatch for code that needs the concrete adapter (e.g. to + * call an implementation-specific method that is not part of + * {@see AdapterInterface}). + */ + public function adapter(): AdapterInterface + { + return $this->adapter; + } + + public function get(string $key, $default = null) + { + return $this->adapter->get($key, $default); } - private function _initialize(string $name, $adapter, array $options) + public function set(string $key, $value): AdapterInterface { - switch ($adapter) { - case self::ADAPTER_SESSION: - $this->adapter = new SessionAdapter($name, $options); - return; - case self::ADAPTER_COOKIE: - $this->adapter = new CookieAdapter($name, $options); - return; - default: - break; + $this->adapter->set($key, $value); + + return $this; + } + + public function collective(array $data): AdapterInterface + { + $this->adapter->collective($data); + + return $this; + } + + public function has(string $key): bool + { + return $this->adapter->has($key); + } + + public function remove(string ...$key): AdapterInterface + { + $this->adapter->remove(...$key); + + return $this; + } + + public function destroy(): bool + { + return $this->adapter->destroy(); + } + + /** + * Forward calls that the explicit proxy methods do not cover (e.g. + * an extension method on a custom adapter). The call is delegated + * verbatim, so a method that does not exist on the adapter raises + * the standard PHP "Call to undefined method" error. + * + * @param array $arguments + * + * @return mixed + * + * @throws \Error When the adapter does not expose $name. + */ + public function __call(string $name, array $arguments) + { + return $this->adapter->{$name}(...$arguments); + } + + /** + * @param int|string $adapter + * @param array $options + */ + private function resolveAdapter(string $name, $adapter, array $options): AdapterInterface + { + if (\is_int($adapter)) { + switch ($adapter) { + case self::ADAPTER_SESSION: + return new SessionAdapter($name, $options); + case self::ADAPTER_COOKIE: + return new CookieAdapter($name, $options); + default: + throw new InvalidArgumentException(\sprintf( + 'Unknown adapter constant: %d. Expected ADAPTER_SESSION (%d) or ADAPTER_COOKIE (%d).', + $adapter, + self::ADAPTER_SESSION, + self::ADAPTER_COOKIE + )); + } + } + + if (!\is_string($adapter)) { + throw new InvalidArgumentException( + '$adapter must be one of the ADAPTER_* constants or a class name that extends ' . AbstractAdapter::class . '.' + ); } - if (!\is_string($adapter) || !\class_exists($adapter)) { - throw new \InvalidArgumentException('$adapter can simply be a class that extends the AbstractAdapter class.'); + + if (!\class_exists($adapter)) { + throw new InvalidArgumentException(\sprintf('Adapter class "%s" does not exist.', $adapter)); } - $reflection = new \ReflectionClass($adapter); + + $reflection = new ReflectionClass($adapter); if (!$reflection->isSubclassOf(AbstractAdapter::class)) { - throw new \InvalidArgumentException('$adapter can simply be a class that extends the AbstractAdapter class.'); + throw new InvalidArgumentException(\sprintf( + 'Adapter class "%s" must extend %s.', + $adapter, + AbstractAdapter::class + )); } - /** @var AdapterInterface $adapter */ - $adapter = $reflection->newInstanceArgs([$name, $options]); - $this->adapter = $adapter; - } + /** @var AdapterInterface $instance */ + $instance = $reflection->newInstanceArgs([$name, $options]); + + return $instance; + } } diff --git a/src/SessionAdapter.php b/src/SessionAdapter.php index 3f5cfbd..df53b3e 100644 --- a/src/SessionAdapter.php +++ b/src/SessionAdapter.php @@ -1,15 +1,4 @@ - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); @@ -17,32 +6,46 @@ use InitPHP\ParameterBag\ParameterBag; +/** + * Stores auth state under a single key inside $_SESSION. + * + * The caller is responsible for starting the session before constructing + * the adapter; the adapter refuses to operate against an inactive session + * because doing so would silently lose every subsequent write. + */ class SessionAdapter extends AbstractAdapter { - protected string $name; protected ParameterBag $session; + /** + * @param array $options Forwarded to the internal + * {@see ParameterBag}. Useful + * knobs: `isMulti` (dotted + * paths), `separator`, + * `caseInsensitive`. Defaults + * to flat mode (`isMulti => false`). + * + * @throws \RuntimeException When the PHP session is not active. + */ public function __construct(string $name, array $options = []) { $this->name = $name; if (\session_status() !== \PHP_SESSION_ACTIVE) { throw new \RuntimeException('Sessions must be started.'); } + /** @var array $sessions */ $sessions = $_SESSION[$this->name] ?? []; - $this->session = new ParameterBag($sessions, [ - 'isMulti' => false - ]); - } - - public function __call($name, $arguments) - { - return $this->getBag()->{$name}(...$arguments); + $this->session = new ParameterBag($sessions, \array_merge(['isMulti' => false], $options)); } /** - * @inheritDoc + * @param mixed $default + * + * @return mixed + * + * @throws \RuntimeException When the session has been destroyed. */ public function get(string $key, $default = null) { @@ -50,31 +53,35 @@ public function get(string $key, $default = null) } /** - * @inheritDoc + * @param mixed $value + * + * @throws \RuntimeException When the session has been destroyed. */ - public function set(string $key, $value): self + public function set(string $key, $value): AdapterInterface { $this->getBag()->set($key, $value); - $_SESSION[$this->name] = $this->getBag()->all(); + $this->syncSession(); return $this; } /** - * @inheritDoc + * @param array $data + * + * @throws \RuntimeException When the session has been destroyed. */ - public function collective(array $data): self + public function collective(array $data): AdapterInterface { foreach ($data as $key => $value) { - $this->getBag()->set($key, $value); + $this->getBag()->set((string) $key, $value); } - $_SESSION[$this->name] = $this->getBag()->all(); + $this->syncSession(); return $this; } /** - * @inheritDoc + * @throws \RuntimeException When the session has been destroyed. */ public function has(string $key): bool { @@ -82,37 +89,46 @@ public function has(string $key): bool } /** - * @inheritDoc + * @throws \RuntimeException When the session has been destroyed. */ - public function remove(string ...$key): self + public function remove(string ...$key): AdapterInterface { foreach ($key as $value) { $this->getBag()->remove($value); } - $_SESSION[$this->name] = $this->getBag()->all(); + $this->syncSession(); return $this; } /** - * @inheritDoc + * Drops the $_SESSION slot held by this segment. Returns true when + * the slot existed, false otherwise. After destroy() any further + * get/set/has/remove/collective call raises {@see \RuntimeException}. */ public function destroy(): bool { $this->getBag()->close(); - if(isset($_SESSION[$this->name])){ + unset($this->session); + if (isset($_SESSION[$this->name])) { unset($_SESSION[$this->name]); + return true; } + return false; } private function getBag(): ParameterBag { - if(isset($this->session)){ + if (isset($this->session)) { return $this->session; } throw new \RuntimeException('Sessions were destroyed or not created at all.'); } + private function syncSession(): void + { + $_SESSION[$this->name] = $this->getBag()->all(); + } } diff --git a/tests/CookieAdapterTest.php b/tests/CookieAdapterTest.php new file mode 100644 index 0000000..6cb2ae7 --- /dev/null +++ b/tests/CookieAdapterTest.php @@ -0,0 +1,287 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/salt/'); + new CookieAdapter('auth', [], new InMemoryCookieWriter()); + } + + public function testRejectsSaltShorterThanMinimum(): void + { + $this->expectException(InvalidArgumentException::class); + new CookieAdapter('auth', ['salt' => 'too-short'], new InMemoryCookieWriter()); + } + + public function testRejectsNonStringSalt(): void + { + $this->expectException(InvalidArgumentException::class); + new CookieAdapter('auth', ['salt' => 12345], new InMemoryCookieWriter()); + } + + public function testRejectsSameSiteNoneWithoutSecure(): void + { + $this->expectException(InvalidArgumentException::class); + new CookieAdapter('auth', [ + 'salt' => self::VALID_SALT, + 'samesite' => 'None', + 'secure' => false, + ], new InMemoryCookieWriter()); + } + + public function testAcceptsSameSiteNoneWhenSecureIsTrue(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', [ + 'salt' => self::VALID_SALT, + 'samesite' => 'None', + 'secure' => true, + ], $writer); + + $adapter->set('x', 1); + + $last = $writer->lastCall(); + self::assertNotNull($last); + self::assertSame('None', $last['options']['samesite']); + self::assertTrue($last['options']['secure']); + } + + public function testAcceptsLaxWithoutSecure(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', [ + 'salt' => self::VALID_SALT, + 'samesite' => 'Lax', + 'secure' => false, + ], $writer); + + $adapter->set('x', 1); + $last = $writer->lastCall(); + self::assertNotNull($last); + self::assertFalse($last['options']['secure']); + } + + public function testCustomExpiresOptionIsRespected(): void + { + $writer = new InMemoryCookieWriter(); + $expires = \time() + 7200; + $adapter = new CookieAdapter('auth', [ + 'salt' => self::VALID_SALT, + 'expires' => $expires, + ], $writer); + + $adapter->set('x', 1); + $last = $writer->lastCall(); + self::assertNotNull($last); + self::assertSame($expires, $last['options']['expires']); + } + + public function testGetReturnsDefaultForAbsentKey(): void + { + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + + self::assertNull($adapter->get('missing')); + self::assertSame('fallback', $adapter->get('missing', 'fallback')); + } + + public function testHasReflectsStoredKeys(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + + self::assertFalse($adapter->has('user_id')); + $adapter->set('user_id', 42); + self::assertTrue($adapter->has('user_id')); + } + + public function testRemoveDropsKeyAndRewritesCookie(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + $adapter->collective(['a' => 1, 'b' => 2]); + + $adapter->remove('a'); + + self::assertFalse($adapter->has('a')); + self::assertTrue($adapter->has('b')); + } + + public function testCollectiveEmitsOneCookieForMultipleKeys(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + + $adapter->collective(['a' => 1, 'b' => 2, 'c' => 3]); + + // The default AbstractAdapter::collective iterates set() once per + // pair, but CookieAdapter overrides it precisely so a bulk write + // emits one Set-Cookie header rather than N. + self::assertCount(1, $writer->calls()); + } + + public function testRoundtripsValuesViaCookie(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + + $adapter->set('user_id', 42)->set('role', 'editor'); + + $last = $writer->lastCall(); + self::assertNotNull($last); + $_COOKIE['auth'] = $last['value']; + + $second = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + self::assertSame(42, $second->get('user_id')); + self::assertSame('editor', $second->get('role')); + } + + public function testRoundtripPreservesUnicodeAndSlashes(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + + $adapter->set('name', 'Müller / O\'Brien — 日本語'); + + $last = $writer->lastCall(); + self::assertNotNull($last); + $_COOKIE['auth'] = $last['value']; + + $second = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + self::assertSame('Müller / O\'Brien — 日本語', $second->get('name')); + } + + public function testRejectsTamperedSignature(): void + { + $writer = new InMemoryCookieWriter(); + (new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer))->set('user_id', 42); + $last = $writer->lastCall(); + self::assertNotNull($last); + $cookie = $last['value']; + + $tampered = \substr($cookie, 0, -1) . (\substr($cookie, -1) === 'a' ? 'b' : 'a'); + $_COOKIE['auth'] = $tampered; + + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + self::assertNull($adapter->get('user_id')); + } + + public function testRejectsCookieEncodedWithDifferentSalt(): void + { + $writer = new InMemoryCookieWriter(); + (new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer))->set('user_id', 42); + $last = $writer->lastCall(); + self::assertNotNull($last); + $_COOKIE['auth'] = $last['value']; + + // Different salt → the HMAC does not verify and decoder yields empty. + $other = new CookieAdapter('auth', ['salt' => \str_repeat('z', 32)], new InMemoryCookieWriter()); + self::assertNull($other->get('user_id')); + } + + /** + * Regression for C4: destroy() must reuse the original path/domain so + * the browser actually drops the cookie. + */ + public function testDestroyEmitsDeletionWithOriginalAttributes(): void + { + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', [ + 'salt' => self::VALID_SALT, + 'path' => '/admin', + 'domain' => 'example.com', + ], $writer); + + $adapter->destroy(); + + $last = $writer->lastCall(); + self::assertNotNull($last); + self::assertSame('auth', $last['name']); + self::assertSame('', $last['value']); + self::assertSame('/admin', $last['options']['path']); + self::assertSame('example.com', $last['options']['domain']); + self::assertSame('Lax', $last['options']['samesite']); + self::assertTrue($last['options']['secure']); + self::assertLessThan(\time(), $last['options']['expires']); + } + + public function testDestroyAlsoClearsCookieSuperglobal(): void + { + $_COOKIE['auth'] = 'irrelevant'; + $writer = new InMemoryCookieWriter(); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + + $adapter->destroy(); + + self::assertArrayNotHasKey('auth', $_COOKIE); + } + + public function testGetAfterDestroyRaisesRuntimeException(): void + { + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + $adapter->destroy(); + + $this->expectException(RuntimeException::class); + $adapter->get('user_id'); + } + + public function testSetAfterDestroyRaisesRuntimeException(): void + { + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + $adapter->destroy(); + + $this->expectException(RuntimeException::class); + $adapter->set('user_id', 1); + } + + public function testSaveSurfacesWriterFailureViaReturnValueOfDestroy(): void + { + $writer = new InMemoryCookieWriter(); + $writer->returnValue(false); + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], $writer); + + self::assertFalse($adapter->destroy()); + } + + public function testGracefullyHandlesMalformedCookie(): void + { + $_COOKIE['auth'] = 'not-a-valid-format'; + + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + self::assertNull($adapter->get('user_id')); + } + + public function testGracefullyHandlesLegacyV1Cookie(): void + { + $_COOKIE['auth'] = \base64_encode(\serialize(['data' => ['x' => 1], 'hash' => 'whatever'])); + + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + self::assertNull($adapter->get('x')); + } + + public function testGracefullyHandlesNonStringCookieValue(): void + { + $_COOKIE['auth'] = ['this', 'is', 'an', 'array']; + + $adapter = new CookieAdapter('auth', ['salt' => self::VALID_SALT], new InMemoryCookieWriter()); + self::assertNull($adapter->get('user_id')); + } +} diff --git a/tests/Fixture/NotAnAdapter.php b/tests/Fixture/NotAnAdapter.php new file mode 100644 index 0000000..bdfaedf --- /dev/null +++ b/tests/Fixture/NotAnAdapter.php @@ -0,0 +1,13 @@ +}> */ + public array $calls = []; + + /** @var array */ + public array $constructorOptions = []; + + public string $constructorName = ''; + + /** + * @param array $options + */ + public function __construct(string $name = '', array $options = []) + { + $this->constructorName = $name; + $this->constructorOptions = $options; + } + + public function get(string $key, $default = null) + { + $this->calls[] = ['method' => 'get', 'args' => [$key, $default]]; + + return 'recorded:' . $key; + } + + public function set(string $key, $value): AdapterInterface + { + $this->calls[] = ['method' => 'set', 'args' => [$key, $value]]; + + return $this; + } + + public function has(string $key): bool + { + $this->calls[] = ['method' => 'has', 'args' => [$key]]; + + return true; + } + + public function remove(string ...$key): AdapterInterface + { + $this->calls[] = ['method' => 'remove', 'args' => $key]; + + return $this; + } + + public function destroy(): bool + { + $this->calls[] = ['method' => 'destroy', 'args' => []]; + + return true; + } + + /** + * Non-interface method exercised by Segment::__call() forwarding. + */ + public function refreshToken(string $reason): string + { + $this->calls[] = ['method' => 'refreshToken', 'args' => [$reason]]; + + return 'refreshed:' . $reason; + } +} diff --git a/tests/NullAdapterTest.php b/tests/NullAdapterTest.php new file mode 100644 index 0000000..71286c8 --- /dev/null +++ b/tests/NullAdapterTest.php @@ -0,0 +1,68 @@ +get('missing')); + self::assertSame('fallback', $adapter->get('missing', 'fallback')); + } + + /** + * Regression for the v1 inconsistency where has() returned true while + * get() returned the default. v2 makes has() honest: nothing is ever + * present in a Null Object store. + */ + public function testHasAlwaysReturnsFalse(): void + { + $adapter = new NullAdapter(); + + self::assertFalse($adapter->has('anything')); + } + + public function testSetIsANoOpButReturnsAdapter(): void + { + $adapter = new NullAdapter(); + + self::assertSame($adapter, $adapter->set('user_id', 42)); + self::assertFalse($adapter->has('user_id')); + self::assertNull($adapter->get('user_id')); + } + + public function testCollectiveIsANoOpButReturnsAdapter(): void + { + $adapter = new NullAdapter(); + + self::assertSame($adapter, $adapter->collective(['a' => 1, 'b' => 2])); + self::assertFalse($adapter->has('a')); + } + + public function testRemoveIsANoOpButReturnsAdapter(): void + { + $adapter = new NullAdapter(); + + self::assertSame($adapter, $adapter->remove('a', 'b')); + } + + public function testDestroyReturnsTrue(): void + { + $adapter = new NullAdapter(); + + self::assertTrue($adapter->destroy()); + } + + public function testImplementsAdapterInterface(): void + { + self::assertInstanceOf(AdapterInterface::class, new NullAdapter()); + } +} diff --git a/tests/PermissionTest.php b/tests/PermissionTest.php new file mode 100644 index 0000000..d0a87cc --- /dev/null +++ b/tests/PermissionTest.php @@ -0,0 +1,164 @@ +is('editor')); + self::assertTrue($perm->is('post_list')); + self::assertTrue($perm->is('post_add')); + self::assertTrue($perm->is('EDITOR')); + } + + public function testConstructorDeduplicatesNormalizedPermissions(): void + { + $perm = new Permission(['Editor', 'editor', 'EDITOR']); + + self::assertSame(['editor'], $perm->getPermissions()); + } + + public function testConstructorTrimsAndLowercasesEachPermission(): void + { + $perm = new Permission([' Admin ', "\tviewer\n"]); + + self::assertSame(['admin', 'viewer'], $perm->getPermissions()); + } + + public function testConstructorSilentlySkipsNonStringValues(): void + { + /** @phpstan-ignore-next-line — exercising the runtime guard */ + $perm = new Permission(['admin', 42, null, 'editor', ['nested']]); + + self::assertSame(['admin', 'editor'], $perm->getPermissions()); + } + + public function testIsReturnsFalseForEmptyPermissionSet(): void + { + $perm = new Permission(); + + self::assertFalse($perm->is('admin')); + } + + public function testIsReturnsTrueWhenAnyOfTheSuppliedNamesMatches(): void + { + $perm = new Permission(['admin']); + + self::assertTrue($perm->is('editor', 'admin', 'viewer')); + } + + public function testPushAddsNewPermissionsAndReportsCount(): void + { + $perm = new Permission(['admin']); + + self::assertSame(2, $perm->push('editor', 'Viewer')); + self::assertSame(['admin', 'editor', 'viewer'], $perm->getPermissions()); + } + + public function testPushIsIdempotentAndIgnoresAlreadyPresentPermissions(): void + { + $perm = new Permission(['admin']); + + self::assertSame(0, $perm->push('admin', 'ADMIN', 'Admin')); + self::assertSame(['admin'], $perm->getPermissions()); + } + + public function testRemoveReportsCountAndZeroForMissingPermissions(): void + { + $perm = new Permission(['admin', 'editor']); + + self::assertSame(1, $perm->remove('admin', 'viewer')); + self::assertSame(0, $perm->remove('viewer')); + self::assertSame(['editor'], $perm->getPermissions()); + } + + /** + * Regression: remove() previously left a hole in the internal array + * because unset() does not reindex. A subsequent getPermissions() call + * then exposed a non-sequential array which broke JSON encoding and + * any caller that relied on list semantics. + */ + public function testRemoveReindexesPermissionList(): void + { + $perm = new Permission(['admin', 'editor', 'viewer']); + $perm->remove('editor'); + + self::assertSame(['admin', 'viewer'], $perm->getPermissions()); + } + + public function testDeprecatedGetPermissionAliasReturnsSameList(): void + { + $perm = new Permission(['admin', 'editor']); + + self::assertSame($perm->getPermissions(), $perm->getPermission()); + } + + public function testMagicIsPrefixedCallDelegatesToIs(): void + { + $perm = new Permission(['admin']); + + /** @phpstan-ignore-next-line — magic accessor */ + self::assertTrue($perm->is_admin()); + /** @phpstan-ignore-next-line */ + self::assertFalse($perm->is_editor()); + } + + public function testMagicCallRejectsUnknownMethodNames(): void + { + $perm = new Permission(); + + $this->expectException(BadMethodCallException::class); + /** @phpstan-ignore-next-line */ + $perm->doSomething(); + } + + public function testMagicCallRejectsEmptyIsPrefix(): void + { + $perm = new Permission(['admin']); + + /** @phpstan-ignore-next-line — bare `is_` resolves to empty name and must be falsy */ + self::assertFalse($perm->is_()); + } + + public function testMagicIssetSupportsBareAndIsPrefixedAccess(): void + { + $perm = new Permission(['admin']); + + self::assertTrue(isset($perm->admin)); + self::assertTrue(isset($perm->is_admin)); + self::assertFalse(isset($perm->editor)); + } + + public function testMagicUnsetRemovesPermission(): void + { + $perm = new Permission(['admin', 'editor']); + + unset($perm->is_admin); + + self::assertSame(['editor'], $perm->getPermissions()); + } + + public function testSerializationRoundtripPreservesPermissionList(): void + { + $original = new Permission(['Admin', 'editor']); + /** @var Permission $restored */ + $restored = \unserialize(\serialize($original)); + + self::assertSame(['admin', 'editor'], $restored->getPermissions()); + self::assertTrue($restored->is('admin')); + } +} diff --git a/tests/SegmentTest.php b/tests/SegmentTest.php new file mode 100644 index 0000000..3122676 --- /dev/null +++ b/tests/SegmentTest.php @@ -0,0 +1,189 @@ + 'bar']); + + /** @var RecordingAdapter $adapter */ + $adapter = $segment->adapter(); + self::assertInstanceOf(RecordingAdapter::class, $adapter); + self::assertSame('auth', $adapter->constructorName); + self::assertSame(['foo' => 'bar'], $adapter->constructorOptions); + } + + public function testCookieFactoryReturnsCookieAdapter(): void + { + $segment = Segment::cookie('auth', ['salt' => self::VALID_SALT]); + + self::assertInstanceOf(CookieAdapter::class, $segment->adapter()); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testSessionFactoryReturnsSessionAdapter(): void + { + \session_start(); + + $segment = Segment::session('auth'); + self::assertInstanceOf(SessionAdapter::class, $segment->adapter()); + } + + public function testLegacyCreateWithIntegerConstantStillResolves(): void + { + $segment = Segment::create('auth', Segment::ADAPTER_COOKIE, ['salt' => self::VALID_SALT]); + + self::assertInstanceOf(CookieAdapter::class, $segment->adapter()); + } + + public function testLegacyConstructorWithStringClassNameStillResolves(): void + { + $segment = new Segment('auth', RecordingAdapter::class, ['k' => 'v']); + + self::assertInstanceOf(RecordingAdapter::class, $segment->adapter()); + } + + public function testRejectsUnknownIntegerAdapterConstant(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unknown adapter constant/'); + new Segment('auth', 999); + } + + public function testRejectsNonExistentAdapterClass(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/does not exist/'); + new Segment('auth', 'App\\No\\Such\\Class'); + } + + public function testRejectsClassThatDoesNotExtendAbstractAdapter(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/must extend/'); + new Segment('auth', NotAnAdapter::class); + } + + public function testRejectsAdapterArgumentThatIsNeitherIntNorString(): void + { + $this->expectException(InvalidArgumentException::class); + /** @phpstan-ignore-next-line — exercising the runtime guard */ + new Segment('auth', 1.5); + } + + public function testImplementsAdapterInterface(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + self::assertInstanceOf(AdapterInterface::class, $segment); + } + + public function testGetDelegatesToAdapter(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + /** @var RecordingAdapter $adapter */ + $adapter = $segment->adapter(); + + $result = $segment->get('user_id', 'fallback'); + + self::assertSame('recorded:user_id', $result); + self::assertSame([['method' => 'get', 'args' => ['user_id', 'fallback']]], $adapter->calls); + } + + public function testSetDelegatesAndReturnsSegmentForChaining(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + /** @var RecordingAdapter $adapter */ + $adapter = $segment->adapter(); + + $returned = $segment->set('user_id', 42); + + self::assertSame($segment, $returned); + self::assertSame([['method' => 'set', 'args' => ['user_id', 42]]], $adapter->calls); + } + + public function testCollectiveDelegatesAndReturnsSegment(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + /** @var RecordingAdapter $adapter */ + $adapter = $segment->adapter(); + + // collective() on RecordingAdapter falls through to AbstractAdapter's + // default implementation, which iterates set() per pair. + $segment->collective(['a' => 1, 'b' => 2]); + + self::assertSame( + [ + ['method' => 'set', 'args' => ['a', 1]], + ['method' => 'set', 'args' => ['b', 2]], + ], + $adapter->calls + ); + } + + public function testHasRemoveAndDestroyDelegate(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + /** @var RecordingAdapter $adapter */ + $adapter = $segment->adapter(); + + self::assertTrue($segment->has('x')); + self::assertSame($segment, $segment->remove('a', 'b')); + self::assertTrue($segment->destroy()); + + self::assertSame( + [ + ['method' => 'has', 'args' => ['x']], + ['method' => 'remove', 'args' => ['a', 'b']], + ['method' => 'destroy', 'args' => []], + ], + $adapter->calls + ); + } + + public function testMagicCallForwardsToAdapterExtensionMethods(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + /** @var RecordingAdapter $adapter */ + $adapter = $segment->adapter(); + + /** @phpstan-ignore-next-line — exercising __call forwarding */ + $result = $segment->refreshToken('expired'); + + self::assertSame('refreshed:expired', $result); + self::assertSame([['method' => 'refreshToken', 'args' => ['expired']]], $adapter->calls); + } + + public function testMagicCallSurfacesErrorWhenAdapterMethodMissing(): void + { + $segment = Segment::custom('auth', RecordingAdapter::class); + + $this->expectException(Error::class); + /** @phpstan-ignore-next-line */ + $segment->totallyMadeUp(); + } +} diff --git a/tests/SessionAdapterTest.php b/tests/SessionAdapterTest.php new file mode 100644 index 0000000..84c9d98 --- /dev/null +++ b/tests/SessionAdapterTest.php @@ -0,0 +1,174 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Sessions must be started.'); + new SessionAdapter('auth'); + } + + public function testGetReturnsDefaultForAbsentKey(): void + { + $adapter = new SessionAdapter('auth'); + + self::assertNull($adapter->get('missing')); + self::assertSame('fallback', $adapter->get('missing', 'fallback')); + } + + public function testSetPersistsValueToSessionSuperglobal(): void + { + $adapter = new SessionAdapter('auth'); + + $adapter->set('user_id', 42); + + self::assertSame(42, $adapter->get('user_id')); + self::assertSame(['user_id' => 42], $_SESSION['auth']); + } + + public function testSetReturnsAdapterForChaining(): void + { + $adapter = new SessionAdapter('auth'); + + self::assertSame($adapter, $adapter->set('user_id', 1)); + } + + public function testCollectivePersistsAllPairsAndSynchronizesSuperglobalOnce(): void + { + $adapter = new SessionAdapter('auth'); + + $adapter->collective(['user_id' => 7, 'role' => 'editor']); + + self::assertSame(['user_id' => 7, 'role' => 'editor'], $_SESSION['auth']); + } + + public function testHasReflectsTheCurrentState(): void + { + $adapter = new SessionAdapter('auth'); + + self::assertFalse($adapter->has('user_id')); + $adapter->set('user_id', 1); + self::assertTrue($adapter->has('user_id')); + } + + public function testRemoveDropsKeyAndSynchronizesSuperglobal(): void + { + $adapter = new SessionAdapter('auth'); + $adapter->collective(['user_id' => 1, 'role' => 'admin']); + + $adapter->remove('user_id'); + + self::assertFalse($adapter->has('user_id')); + self::assertSame(['role' => 'admin'], $_SESSION['auth']); + } + + public function testRemoveAcceptsMultipleKeys(): void + { + $adapter = new SessionAdapter('auth'); + $adapter->collective(['a' => 1, 'b' => 2, 'c' => 3]); + + $adapter->remove('a', 'c'); + + self::assertSame(['b' => 2], $_SESSION['auth']); + } + + public function testDestroyClearsSessionSlotAndReturnsTrue(): void + { + $adapter = new SessionAdapter('auth'); + $adapter->set('user_id', 1); + + self::assertTrue($adapter->destroy()); + self::assertArrayNotHasKey('auth', $_SESSION); + } + + public function testDestroyReturnsFalseWhenSlotWasNeverWritten(): void + { + $adapter = new SessionAdapter('auth'); + + self::assertFalse($adapter->destroy()); + } + + public function testGetAfterDestroyRaisesRuntimeException(): void + { + $adapter = new SessionAdapter('auth'); + $adapter->destroy(); + + $this->expectException(RuntimeException::class); + $adapter->get('user_id'); + } + + public function testSetAfterDestroyRaisesRuntimeException(): void + { + $adapter = new SessionAdapter('auth'); + $adapter->destroy(); + + $this->expectException(RuntimeException::class); + $adapter->set('user_id', 1); + } + + public function testReadsExistingSessionDataOnInstantiation(): void + { + $_SESSION['auth'] = ['user_id' => 99, 'role' => 'admin']; + + $adapter = new SessionAdapter('auth'); + + self::assertSame(99, $adapter->get('user_id')); + self::assertSame('admin', $adapter->get('role')); + } + + public function testConstructorOptionsAreForwardedToParameterBag(): void + { + $_SESSION['auth'] = ['db' => ['host' => 'localhost', 'port' => 3306]]; + + $adapter = new SessionAdapter('auth', ['isMulti' => true]); + + // With isMulti=true, ParameterBag walks dotted paths. + self::assertSame('localhost', $adapter->get('db.host')); + self::assertSame(3306, $adapter->get('db.port')); + } + + public function testDifferentNamesCohabitateInTheSameSession(): void + { + $cart = new SessionAdapter('cart'); + $auth = new SessionAdapter('auth'); + + $cart->set('items', 3); + $auth->set('user_id', 1); + + self::assertSame(3, $cart->get('items')); + self::assertSame(1, $auth->get('user_id')); + self::assertNull($auth->get('items')); + } +}