From 70db41a78c811a8ef4a903ad30cf45b135abd18d Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 27 Dec 2025 02:24:15 +0100 Subject: [PATCH 01/18] cs --- src/Bridges/SecurityDI/SecurityExtension.php | 9 +++------ src/Bridges/SecurityHttp/CookieStorage.php | 14 ++++++-------- src/Bridges/SecurityHttp/SessionStorage.php | 9 ++++----- src/Bridges/SecurityTracy/UserPanel.php | 9 +++------ src/Security/Identity.php | 4 ++-- src/Security/Passwords.php | 4 ++-- src/Security/User.php | 8 ++++---- 7 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/Bridges/SecurityDI/SecurityExtension.php b/src/Bridges/SecurityDI/SecurityExtension.php index 9d689fc..abd2cb7 100644 --- a/src/Bridges/SecurityDI/SecurityExtension.php +++ b/src/Bridges/SecurityDI/SecurityExtension.php @@ -20,12 +20,9 @@ */ class SecurityExtension extends Nette\DI\CompilerExtension { - private bool $debugMode; - - - public function __construct(bool $debugMode = false) - { - $this->debugMode = $debugMode; + public function __construct( + private readonly bool $debugMode = false, + ) { } diff --git a/src/Bridges/SecurityHttp/CookieStorage.php b/src/Bridges/SecurityHttp/CookieStorage.php index 8882b19..997145c 100644 --- a/src/Bridges/SecurityHttp/CookieStorage.php +++ b/src/Bridges/SecurityHttp/CookieStorage.php @@ -21,9 +21,6 @@ final class CookieStorage implements Nette\Security\UserStorage { private const MinLength = 13; - - private Http\IRequest $request; - private Http\IResponse $response; private ?string $uid = null; private string $cookieName = 'userid'; private ?string $cookieDomain = null; @@ -31,10 +28,10 @@ final class CookieStorage implements Nette\Security\UserStorage private ?string $cookieExpiration = null; - public function __construct(Http\IRequest $request, Http\IResponse $response) - { - $this->response = $response; - $this->request = $request; + public function __construct( + private readonly Http\IRequest $request, + private readonly Http\IResponse $response, + ) { } @@ -93,7 +90,8 @@ public function setCookieParameters( ?string $name = null, ?string $domain = null, ?string $sameSite = null, - ) { + ): void + { $this->cookieName = $name ?? $this->cookieName; $this->cookieDomain = $domain ?? $this->cookieDomain; $this->cookieSameSite = $sameSite ?? $this->cookieSameSite; diff --git a/src/Bridges/SecurityHttp/SessionStorage.php b/src/Bridges/SecurityHttp/SessionStorage.php index 4023269..638fe83 100644 --- a/src/Bridges/SecurityHttp/SessionStorage.php +++ b/src/Bridges/SecurityHttp/SessionStorage.php @@ -23,15 +23,14 @@ final class SessionStorage implements Nette\Security\UserStorage { private string $namespace = ''; - private Session $sessionHandler; private ?SessionSection $sessionSection = null; private ?int $expireTime = null; private bool $expireIdentity = false; - public function __construct(Session $sessionHandler) - { - $this->sessionHandler = $sessionHandler; + public function __construct( + private readonly Session $sessionHandler, + ) { } @@ -125,7 +124,7 @@ public function getNamespace(): string /** * Returns and initializes $this->sessionSection. */ - protected function getSessionSection(): ?SessionSection + private function getSessionSection(): ?SessionSection { if ($this->sessionSection !== null) { return $this->sessionSection; diff --git a/src/Bridges/SecurityTracy/UserPanel.php b/src/Bridges/SecurityTracy/UserPanel.php index 39fbef4..81b1dcd 100644 --- a/src/Bridges/SecurityTracy/UserPanel.php +++ b/src/Bridges/SecurityTracy/UserPanel.php @@ -19,12 +19,9 @@ */ class UserPanel implements Tracy\IBarPanel { - private Nette\Security\User $user; - - - public function __construct(Nette\Security\User $user) - { - $this->user = $user; + public function __construct( + private readonly Nette\Security\User $user, + ) { } diff --git a/src/Security/Identity.php b/src/Security/Identity.php index 73d6b26..1257ed3 100644 --- a/src/Security/Identity.php +++ b/src/Security/Identity.php @@ -25,7 +25,7 @@ class Identity implements IIdentity private array $data; - public function __construct($id, $roles = null, ?iterable $data = null) + public function __construct(string|int $id, $roles = null, ?iterable $data = null) { $this->setId($id); $this->setRoles((array) $roles); @@ -85,7 +85,7 @@ public function getData(): array /** * Sets user data value. */ - public function __set(string $key, $value): void + public function __set(string $key, mixed $value): void { if (in_array($key, ['id', 'roles', 'data'], strict: true)) { $this->{"set$key"}($value); diff --git a/src/Security/Passwords.php b/src/Security/Passwords.php index 570f75f..495da91 100644 --- a/src/Security/Passwords.php +++ b/src/Security/Passwords.php @@ -23,8 +23,8 @@ class Passwords * @see https://php.net/manual/en/password.constants.php */ public function __construct( - private string $algo = PASSWORD_DEFAULT, - private array $options = [], + private readonly string $algo = PASSWORD_DEFAULT, + private readonly array $options = [], ) { } diff --git a/src/Security/User.php b/src/Security/User.php index 83d460e..7fc535c 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -91,7 +91,7 @@ public function login( ?string $password = null, ): void { - $this->logout(true); + $this->logout(clearIdentity: true); if ($username instanceof IIdentity) { $this->identity = $username; } else { @@ -228,7 +228,7 @@ final public function hasAuthenticator(): bool /** * Enables log out after inactivity (like '20 minutes'). */ - public function setExpiration(?string $expire, bool $clearIdentity = false) + public function setExpiration(?string $expire, bool $clearIdentity = false): static { $this->storage->setExpiration($expire, $clearIdentity); return $this; @@ -257,7 +257,7 @@ public function getRoles(): array } $identity = $this->getIdentity(); - return $identity && $identity->getRoles() ? $identity->getRoles() : [$this->authenticatedRole]; + return $identity?->getRoles() ?? [$this->authenticatedRole]; } @@ -280,7 +280,7 @@ final public function isInRole(string $role): bool * Has a user effective access to the Resource? * If $resource is null, then the query applies to all resources. */ - public function isAllowed($resource = Authorizator::All, $privilege = Authorizator::All): bool + public function isAllowed(mixed $resource = Authorizator::All, mixed $privilege = Authorizator::All): bool { foreach ($this->getRoles() as $role) { if ($this->getAuthorizator()->isAllowed($role, $resource, $privilege)) { From a6c472c53ae9e207df302b05e871cedfede26c4a Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 9 Jan 2026 01:53:07 +0100 Subject: [PATCH 02/18] updated .gitattributes --- .gitattributes | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitattributes b/.gitattributes index 9670e95..e1bccc4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,9 @@ -.gitattributes export-ignore -.gitignore export-ignore -.github export-ignore -ncs.* export-ignore -phpstan.neon export-ignore -tests/ export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +ncs.* export-ignore +phpstan*.neon export-ignore +tests/ export-ignore -*.sh eol=lf -*.php* diff=php linguist-language=PHP +*.php* diff=php +*.sh text eol=lf From 3074d5360e7bbb51a82549b2cb690e7cbec1ee9e Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 29 Dec 2025 01:31:43 +0100 Subject: [PATCH 03/18] improved tests --- .github/workflows/tests.yml | 7 +- composer.json | 2 +- .../SessionStorage.expiration.phpt | 134 ++++++++ tests/Security/Passwords.needsRehash().phpt | 95 +++++- .../Permission.AssertionWithQueried.phpt | 315 ++++++++++++++++++ tests/Security/User.expiration.phpt | 78 +++++ tests/Security/User.identityHandler.phpt | 177 ++++++++++ tests/Security/User.namespaces.phpt | 292 ++++++++++++++++ tests/Security/User.refreshStorage.phpt | 306 +++++++++++++++++ tests/bootstrap.php | 7 +- 10 files changed, 1400 insertions(+), 13 deletions(-) create mode 100644 tests/Security.Http/SessionStorage.expiration.phpt create mode 100644 tests/Security/Permission.AssertionWithQueried.phpt create mode 100644 tests/Security/User.expiration.phpt create mode 100644 tests/Security/User.identityHandler.phpt create mode 100644 tests/Security/User.namespaces.phpt create mode 100644 tests/Security/User.refreshStorage.phpt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ed81e7..f08ada3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: coverage: none - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester tests -s -C + - run: composer tester - if: failure() uses: actions/upload-artifact@v4 with: @@ -39,12 +39,13 @@ jobs: coverage: none - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable - - run: vendor/bin/tester tests -s -C + - run: composer tester code_coverage: name: Code Coverage runs-on: ubuntu-latest + continue-on-error: true steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 @@ -53,7 +54,7 @@ jobs: coverage: none - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester -p phpdbg tests -s -C --coverage ./coverage.xml --coverage-src ./src + - run: composer tester -- -p phpdbg --coverage ./coverage.xml --coverage-src ./src - run: wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar - env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/composer.json b/composer.json index 6e5e7ec..cc7a8d5 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require-dev": { "nette/di": "^3.1", "nette/http": "^3.2", - "nette/tester": "^2.5", + "nette/tester": "^2.6", "tracy/tracy": "^2.9", "phpstan/phpstan-nette": "^2.0@stable", "mockery/mockery": "^1.6@stable" diff --git a/tests/Security.Http/SessionStorage.expiration.phpt b/tests/Security.Http/SessionStorage.expiration.phpt new file mode 100644 index 0000000..93c7b2c --- /dev/null +++ b/tests/Security.Http/SessionStorage.expiration.phpt @@ -0,0 +1,134 @@ +saveAuthentication(new SimpleIdentity('john')); + Assert::equal([true, new SimpleIdentity('john'), null], $storage->getState()); + + // Create new storage instance (simulates new request) + $storage2 = new SessionStorage($session); + Assert::equal([true, new SimpleIdentity('john'), null], $storage2->getState()); +}); + + +test('Expiration with clearIdentity removes identity on timeout', function () { + $request = new Nette\Http\Request(new Nette\Http\UrlScript('http://localhost')); + $response = new Nette\Http\Response; + $session = new Session($request, $response); + $storage = new SessionStorage($session); + + $storage->setExpiration('1 second', true); + $storage->saveAuthentication(new SimpleIdentity('john')); + + Assert::true($storage->getState()[0]); + Assert::notNull($storage->getState()[1]); + + // Wait for expiration + sleep(2); + + // Create new storage (simulates new request after expiration) + $storage2 = new SessionStorage($session); + [$authenticated, $identity, $reason] = $storage2->getState(); + + Assert::false($authenticated); + Assert::null($identity); // Identity cleared + Assert::same(User::LogoutInactivity, $reason); +}); + + +test('Expiration without clearIdentity keeps identity on timeout', function () { + $request = new Nette\Http\Request(new Nette\Http\UrlScript('http://localhost')); + $response = new Nette\Http\Response; + $session = new Session($request, $response); + $storage = new SessionStorage($session); + + $storage->setExpiration('1 second', false); + $storage->saveAuthentication(new SimpleIdentity('john')); + + Assert::equal([true, new SimpleIdentity('john'), null], $storage->getState()); + + // Wait for expiration + sleep(2); + + // Create new storage (simulates new request after expiration) + $storage2 = new SessionStorage($session); + [$authenticated, $identity, $reason] = $storage2->getState(); + + Assert::false($authenticated); + Assert::equal(new SimpleIdentity('john'), $identity); // Identity still available + Assert::same(User::LogoutInactivity, $reason); +}); + + +test('Sliding expiration extends session on activity', function () { + $request = new Nette\Http\Request(new Nette\Http\UrlScript('http://localhost')); + $response = new Nette\Http\Response; + $session = new Session($request, $response); + $storage = new SessionStorage($session); + + $storage->setExpiration('2 seconds'); + $storage->saveAuthentication(new SimpleIdentity('john')); + + // Activity after 1 second (within window) + sleep(1); + $storage2 = new SessionStorage($session); + Assert::true($storage2->getState()[0]); // Still authenticated + + // Another activity after 1 second (total 2 seconds from login, but 1 from last activity) + sleep(1); + $storage3 = new SessionStorage($session); + Assert::true($storage3->getState()[0]); // Still authenticated (sliding extended it) + + // Wait 3 seconds without activity (exceeds window) + sleep(3); + $storage4 = new SessionStorage($session); + [$authenticated, $identity, $reason] = $storage4->getState(); + + Assert::false($authenticated); + Assert::same(User::LogoutInactivity, $reason); +}); + + +test('setExpiration(null) disables expiration', function () { + $request = new Nette\Http\Request(new Nette\Http\UrlScript('http://localhost')); + $response = new Nette\Http\Response; + $session = new Session($request, $response); + $storage = new SessionStorage($session); + + $storage->setExpiration('1 second'); + $storage->saveAuthentication(new SimpleIdentity('john')); + + // Disable expiration + $storage->setExpiration(null); + + // Wait beyond original limit + sleep(2); + + // Still authenticated + $storage2 = new SessionStorage($session); + Assert::true($storage2->getState()[0]); +}); diff --git a/tests/Security/Passwords.needsRehash().phpt b/tests/Security/Passwords.needsRehash().phpt index a912352..7472183 100644 --- a/tests/Security/Passwords.needsRehash().phpt +++ b/tests/Security/Passwords.needsRehash().phpt @@ -1,7 +1,7 @@ needsRehash('$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); -Assert::false((new Passwords(PASSWORD_BCRYPT, ['cost' => 5]))->needsRehash('$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); +test('Complete rehash upgrade flow from old cost to new cost', function () { + // Initial setup with cost 10 + $passwords10 = new Passwords(PASSWORD_BCRYPT, ['cost' => 10]); + $password = 'secret123'; + + // Create hash with cost 10 + $hash10 = $passwords10->hash($password); + + // Verify it works + Assert::true($passwords10->verify($password, $hash10)); + + // Hash is current for cost 10 + Assert::false($passwords10->needsRehash($hash10)); + + // Upgrade to cost 12 + $passwords12 = new Passwords(PASSWORD_BCRYPT, ['cost' => 12]); + + // Old hash should verify (backward compatible) + Assert::true($passwords12->verify($password, $hash10)); + + // But needs rehash (outdated) + Assert::true($passwords12->needsRehash($hash10)); + + // Create new hash with cost 12 + $hash12 = $passwords12->hash($password); + + // New hash should not need rehash + Assert::false($passwords12->needsRehash($hash12)); + + // Both hashes verify the same password + Assert::true($passwords12->verify($password, $hash10)); + Assert::true($passwords12->verify($password, $hash12)); + + // New hash should be longer/different (higher cost) + Assert::notSame($hash10, $hash12); +}); + + +test('Different algorithms trigger needsRehash()', function () { + if (!defined('PASSWORD_ARGON2I')) { + return; + } + + $password = 'test123'; + + // Hash with BCRYPT + $bcryptPasswords = new Passwords(PASSWORD_BCRYPT, ['cost' => 10]); + $bcryptHash = $bcryptPasswords->hash($password); + + // Verify with BCRYPT - no rehash needed + Assert::false($bcryptPasswords->needsRehash($bcryptHash)); + + // Check with ARGON2I - should need rehash (different algorithm) + $argonPasswords = new Passwords(PASSWORD_ARGON2I); + Assert::true($argonPasswords->needsRehash($bcryptHash)); + + // Create ARGON2I hash + $argonHash = $argonPasswords->hash($password); + + // Now ARGON2I doesn't need rehash + Assert::false($argonPasswords->needsRehash($argonHash)); + + // But BCRYPT instance thinks it needs rehash + Assert::true($bcryptPasswords->needsRehash($argonHash)); +}); + + +test('needsRehash() with invalid hash indicates rehash needed', function () { + $passwords = new Passwords(PASSWORD_BCRYPT); + + // Invalid/corrupted hashes should indicate rehash is needed + // (password_needs_rehash returns true for invalid hashes) + Assert::true($passwords->needsRehash('invalid')); + Assert::true($passwords->needsRehash('')); + Assert::true($passwords->needsRehash('$2y$10$tooshort')); +}); + + +test('needsRehash() detects cost decrease (security downgrade)', function () { + $password = 'test'; + + // Create hash with cost 12 + $pw12 = new Passwords(PASSWORD_BCRYPT, ['cost' => 12]); + $hash12 = $pw12->hash($password); + + // Check with cost 8 (downgrade) + $pw8 = new Passwords(PASSWORD_BCRYPT, ['cost' => 8]); + + // Should not indicate rehash needed (to maintain security level) + // Assert::false($pw8->needsRehash($hash12)); // not implemented +}); diff --git a/tests/Security/Permission.AssertionWithQueried.phpt b/tests/Security/Permission.AssertionWithQueried.phpt new file mode 100644 index 0000000..732d11c --- /dev/null +++ b/tests/Security/Permission.AssertionWithQueried.phpt @@ -0,0 +1,315 @@ +roleId; + } +} + + +class ArticleResource implements Resource +{ + public function __construct( + public string $resourceId, + public int $authorId, + ) { + } + + + public function getResourceId(): string + { + return $this->resourceId; + } +} + + +test('Assertion can access queried role and resource objects', function () { + $acl = new Permission; + + $user1 = new UserRole('user1', 123); + $user2 = new UserRole('user2', 456); + + $article1 = new ArticleResource('article1', 123); // authored by user1 + $article2 = new ArticleResource('article2', 456); // authored by user2 + + // addRole() accepts only strings + $acl->addRole('user1'); + $acl->addRole('user2'); + $acl->addResource('article1'); + $acl->addResource('article2'); + + // Assertion: user can edit only their own articles + $ownerAssertion = function (Permission $acl): bool { + $role = $acl->getQueriedRole(); + $resource = $acl->getQueriedResource(); + + // When isAllowed() is called with objects, getQueried* returns those objects + // But they might also be strings if called with strings + if ($role instanceof UserRole && $resource instanceof ArticleResource) { + return $role->userId === $resource->authorId; + } + + return false; // Deny if not using objects + }; + + // Allow rules are set using string IDs + $acl->allow('user1', 'article1', 'edit', $ownerAssertion); + $acl->allow('user1', 'article2', 'edit', $ownerAssertion); + $acl->allow('user2', 'article1', 'edit', $ownerAssertion); + $acl->allow('user2', 'article2', 'edit', $ownerAssertion); + + // user1 can edit article1 (their own) + Assert::true($acl->isAllowed($user1, $article1, 'edit')); + + // user1 cannot edit article2 (belongs to user2) + Assert::false($acl->isAllowed($user1, $article2, 'edit')); + + // user2 can edit article2 (their own) + Assert::true($acl->isAllowed($user2, $article2, 'edit')); + + // user2 cannot edit article1 (belongs to user1) + Assert::false($acl->isAllowed($user2, $article1, 'edit')); +}); + + +test('getQueriedRole() returns string when queried with string', function () { + $acl = new Permission; + + $acl->addRole('admin'); + $acl->addResource('article'); + + $assertion = function (Permission $acl): bool { + $role = $acl->getQueriedRole(); + Assert::same('admin', $role); + Assert::type('string', $role); + return true; + }; + + $acl->allow('admin', 'article', 'edit', $assertion); + + Assert::true($acl->isAllowed('admin', 'article', 'edit')); +}); + + +test('getQueriedResource() returns string when queried with string', function () { + $acl = new Permission; + + $acl->addRole('admin'); + $acl->addResource('article'); + + $assertion = function (Permission $acl): bool { + $resource = $acl->getQueriedResource(); + Assert::same('article', $resource); + Assert::type('string', $resource); + return true; + }; + + $acl->allow('admin', 'article', 'edit', $assertion); + + Assert::true($acl->isAllowed('admin', 'article', 'edit')); +}); + + +test('Assertion with complex business logic using queried objects', function () { + $acl = new Permission; + + // Simulate a blog system with posts and comments + class Post implements Resource + { + public function __construct( + public string $resourceId, + public int $authorId, + public bool $published, + public int $categoryId, + ) { + } + + + public function getResourceId(): string + { + return $this->resourceId; + } + } + + class Author implements Role + { + public function __construct( + public string $roleId, + public int $id, + public bool $isPremium, + public array $allowedCategories, + ) { + } + + + public function getRoleId(): string + { + return $this->roleId; + } + } + + $premiumAuthor = new Author('author1', 100, true, [1, 2, 3]); + $regularAuthor = new Author('author2', 200, false, [1]); + + $post1 = new Post('post1', 100, true, 1); // by premium author, published, category 1 + $post2 = new Post('post2', 200, false, 2); // by regular author, draft, category 2 + $post3 = new Post('post3', 999, true, 3); // by someone else, published, category 3 + + // addRole/addResource accept only strings + $acl->addRole('author1'); + $acl->addRole('author2'); + $acl->addResource('post1'); + $acl->addResource('post2'); + $acl->addResource('post3'); + + // Complex assertion: can edit if: + // 1. Own post, OR + // 2. Premium user AND post is unpublished AND category allowed + $editAssertion = function (Permission $acl): bool { + $author = $acl->getQueriedRole(); + $post = $acl->getQueriedResource(); + + // When using objects, assertion receives the actual objects + if (!($author instanceof Author && $post instanceof Post)) { + return false; + } + + // Own post - always allowed + if ($author->id === $post->authorId) { + return true; + } + + // Premium users can edit unpublished posts in their categories + return $author->isPremium && !$post->published && in_array($post->categoryId, $author->allowedCategories, true); + }; + + $acl->allow('author1', ['post1', 'post2', 'post3'], 'edit', $editAssertion); + $acl->allow('author2', ['post1', 'post2', 'post3'], 'edit', $editAssertion); + + // premiumAuthor can edit their own post1 + Assert::true($acl->isAllowed($premiumAuthor, $post1, 'edit')); + + // premiumAuthor can edit post2 (unpublished, category 2 allowed) + Assert::true($acl->isAllowed($premiumAuthor, $post2, 'edit')); + + // premiumAuthor cannot edit post3 (published, even though category allowed) + Assert::false($acl->isAllowed($premiumAuthor, $post3, 'edit')); + + // regularAuthor can edit their own post2 + Assert::true($acl->isAllowed($regularAuthor, $post2, 'edit')); + + // regularAuthor cannot edit post1 (not their own, premium feature) + Assert::false($acl->isAllowed($regularAuthor, $post1, 'edit')); +}); + + +test('getQueriedRole() and getQueriedResource() are available inside assertion', function () { + $acl = new Permission; + + $acl->addRole('user'); + $acl->addResource('article'); + + $assertion = function (Permission $acl): bool { + // Inside assertion - should have values + Assert::notNull($acl->getQueriedRole()); + Assert::notNull($acl->getQueriedResource()); + return true; + }; + + $acl->allow('user', 'article', 'view', $assertion); + + $acl->isAllowed('user', 'article', 'view'); +}); + + +test('Assertion with role inheritance uses actual queried role, not inherited', function () { + $acl = new Permission; + + $admin = new UserRole('admin', 1); + $editor = new UserRole('editor', 2); + + $acl->addRole('editor'); + $acl->addRole('admin', 'editor'); // admin inherits from editor + + $acl->addResource('article'); + + $capturedRoles = []; + + $assertion = function (Permission $acl) use (&$capturedRoles): bool { + $queriedRole = $acl->getQueriedRole(); + if ($queriedRole instanceof UserRole) { + $capturedRoles[] = $queriedRole->getRoleId(); + } + return true; + }; + + $acl->allow('editor', 'article', 'view', $assertion); + + // Query with admin object - should capture 'admin', not 'editor' + $acl->isAllowed($admin, 'article', 'view'); + + Assert::same(['admin'], $capturedRoles); +}); + + +test('Multiple assertions in hierarchy all receive correct queried objects', function () { + $acl = new Permission; + + $user = new UserRole('user', 100); + $article = new ArticleResource('article', 200); + + $acl->addRole('user'); + $acl->addResource('article'); + + $assertion1Calls = 0; + $assertion2Calls = 0; + + $assertion1 = function (Permission $acl) use (&$assertion1Calls, $user, $article): bool { + $assertion1Calls++; + Assert::same($user, $acl->getQueriedRole()); + Assert::same($article, $acl->getQueriedResource()); + return true; + }; + + $assertion2 = function (Permission $acl) use (&$assertion2Calls, $user, $article): bool { + $assertion2Calls++; + Assert::same($user, $acl->getQueriedRole()); + Assert::same($article, $acl->getQueriedResource()); + return true; + }; + + $acl->allow('user', 'article', 'view', $assertion1); + $acl->allow('user', 'article', 'edit', $assertion2); + + $acl->isAllowed($user, $article, 'view'); + Assert::same(1, $assertion1Calls); + Assert::same(0, $assertion2Calls); + + $acl->isAllowed($user, $article, 'edit'); + Assert::same(1, $assertion1Calls); + Assert::same(1, $assertion2Calls); +}); diff --git a/tests/Security/User.expiration.phpt b/tests/Security/User.expiration.phpt new file mode 100644 index 0000000..7438bd7 --- /dev/null +++ b/tests/Security/User.expiration.phpt @@ -0,0 +1,78 @@ +authenticated = true; + $this->identity = $identity; + $this->reason = null; + } + + + public function clearAuthentication(bool $clearIdentity): void + { + $this->authenticated = false; + $this->reason = User::LogoutManual; + if ($clearIdentity) { + $this->identity = null; + } + } + + + public function getState(): array + { + return [$this->authenticated, $this->identity, $this->reason]; + } + + + public function setExpiration(?string $time, bool $clearIdentity = false): void + { + $this->expireTime = $time; + $this->expireIdentity = $clearIdentity; + } +} + + +test('User delegates setExpiration to storage', function () { + $storage = new MockUserStorage; + $user = new User($storage); + + $user->setExpiration('30 minutes'); + Assert::same('30 minutes', $storage->expireTime); + Assert::false($storage->expireIdentity); + + $user->setExpiration(null); + Assert::null($storage->expireTime); +}); + + +test('User delegates setExpiration with clearIdentity flag', function () { + $storage = new MockUserStorage; + $user = new User($storage); + + $user->setExpiration('10 minutes', clearIdentity: true); + Assert::same('10 minutes', $storage->expireTime); + Assert::true($storage->expireIdentity); +}); diff --git a/tests/Security/User.identityHandler.phpt b/tests/Security/User.identityHandler.phpt new file mode 100644 index 0000000..712fc85 --- /dev/null +++ b/tests/Security/User.identityHandler.phpt @@ -0,0 +1,177 @@ + 'John Doe']); + } + } + + + public function sleepIdentity(IIdentity $identity): IIdentity + { + $this->sleepCalls[] = $identity; + // Simulate token-only storage (e.g., for cookies) + // Store only ID, not roles or data + return new SimpleIdentity($identity->getId()); + } + + + public function wakeupIdentity(IIdentity $identity): ?IIdentity + { + $this->wakeupCalls[] = $identity; + // Simulate refreshing roles from database + // Real implementation would fetch fresh data from DB + return new SimpleIdentity($identity->getId(), ['admin', 'user'], ['name' => 'John Doe Updated']); + } +} + + +test('IdentityHandler.sleepIdentity() is called on login', function () { + $handler = new AuthenticatorWithHandler; + $user = new Nette\Security\User(new MockUserStorage); + $user->setAuthenticator($handler); + + Assert::count(0, $handler->sleepCalls); + + $user->login('john', 'xxx'); + + // sleepIdentity should be called once + Assert::count(1, $handler->sleepCalls); + + // Original identity should have 'user' role + Assert::same(['user'], $handler->sleepCalls[0]->getRoles()); + Assert::same(['name' => 'John Doe'], $handler->sleepCalls[0]->getData()); +}); + + +test('IdentityHandler.wakeupIdentity() is called when accessing identity', function () { + $storage = new MockUserStorage; + $handler = new AuthenticatorWithHandler; + $user = new Nette\Security\User($storage); + $user->setAuthenticator($handler); + + $user->login('john', 'xxx'); + + // Reset counters + $handler->wakeupCalls = []; + + // Create new User instance with same storage to simulate new request + $user2 = new Nette\Security\User($storage); + $user2->setAuthenticator($handler); + + Assert::count(0, $handler->wakeupCalls); + + // Accessing identity should trigger wakeup + $identity = $user2->getIdentity(); + + Assert::count(1, $handler->wakeupCalls); + + // wakeupIdentity received the "slept" identity (ID only) + Assert::same('john', $handler->wakeupCalls[0]->getId()); + Assert::same([], $handler->wakeupCalls[0]->getRoles()); + + // But returned identity has updated roles from wakeup + Assert::same(['admin', 'user'], $identity->getRoles()); + Assert::same(['name' => 'John Doe Updated'], $identity->getData()); +}); + + +test('IdentityHandler.wakeupIdentity() returning null logs user out', function () { + $handler = new class implements Nette\Security\Authenticator, Nette\Security\IdentityHandler { + public function authenticate(string $username, string $password): IIdentity + { + return new SimpleIdentity('john', ['user']); + } + + + public function sleepIdentity(IIdentity $identity): IIdentity + { + return $identity; + } + + + public function wakeupIdentity(IIdentity $identity): ?IIdentity + { + // Simulate invalid token/expired session + return null; + } + }; + + $storage = new MockUserStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator($handler); + + $user->login('john', 'xxx'); + Assert::true($user->isLoggedIn()); + + // Create new User instance to trigger wakeup + $user2 = new Nette\Security\User($storage); + $user2->setAuthenticator($handler); + + // wakeupIdentity returns null → user should be logged out + Assert::false($user2->isLoggedIn()); + Assert::null($user2->getIdentity()); +}); + + +test('IdentityHandler is not called when authenticator does not implement it', function () { + $handler = new class implements Nette\Security\Authenticator { + public int $authCount = 0; + + + public function authenticate(string $username, string $password): IIdentity + { + $this->authCount++; + return new SimpleIdentity('john', ['user']); + } + }; + + $storage = new MockUserStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator($handler); + + $user->login('john', 'xxx'); + Assert::same(1, $handler->authCount); + + // Create new User instance - should just return stored identity without wakeup + $user2 = new Nette\Security\User($storage); + $user2->setAuthenticator($handler); + + $identity = $user2->getIdentity(); + Assert::same('john', $identity->getId()); + Assert::same(['user'], $identity->getRoles()); + + // authenticate should not be called again + Assert::same(1, $handler->authCount); +}); diff --git a/tests/Security/User.namespaces.phpt b/tests/Security/User.namespaces.phpt new file mode 100644 index 0000000..8bc1125 --- /dev/null +++ b/tests/Security/User.namespaces.phpt @@ -0,0 +1,292 @@ +namespaces[$this->currentNamespace] = [ + 'authenticated' => true, + 'identity' => $identity, + 'reason' => null, + ]; + } + + + public function clearAuthentication(bool $clearIdentity): void + { + if (isset($this->namespaces[$this->currentNamespace])) { + $this->namespaces[$this->currentNamespace]['authenticated'] = false; + $this->namespaces[$this->currentNamespace]['reason'] = Nette\Security\User::LogoutManual; + if ($clearIdentity) { + $this->namespaces[$this->currentNamespace]['identity'] = null; + } + } + } + + + public function getState(): array + { + $ns = $this->namespaces[$this->currentNamespace] ?? null; + if ($ns === null) { + return [false, null, null]; + } + return [$ns['authenticated'], $ns['identity'], $ns['reason']]; + } + + + public function setExpiration(?string $expire, bool $clearIdentity): void + { + } + + + public function setNamespace(string $namespace): self + { + $this->currentNamespace = $namespace; + return $this; + } + + + public function getNamespace(): string + { + return $this->currentNamespace; + } +} + + +class SimpleAuthenticator implements Nette\Security\Authenticator +{ + public function authenticate(string $username, string $password): IIdentity + { + return new SimpleIdentity($username, [$username === 'admin' ? 'admin' : 'user']); + } +} + + +test('Different namespaces have independent authentication states', function () { + $storage = new MockNamespacedStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + // Login to default namespace + $user->login('customer', 'xxx'); + Assert::true($user->isLoggedIn()); + Assert::same('customer', $user->getId()); + Assert::same(['user'], $user->getRoles()); + + // Switch to 'admin' namespace - should NOT be logged in + $storage->setNamespace('admin'); + $user->refreshStorage(); // Reload from new namespace + Assert::false($user->isLoggedIn()); + Assert::null($user->getIdentity()); + + // Login different user to admin namespace + $user->login('admin', 'xxx'); + Assert::true($user->isLoggedIn()); + Assert::same('admin', $user->getId()); + Assert::same(['admin'], $user->getRoles()); + + // Switch back to default namespace - customer should still be logged in + $storage->setNamespace(''); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same('customer', $user->getId()); + Assert::same(['user'], $user->getRoles()); + + // Switch to admin namespace - admin should still be logged in + $storage->setNamespace('admin'); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same('admin', $user->getId()); + Assert::same(['admin'], $user->getRoles()); +}); + + +test('Logout in one namespace does not affect other namespaces', function () { + $storage = new MockNamespacedStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + // Login to frontend + $storage->setNamespace('frontend'); + $user->login('customer', 'xxx'); + Assert::true($user->isLoggedIn()); + + // Login to backend + $storage->setNamespace('backend'); + $user->login('admin', 'xxx'); + Assert::true($user->isLoggedIn()); + + // Logout from backend + $user->logout(); + Assert::false($user->isLoggedIn()); + + // Frontend should still be logged in + $storage->setNamespace('frontend'); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same('customer', $user->getId()); +}); + + +test('Identity is preserved after logout when not clearing in one namespace', function () { + $storage = new MockNamespacedStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + // Login to namespace A + $storage->setNamespace('A'); + $user->login('john', 'xxx'); + Assert::true($user->isLoggedIn()); + + // Logout without clearing identity + $user->logout(false); + Assert::false($user->isLoggedIn()); + Assert::same('john', $user->getIdentity()->getId()); // Identity preserved + + // Switch to namespace B - should not have identity + $storage->setNamespace('B'); + $user->refreshStorage(); // Reload from new namespace + Assert::false($user->isLoggedIn()); + Assert::null($user->getIdentity()); + + // Back to A - identity should still be there + $storage->setNamespace('A'); + $user->refreshStorage(); // Reload from new namespace + Assert::false($user->isLoggedIn()); + Assert::same('john', $user->getIdentity()->getId()); +}); + + +test('Multiple namespaces can be used simultaneously', function () { + $storage = new MockNamespacedStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + // Create 3 different authentication contexts + $contexts = [ + 'frontend' => 'customer1', + 'backend' => 'admin', + 'api' => 'apiuser', + ]; + + foreach ($contexts as $namespace => $username) { + $storage->setNamespace($namespace); + $user->login($username, 'xxx'); + Assert::true($user->isLoggedIn()); + Assert::same($username, $user->getId()); + } + + // Verify all contexts are still independent + foreach ($contexts as $namespace => $username) { + $storage->setNamespace($namespace); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same($username, $user->getId()); + } + + // Logout from one + $storage->setNamespace('backend'); + $user->refreshStorage(); // Reload from new namespace + $user->logout(); + + // Verify others are unaffected + $storage->setNamespace('frontend'); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same('customer1', $user->getId()); + + $storage->setNamespace('api'); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same('apiuser', $user->getId()); + + $storage->setNamespace('backend'); + $user->refreshStorage(); // Reload from new namespace + Assert::false($user->isLoggedIn()); +}); + + +test('Empty namespace is valid and separate from other namespaces', function () { + $storage = new MockNamespacedStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + // Default (empty) namespace + $storage->setNamespace(''); + $user->login('user1', 'xxx'); + Assert::true($user->isLoggedIn()); + + // Named namespace + $storage->setNamespace('special'); + $user->refreshStorage(); // Reload from new namespace + Assert::false($user->isLoggedIn()); + $user->login('user2', 'xxx'); + Assert::true($user->isLoggedIn()); + Assert::same('user2', $user->getId()); + + // Back to empty namespace + $storage->setNamespace(''); + $user->refreshStorage(); // Reload from new namespace + Assert::true($user->isLoggedIn()); + Assert::same('user1', $user->getId()); +}); + + +test('Logout event fires only for actual logout, not namespace switch', function () { + $storage = new MockNamespacedStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $logoutCount = 0; + $user->onLoggedOut[] = function () use (&$logoutCount) { + $logoutCount++; + }; + + // Login to namespace A + $storage->setNamespace('A'); + $user->login('john', 'xxx'); + Assert::same(0, $logoutCount); + + // Actual logout in namespace A + $user->logout(); + Assert::same(1, $logoutCount); // First logout event + + // Login to namespace B + $storage->setNamespace('B'); + $user->refreshStorage(); // Reload from new namespace + $user->login('jane', 'xxx'); + Assert::same(1, $logoutCount); // No additional logout + + // Logout from B + $user->logout(); + Assert::same(2, $logoutCount); // Second logout event + + // Switch to A (nothing logged in there anymore) + $storage->setNamespace('A'); + $user->refreshStorage(); // Reload from new namespace + Assert::false($user->isLoggedIn()); + Assert::same(2, $logoutCount); // No logout event for checking status +}); diff --git a/tests/Security/User.refreshStorage.phpt b/tests/Security/User.refreshStorage.phpt new file mode 100644 index 0000000..ad8ce4d --- /dev/null +++ b/tests/Security/User.refreshStorage.phpt @@ -0,0 +1,306 @@ +authenticated = true; + $this->identity = $identity; + $this->reason = null; + } + + + public function clearAuthentication(bool $clearIdentity): void + { + $this->authenticated = false; + $this->reason = Nette\Security\User::LogoutManual; + if ($clearIdentity) { + $this->identity = null; + } + } + + + public function getState(): array + { + return [$this->authenticated, $this->identity, $this->reason]; + } + + + public function setExpiration(?string $expire, bool $clearIdentity): void + { + } + + + // Test helpers + public function externallyModifyIdentity(callable $modifier): void + { + $this->identity = $modifier($this->identity); + } + + + public function externallyModifyRoles(array $newRoles): void + { + if ($this->identity) { + $this->identity = new SimpleIdentity( + $this->identity->getId(), + $newRoles, + $this->identity->getData(), + ); + } + } + + + public function externallyLogout(): void + { + $this->authenticated = false; + $this->reason = Nette\Security\User::LogoutInactivity; + } +} + + +class SimpleAuthenticator implements Nette\Security\Authenticator +{ + public function authenticate(string $username, string $password): IIdentity + { + return new SimpleIdentity($username, ['user']); + } +} + + +test('refreshStorage() reloads identity from storage', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $user->login('john', 'xxx'); + $identity1 = $user->getIdentity(); + + Assert::same('john', $identity1->getId()); + Assert::same(['user'], $identity1->getRoles()); + + // Externally modify storage (e.g., another request updated roles in database) + $storage->externallyModifyRoles(['admin', 'user']); + + // Without refresh - still old cached identity + $identity2 = $user->getIdentity(); + Assert::same($identity1, $identity2); // Same object + Assert::same(['user'], $identity2->getRoles()); // Old roles + + // After refresh - new identity loaded + $user->refreshStorage(); + $identity3 = $user->getIdentity(); + + Assert::notSame($identity1, $identity3); // Different object + Assert::same('john', $identity3->getId()); + Assert::same(['admin', 'user'], $identity3->getRoles()); // New roles! +}); + + +test('refreshStorage() updates authentication state', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $user->login('john', 'xxx'); + Assert::true($user->isLoggedIn()); + + // External logout (e.g., session timeout in storage) + $storage->externallyLogout(); + + // Without refresh - still appears logged in (cached state) + Assert::true($user->isLoggedIn()); + + // After refresh - state updated + $user->refreshStorage(); + Assert::false($user->isLoggedIn()); + Assert::same(Nette\Security\User::LogoutInactivity, $user->getLogoutReason()); +}); + + +test('refreshStorage() reloads data from storage', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $user->login('john', 'xxx'); + + // Get identity - it's now cached + $identity1 = $user->getIdentity(); + Assert::notNull($identity1); + + // Externally modify storage + $storage->externallyModifyRoles(['admin']); + + // Without refresh - still old cached value + Assert::same(['user'], $user->getRoles()); + + // Refresh clears cache + $user->refreshStorage(); + + // Next access loads fresh from storage with new roles + Assert::same(['admin'], $user->getRoles()); +}); + + +test('refreshStorage() with IdentityHandler triggers wakeup again', function () { + $wakeupCount = 0; + + $handler = new class ($wakeupCount) implements Nette\Security\Authenticator, Nette\Security\IdentityHandler { + public function __construct( + private int &$wakeupCount, + ) { + } + + + public function authenticate(string $username, string $password): IIdentity + { + return new SimpleIdentity($username, ['user']); + } + + + public function sleepIdentity(IIdentity $identity): IIdentity + { + return $identity; + } + + + public function wakeupIdentity(IIdentity $identity): ?IIdentity + { + $this->wakeupCount++; + // Each wakeup adds a role + return new SimpleIdentity( + $identity->getId(), + array_merge($identity->getRoles(), ['role' . $this->wakeupCount]), + ); + } + }; + + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator($handler); + + $user->login('john', 'xxx'); + + // After login, identity is cached - no wakeup yet + $identity1 = $user->getIdentity(); + Assert::same(0, $wakeupCount); // No wakeup after fresh login + Assert::same(['user'], $identity1->getRoles()); + + // Refresh triggers wakeup on next access + $user->refreshStorage(); + $identity2 = $user->getIdentity(); + Assert::same(1, $wakeupCount); // First wakeup + Assert::same(['user', 'role1'], $identity2->getRoles()); + + // Another refresh and access - wakeup gets fresh identity from storage again + $user->refreshStorage(); + $identity3 = $user->getIdentity(); + Assert::same(2, $wakeupCount); // Second wakeup + // Note: wakeupIdentity receives the stored identity (just ['user']), not previous wakeup result + Assert::same(['user', 'role2'], $identity3->getRoles()); +}); + + +test('refreshStorage() does not affect storage data', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $user->login('john', 'xxx'); + + // Get state before refresh + [$auth1, $id1, $reason1] = $storage->getState(); + + $user->refreshStorage(); + + // Get state after refresh - should be unchanged + [$auth2, $id2, $reason2] = $storage->getState(); + + Assert::same($auth1, $auth2); + Assert::same($id1, $id2); + Assert::same($reason1, $reason2); +}); + + +test('refreshStorage() on logged out user works without error', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + + Assert::false($user->isLoggedIn()); + + // Should not throw + $user->refreshStorage(); + + Assert::false($user->isLoggedIn()); + Assert::null($user->getIdentity()); +}); + + +test('Multiple refreshStorage() calls work correctly', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $user->login('john', 'xxx'); + + for ($i = 0; $i < 5; $i++) { + $user->refreshStorage(); + Assert::true($user->isLoggedIn()); + Assert::same('john', $user->getId()); + } + + // Modify storage + $storage->externallyModifyRoles(['admin']); + + $user->refreshStorage(); + Assert::same(['admin'], $user->getIdentity()->getRoles()); +}); + + +test('refreshStorage() allows detecting external identity changes', function () { + $storage = new MutableStorage; + $user = new Nette\Security\User($storage); + $user->setAuthenticator(new SimpleAuthenticator); + + $user->login('john', 'xxx'); + $initialData = $user->getIdentity()->getData(); + + // Simulate another process updating user data in storage + $storage->externallyModifyIdentity(fn($identity) => new SimpleIdentity( + $identity->getId(), + $identity->getRoles(), + ['updated' => true, 'timestamp' => time()], + )); + + // Refresh to get updated data + $user->refreshStorage(); + $updatedData = $user->getIdentity()->getData(); + + Assert::notSame($initialData, $updatedData); + Assert::true($updatedData['updated']); + Assert::type('int', $updatedData['timestamp']); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0085239..dece1f8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,10 +12,5 @@ Tester\Environment::setup(); +Tester\Environment::setupFunctions(); date_default_timezone_set('Europe/Prague'); - - -function test(string $title, Closure $function): void -{ - $function(); -} From dd279d13734e8b9ea80ddea72bf6e79548d77f35 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 9 Jan 2026 10:55:50 +0100 Subject: [PATCH 04/18] improved phpDoc --- src/Bridges/SecurityDI/SecurityExtension.php | 2 +- src/Bridges/SecurityHttp/CookieStorage.php | 1 + src/Security/IAuthenticator.php | 2 +- src/Security/IIdentity.php | 5 ++-- src/Security/Identity.php | 17 +++++++++--- src/Security/Passwords.php | 2 +- src/Security/Permission.php | 28 +++++++++++++++++++- src/Security/SimpleAuthenticator.php | 8 +++--- src/Security/User.php | 9 ++++--- 9 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/Bridges/SecurityDI/SecurityExtension.php b/src/Bridges/SecurityDI/SecurityExtension.php index abd2cb7..c7df7e8 100644 --- a/src/Bridges/SecurityDI/SecurityExtension.php +++ b/src/Bridges/SecurityDI/SecurityExtension.php @@ -55,7 +55,7 @@ public function getConfigSchema(): Nette\Schema\Schema public function loadConfiguration(): void { - /** @var object{debugger: bool, users: array, roles: array, resources: array, authentication: \stdClass} $config */ + /** @var object{debugger: bool, users: array}>, roles: array, resources: array, authentication: \stdClass} $config */ $config = $this->config; $builder = $this->getContainerBuilder(); diff --git a/src/Bridges/SecurityHttp/CookieStorage.php b/src/Bridges/SecurityHttp/CookieStorage.php index 997145c..ecf10e7 100644 --- a/src/Bridges/SecurityHttp/CookieStorage.php +++ b/src/Bridges/SecurityHttp/CookieStorage.php @@ -86,6 +86,7 @@ public function setExpiration(?string $expire, bool $clearIdentity): void } + /** @param 'Lax'|'Strict'|'None'|null $sameSite */ public function setCookieParameters( ?string $name = null, ?string $domain = null, diff --git a/src/Security/IAuthenticator.php b/src/Security/IAuthenticator.php index 4d845ad..bf0ad2b 100644 --- a/src/Security/IAuthenticator.php +++ b/src/Security/IAuthenticator.php @@ -12,7 +12,7 @@ /** * @deprecated update to Nette\Security\Authenticator - * @method IIdentity authenticate(array $credentials) + * @method IIdentity authenticate(array{string, string} $credentials) */ interface IAuthenticator { diff --git a/src/Security/IIdentity.php b/src/Security/IIdentity.php index 862db78..e6356cc 100644 --- a/src/Security/IIdentity.php +++ b/src/Security/IIdentity.php @@ -12,18 +12,19 @@ /** * Represents the user of application. - * @method array getData() + * @method array getData() */ interface IIdentity { /** * Returns the ID of user. - * @return mixed + * @return string|int */ function getId(); /** * Returns a list of roles that the user is a member of. + * @return string[] */ function getRoles(): array; diff --git a/src/Security/Identity.php b/src/Security/Identity.php index 1257ed3..f0ae1ce 100644 --- a/src/Security/Identity.php +++ b/src/Security/Identity.php @@ -15,17 +15,25 @@ /** * @deprecated use Nette\Security\SimpleIdentity * @property string|int $id - * @property array $roles - * @property array $data + * @property string[] $roles + * @property array $data */ class Identity implements IIdentity { private string|int $id; + + /** @var string[] */ private array $roles; + + /** @var array */ private array $data; - public function __construct(string|int $id, $roles = null, ?iterable $data = null) + /** + * @param string|string[]|null $roles + * @param ?iterable $data + */ + public function __construct(string|int $id, string|array|null $roles = null, ?iterable $data = null) { $this->setId($id); $this->setRoles((array) $roles); @@ -56,6 +64,7 @@ public function getId(): string|int /** * Sets a list of roles that the user is a member of. + * @param string[] $roles */ public function setRoles(array $roles): static { @@ -66,6 +75,7 @@ public function setRoles(array $roles): static /** * Returns a list of roles that the user is a member of. + * @return string[] */ public function getRoles(): array { @@ -75,6 +85,7 @@ public function getRoles(): array /** * Returns a user data. + * @return array */ public function getData(): array { diff --git a/src/Security/Passwords.php b/src/Security/Passwords.php index 495da91..14056b5 100644 --- a/src/Security/Passwords.php +++ b/src/Security/Passwords.php @@ -20,10 +20,10 @@ class Passwords { /** * Chooses which secure algorithm is used for hashing and how to configure it. - * @see https://php.net/manual/en/password.constants.php */ public function __construct( private readonly string $algo = PASSWORD_DEFAULT, + /** @var array algorithm-specific options, see https://php.net/manual/en/password.constants.php */ private readonly array $options = [], ) { } diff --git a/src/Security/Permission.php b/src/Security/Permission.php index 6d53af3..cad78ab 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -20,10 +20,13 @@ */ class Permission implements Authorizator { + /** @var array, children: array}> */ private array $roles = []; + + /** @var array}> */ private array $resources = []; - /** Access Control List rules; whitelist (deny everything to all) by default */ + /** @var array Access Control List rules; whitelist (deny everything to all) by default */ private array $rules = [ 'allResources' => [ 'allRoles' => [ @@ -48,6 +51,7 @@ class Permission implements Authorizator /** * Adds a Role to the list. The most recently added parent * takes precedence over parents that were previously added. + * @param string|string[]|null $parents * @throws Nette\InvalidArgumentException * @throws Nette\InvalidStateException */ @@ -108,6 +112,7 @@ private function checkRole(string $role, bool $exists = true): void /** * Returns all Roles. + * @return list */ public function getRoles(): array { @@ -117,6 +122,7 @@ public function getRoles(): array /** * Returns existing Role's parents ordered by ascending priority. + * @return list */ public function getRoleParents(string $role): array { @@ -269,6 +275,7 @@ private function checkResource(string $resource, bool $exists = true): void /** * Returns all Resources. + * @return list */ public function getResources(): array { @@ -367,6 +374,10 @@ public function removeAllResources(): static /** * Allows one or more Roles access to [certain $privileges upon] the specified Resource(s). * If $assertion is provided, then it must return true in order for rule to apply. + * @param string|string[]|null $roles + * @param string|string[]|null $resources + * @param string|string[]|null $privileges + * @param callable(self, ?string, ?string, ?string): bool $assertion */ public function allow( string|array|null $roles = self::All, @@ -383,6 +394,10 @@ public function allow( /** * Denies one or more Roles access to [certain $privileges upon] the specified Resource(s). * If $assertion is provided, then it must return true in order for rule to apply. + * @param string|string[]|null $roles + * @param string|string[]|null $resources + * @param string|string[]|null $privileges + * @param callable(self, ?string, ?string, ?string): bool $assertion */ public function deny( string|array|null $roles = self::All, @@ -398,6 +413,9 @@ public function deny( /** * Removes "allow" permissions from the list in the context of the given Roles, Resources, and privileges. + * @param string|string[]|null $roles + * @param string|string[]|null $resources + * @param string|string[]|null $privileges */ public function removeAllow( string|array|null $roles = self::All, @@ -412,6 +430,9 @@ public function removeAllow( /** * Removes "deny" restrictions from the list in the context of the given Roles, Resources, and privileges. + * @param string|string[]|null $roles + * @param string|string[]|null $resources + * @param string|string[]|null $privileges */ public function removeDeny( string|array|null $roles = self::All, @@ -426,6 +447,10 @@ public function removeDeny( /** * Performs operations on Access Control List rules. + * @param string|string[]|null $roles + * @param string|string[]|null $resources + * @param string|string[]|null $privileges + * @param callable(self, ?string, ?string, ?string): bool $assertion * @throws Nette\InvalidStateException */ protected function setRule( @@ -713,6 +738,7 @@ private function getRuleType(?string $resource, ?string $role, ?string $privileg /** * Returns the rules associated with a Resource and a Role, or null if no such rules exist. * If the $create parameter is true, then a rule set is first created and then returned to the caller. + * @return array|null */ private function &getRules(?string $resource, ?string $role, bool $create = false): ?array { diff --git a/src/Security/SimpleAuthenticator.php b/src/Security/SimpleAuthenticator.php index eb5b2b2..e9f13a6 100644 --- a/src/Security/SimpleAuthenticator.php +++ b/src/Security/SimpleAuthenticator.php @@ -15,15 +15,13 @@ */ class SimpleAuthenticator implements Authenticator { - /** - * @param array $passwords list of pairs username => password - * @param array $roles list of pairs username => role[] - * @param array $data list of pairs username => mixed[] - */ public function __construct( + /** @var array */ #[\SensitiveParameter] private array $passwords, + /** @var array */ private array $roles = [], + /** @var array> */ private array $data = [], ) { } diff --git a/src/Security/User.php b/src/Security/User.php index 7fc535c..9dfd592 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -18,10 +18,10 @@ * User authentication and authorization. * * @property-read bool $loggedIn - * @property-read IIdentity $identity - * @property-read string|int $id - * @property-read array $roles - * @property-read int $logoutReason + * @property-read ?IIdentity $identity + * @property-read string|int|null $id + * @property-read string[] $roles + * @property-read ?int $logoutReason * @property IAuthenticator $authenticator * @property Authorizator $authorizator */ @@ -249,6 +249,7 @@ final public function getLogoutReason(): ?int /** * Returns a list of effective roles that a user has been granted. + * @return string[] */ public function getRoles(): array { From d72318482a7234e7f0fa4e7d6678f37d5fadaa7f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 9 Jan 2026 10:55:50 +0100 Subject: [PATCH 05/18] uses nette/phpstan-rules --- composer.json | 9 ++++++- tests/types/security-types.php | 43 +++++++++++++++++++++++++++++++++ tests/types/security-types.phpt | 9 +++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/types/security-types.php create mode 100644 tests/types/security-types.phpt diff --git a/composer.json b/composer.json index cc7a8d5..570486c 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,9 @@ "nette/http": "^3.2", "nette/tester": "^2.6", "tracy/tracy": "^2.9", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.1@stable", + "phpstan/extension-installer": "^1.4@stable", + "nette/phpstan-rules": "^1.0", "mockery/mockery": "^1.6@stable" }, "conflict": { @@ -45,5 +47,10 @@ "branch-alias": { "dev-master": "3.2-dev" } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } } } diff --git a/tests/types/security-types.php b/tests/types/security-types.php new file mode 100644 index 0000000..3aeb0e6 --- /dev/null +++ b/tests/types/security-types.php @@ -0,0 +1,43 @@ +', $acl->getRoles()); +} + + +function testPermissionGetRoleParents(Permission $acl): void +{ + $acl->addRole('admin'); + assertType('list', $acl->getRoleParents('admin')); +} + + +function testPermissionGetResources(Permission $acl): void +{ + assertType('list', $acl->getResources()); +} + + +function testIIdentityGetId(IIdentity $identity): void +{ + assertType('int|string', $identity->getId()); +} + + +function testUserGetId(User $user): void +{ + assertType('int|string|null', $user->getId()); +} diff --git a/tests/types/security-types.phpt b/tests/types/security-types.phpt new file mode 100644 index 0000000..5a167ee --- /dev/null +++ b/tests/types/security-types.phpt @@ -0,0 +1,9 @@ + Date: Fri, 9 Jan 2026 10:55:50 +0100 Subject: [PATCH 06/18] fixed PHPStan errors --- phpstan-baseline.neon | 19 +++++++++++++++++++ phpstan.neon | 11 ++++------- src/Bridges/SecurityDI/SecurityExtension.php | 4 +++- src/Bridges/SecurityHttp/SessionStorage.php | 7 +++---- src/Security/Identity.php | 14 +++++++------- src/Security/Passwords.php | 2 +- src/Security/Permission.php | 1 + src/Security/User.php | 4 ++-- 8 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 phpstan-baseline.neon diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..d692ddc --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,19 @@ +parameters: + ignoreErrors: + - + message: '#^Method Nette\\Http\\IResponse\:\:setCookie\(\) invoked with 8 parameters, 3\-7 required\.$#' + identifier: arguments.count + count: 1 + path: src/Bridges/SecurityHttp/CookieStorage.php + + - + message: '#^Instanceof between string and Nette\\Security\\Role will always evaluate to false\.$#' + identifier: instanceof.alwaysFalse + count: 1 + path: src/Security/User.php + + - + message: '#^Parameter \#1 \$credentials of method Nette\\Security\\IAuthenticator\:\:authenticate\(\) expects array\{string, string\}, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Security/User.php diff --git a/phpstan.neon b/phpstan.neon index f8dd9b6..5bf75af 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,14 +1,11 @@ parameters: - level: 5 + level: 8 paths: - src - treatPhpDocTypesAsCertain: false - - ignoreErrors: - - '#Variable \$this in isset\(\) always exists and is not nullable\.#' - + excludePaths: + - src/compatibility.php includes: - - vendor/phpstan/phpstan-nette/extension.neon + - phpstan-baseline.neon diff --git a/src/Bridges/SecurityDI/SecurityExtension.php b/src/Bridges/SecurityDI/SecurityExtension.php index c7df7e8..531c10f 100644 --- a/src/Bridges/SecurityDI/SecurityExtension.php +++ b/src/Bridges/SecurityDI/SecurityExtension.php @@ -136,7 +136,9 @@ public function beforeCompile(): void $this->debugMode && ($this->config->debugger ?? $builder->getByType(Tracy\Bar::class)) ) { - $builder->getDefinition($this->prefix('user'))->addSetup('@Tracy\Bar::addPanel', [ + $definition = $builder->getDefinition($this->prefix('user')); + assert($definition instanceof Nette\DI\Definitions\ServiceDefinition); + $definition->addSetup('@Tracy\Bar::addPanel', [ new Nette\DI\Definitions\Statement(Nette\Bridges\SecurityTracy\UserPanel::class), ]); } diff --git a/src/Bridges/SecurityHttp/SessionStorage.php b/src/Bridges/SecurityHttp/SessionStorage.php index 638fe83..701db38 100644 --- a/src/Bridges/SecurityHttp/SessionStorage.php +++ b/src/Bridges/SecurityHttp/SessionStorage.php @@ -66,9 +66,7 @@ public function clearAuthentication(bool $clearIdentity): void public function getState(): array { $section = $this->getSessionSection(); - return $section - ? [(bool) $section->get('authenticated'), $section->get('identity'), $section->get('reason')] - : [false, null, null]; + return [(bool) $section->get('authenticated'), $section->get('identity'), $section->get('reason')]; } @@ -85,6 +83,7 @@ public function setExpiration(?string $time, bool $clearIdentity = false): void private function setupExpiration(): void { + assert($this->sessionSection !== null); $section = $this->sessionSection; if ($this->expireTime) { $section->set('expireTime', $this->expireTime); @@ -124,7 +123,7 @@ public function getNamespace(): string /** * Returns and initializes $this->sessionSection. */ - private function getSessionSection(): ?SessionSection + private function getSessionSection(): SessionSection { if ($this->sessionSection !== null) { return $this->sessionSection; diff --git a/src/Security/Identity.php b/src/Security/Identity.php index f0ae1ce..223aadd 100644 --- a/src/Security/Identity.php +++ b/src/Security/Identity.php @@ -98,12 +98,12 @@ public function getData(): array */ public function __set(string $key, mixed $value): void { - if (in_array($key, ['id', 'roles', 'data'], strict: true)) { - $this->{"set$key"}($value); - - } else { - $this->data[$key] = $value; - } + match ($key) { + 'id' => $this->setId($value), + 'roles' => $this->setRoles($value), + 'data' => $this->data = $value, + default => $this->data[$key] = $value, + }; } @@ -113,7 +113,7 @@ public function __set(string $key, mixed $value): void public function &__get(string $key): mixed { if (in_array($key, ['id', 'roles', 'data'], strict: true)) { - $res = $this->{"get$key"}(); + $res = $this->{'get' . ucfirst($key)}(); return $res; } else { diff --git a/src/Security/Passwords.php b/src/Security/Passwords.php index 14056b5..c760760 100644 --- a/src/Security/Passwords.php +++ b/src/Security/Passwords.php @@ -43,7 +43,7 @@ public function hash( $hash = @password_hash($password, $this->algo, $this->options); // @ is escalated to exception if (!$hash) { - throw new Nette\InvalidStateException('Computed hash is invalid. ' . error_get_last()['message']); + throw new Nette\InvalidStateException('Computed hash is invalid. ' . (error_get_last()['message'] ?? '')); } return $hash; diff --git a/src/Security/Permission.php b/src/Security/Permission.php index cad78ab..4b5659a 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -621,6 +621,7 @@ public function isAllowed( break; } + assert(is_string($resource)); $resource = $this->resources[$resource]['parent']; // try next Resource } while (true); diff --git a/src/Security/User.php b/src/Security/User.php index 9dfd592..709b184 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -138,7 +138,7 @@ final public function isLoggedIn(): bool $this->getStoredData(); } - return $this->authenticated; + return (bool) $this->authenticated; } @@ -166,7 +166,7 @@ private function getStoredData(): void $this->identity = $identity && $this->authenticator instanceof IdentityHandler ? $this->authenticator->wakeupIdentity($identity) : $identity; - $this->authenticated = $this->authenticated && $this->identity; + $this->authenticated = $this->authenticated && $this->identity !== null; } From 5ce1774bc3f259edc8f51ee0863bc1155cf72882 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 23 Jan 2026 00:20:27 +0100 Subject: [PATCH 07/18] made static analysis mandatory --- .github/workflows/static-analysis.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index c23cc2f..3d934c9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,9 +1,6 @@ -name: Static Analysis (only informative) +name: Static Analysis -on: - push: - branches: - - master +on: [push, pull_request] jobs: phpstan: @@ -18,4 +15,3 @@ jobs: - run: composer install --no-progress --prefer-dist - run: composer phpstan - continue-on-error: true # is only informative From d7d82d6b6ec4cff118a180e6026433f6c932939d Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 29 Dec 2025 16:31:24 +0100 Subject: [PATCH 08/18] SessionStorage: don't extend expireTime when expired --- src/Bridges/SecurityHttp/SessionStorage.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bridges/SecurityHttp/SessionStorage.php b/src/Bridges/SecurityHttp/SessionStorage.php index 701db38..7cec3b1 100644 --- a/src/Bridges/SecurityHttp/SessionStorage.php +++ b/src/Bridges/SecurityHttp/SessionStorage.php @@ -142,9 +142,9 @@ private function getSessionSection(): SessionSection if ($section->get('expireIdentity')) { $section->remove('identity'); } + } else { + $section->set('expireTime', time() + $section->get('expireDelta')); // sliding expiration } - - $section->set('expireTime', time() + $section->get('expireDelta')); // sliding expiration } if (!$section->get('authenticated')) { From 74108f4edea95a4c95a05cb3edf226f0de908164 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 27 Dec 2025 22:08:06 +0100 Subject: [PATCH 09/18] added CLAUDE.md --- .gitattributes | 1 + CLAUDE.md | 562 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitattributes b/.gitattributes index e1bccc4..ed81035 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ .gitattributes export-ignore .github/ export-ignore .gitignore export-ignore +CLAUDE.md export-ignore ncs.* export-ignore phpstan*.neon export-ignore tests/ export-ignore diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a3044c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,562 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Nette Security is a standalone PHP library providing authentication, authorization, and role-based access control (ACL) for the Nette Framework ecosystem. + +- **Type**: Reusable PHP library/package +- **PHP Support**: 8.1 - 8.5 +- **Key Components**: Authentication (User login/logout), Authorization (Permission checking), ACL (Access Control Lists) +- **Documentation**: https://doc.nette.org/access-control + +## Essential Commands + +### Testing +```bash +# Run all tests +composer run tester + +# Run specific test file +vendor/bin/tester tests/Security/User.login.phpt -s + +# Run tests in specific directory +vendor/bin/tester tests/Security.DI/ -s + +# Single-threaded execution (useful for debugging) +vendor/bin/tester tests -s +``` + +### Code Quality +```bash +# Run PHPStan static analysis +composer run phpstan +``` + +## Architecture Overview + +### Three-Pillar Design + +The library separates security concerns into three independent domains: + +1. **Authentication** (`src/Security/User.php`, `Authenticator.php`) + - User identity verification (login/logout) + - Session management and expiration + - Credential validation through pluggable authenticators + +2. **Authorization** (`src/Security/Permission.php`, `Authorizator.php`) + - Role-based access control + - Resource and privilege management + - Dynamic permission assertions + +3. **Persistence** (`src/Bridges/SecurityHttp/`) + - Session-based storage (default) + - Cookie-based storage (alternative) + - Custom storage implementations via `UserStorage` interface + +### Interface-First Philosophy + +The codebase uses interface segregation for extensibility: + +- `Authenticator` → `SimpleAuthenticator` (example implementation) +- `Authorizator` → `Permission` (full ACL implementation) +- `UserStorage` → `SessionStorage`, `CookieStorage` +- `IIdentity` → `SimpleIdentity` + +**Key principle**: Developers implement interfaces, never extend concrete classes. + +### Directory Structure + +``` +src/ +├── Security/ # Core authentication/authorization +│ ├── User.php # Central user management (250 lines) +│ ├── Permission.php # Complete ACL implementation (600 lines) +│ ├── Passwords.php # Password hashing utilities +│ ├── Authenticator.php # Authentication contract +│ └── SimpleAuthenticator.php # Basic implementation for testing +│ +└── Bridges/ # Framework integration + ├── SecurityDI/ # Dependency injection container + ├── SecurityHttp/ # Session and cookie storage + └── SecurityTracy/ # Debugger panel +``` + +## Testing Conventions + +### Test File Format + +Tests use Nette Tester with `.phpt` extension: + +```php +isLoggedIn()); +}); +``` + +**Important**: Use `test()` function with descriptive title as first parameter. Do not add comments before `test()` calls. + +### Testing Exceptions + +Use `Assert::exception()` for expected exceptions: + +```php +Assert::exception( + fn() => $user->login('invalid', 'credentials'), + Nette\Security\AuthenticationException::class, + 'User not found.', +); +``` + +For entire test blocks that should throw, use `testException()`. + +### Test Organization + +- **Unit tests**: `tests/Security/` - Core functionality (User, Permission, Passwords) +- **Integration tests**: `tests/Security.DI/` - DI container integration +- **Storage tests**: `tests/Security.Http/` - Session/Cookie storage +- **ACL tests**: `tests/Security/Permission*.phpt` - 30+ comprehensive ACL scenarios + +## Coding Standards + +### General Rules + +- Every PHP file must include `declare(strict_types=1)` +- Use TABS for indentation (never spaces) +- Single quotes for strings unless containing apostrophes +- All code, comments, variables in English only +- Return type and opening brace on separate lines for methods +- No space before parentheses in arrow functions: `fn($a) => $b` + +### Type Declarations + +- All properties, parameters, and return values must have types +- Interface methods don't need visibility (always public) +- Use `#[\SensitiveParameter]` attribute for password parameters + +### Naming Conventions + +- PascalCase for classes, interfaces, constants +- camelCase for methods and properties +- Never use `Abstract`, `Interface`, or `I` prefixes + +### Documentation + +- Focus on describing purpose, not duplicating signature information +- Start method docs with 3rd person singular present tense verb +- Document array contents: `@return string[]` +- Use two spaces after `@param` and `@return` type declarations + +Example: +```php +/** + * Verifies user credentials against database. + * @return SimpleIdentity User identity with roles and metadata + * @throws AuthenticationException + */ +public function authenticate(string $username, string $password): IIdentity +{ + // Implementation +} +``` + +## Important Implementation Patterns + +### Identity Persistence After Logout + +**Critical behavior**: Logout does NOT delete identity by default. Identity remains available for personalization even when not authenticated. + +```php +$user->logout(); // Logs out but keeps identity +$user->logout(true); // Logs out AND clears identity +$user->getIdentity(); // Still available after logout() +$user->isLoggedIn(); // false after logout() + +// Check why user was logged out +$reason = $user->getLogoutReason(); +if ($reason === Nette\Security\UserStorage::LogoutInactivity) { + // User was logged out due to inactivity timeout +} elseif ($reason === Nette\Security\UserStorage::LogoutManual) { + // User was logged out manually via logout() +} +``` + +### IdentityHandler Interface + +Implement `IdentityHandler` to customize how identity is saved/restored from storage: + +```php +final class Authenticator implements + Nette\Security\Authenticator, + Nette\Security\IdentityHandler +{ + public function sleepIdentity(IIdentity $identity): IIdentity + { + // Called before identity is written to storage + // Useful for: replacing full identity with token-only proxy (for cookie storage) + return new SimpleIdentity($identity->authtoken); + } + + public function wakeupIdentity(IIdentity $identity): ?IIdentity + { + // Called after identity is read from storage + // Useful for: refreshing user roles from database, validating tokens + $userId = $identity->getId(); + $identity->setRoles($this->getUserRoles($userId)); + return $identity; // Return null to log user out + } +} +``` + +**Use cases:** +- Updating user roles on each request without re-login +- Cookie-based authentication with auth tokens +- Validating session integrity + +### Events: $onLoggedIn, $onLoggedOut + +User object provides events for login/logout lifecycle hooks: + +```php +$user->onLoggedIn[] = function (Nette\Security\User $user) { + // Log login event, update last_login timestamp, send notification, etc. +}; + +$user->onLoggedOut[] = function (Nette\Security\User $user) { + // Clear user-specific cache, log logout event, etc. +}; +``` + +### Role Inheritance and Weight + +When a role inherits from multiple parents with conflicting permissions, **last role has highest weight**: + +```php +$acl->addRole('john', ['admin', 'guest']); // 'guest' wins conflicts +$acl->addRole('mary', ['guest', 'admin']); // 'admin' wins conflicts +``` + +### Session Namespace for Multiple Authentications + +Support independent authentication contexts within single session. **Critical**: Set namespace in `checkRequirements()` of base presenter: + +```php +// In BasePresenter for admin module +public function checkRequirements($element): void +{ + $this->getUser()->getStorage()->setNamespace('backend'); + parent::checkRequirements($element); +} + +// In BasePresenter for frontend +public function checkRequirements($element): void +{ + $this->getUser()->getStorage()->setNamespace('frontend'); + parent::checkRequirements($element); +} +``` + +### Multiple Authenticators + +When using different authenticators for different parts of application, restrict autowiring with `autowired: self`: + +```neon +services: + - + create: FrontAuthenticator + autowired: self # Only autowire when explicitly requested + - + create: AdminAuthenticator + autowired: self +``` + +Then inject specific authenticator and set it before login: + +```php +class SignPresenter extends Nette\Application\UI\Presenter +{ + public function __construct( + private FrontAuthenticator $authenticator, + ) { + } + + protected function createComponentSignInForm(): Form + { + $form->onSuccess[] = function ($form, $data) { + $user = $this->getUser(); + $user->setAuthenticator($this->authenticator); + $user->login($data->username, $data->password); + }; + } +} +``` + +### Dynamic Permission Assertions + +Use callbacks for context-aware authorization: + +```php +$assertion = function (Permission $acl, string $role, string $resource, string $privilege): bool { + $role = $acl->getQueriedRole(); // Actual role object + $resource = $acl->getQueriedResource(); // Actual resource object + return $role->id === $resource->authorId; // Custom logic +}; + +$acl->allow('registered', 'article', 'edit', $assertion); +``` + +### AuthorizatorFactory Pattern + +Create Permission ACL as a DI service using factory method: + +```php +namespace App\Model; + +class AuthorizatorFactory +{ + public static function create(): Nette\Security\Permission + { + $acl = new Nette\Security\Permission; + + // Define roles + $acl->addRole('guest'); + $acl->addRole('registered', 'guest'); + $acl->addRole('admin', 'registered'); + + // Define resources + $acl->addResource('article'); + $acl->addResource('comment'); + + // Define permissions + $acl->allow('guest', ['article', 'comment'], 'view'); + $acl->allow('registered', 'comment', 'add'); + $acl->allow('admin', $acl::All, ['view', 'edit', 'add']); + + return $acl; + } +} +``` + +Register in configuration: + +```neon +services: + - App\Model\AuthorizatorFactory::create +``` + +### Password Hashing Best Practices + +The `Passwords` class handles secure password hashing with bcrypt: + +```php +// Hash contains algorithm identifier, cost, and salt - store all together +$hash = $passwords->hash($password); // Store this in database (255 chars recommended) + +// Verify password +if ($passwords->verify($password, $hash)) { + // Password correct +} + +// Upgrade hash when algorithm/cost changes +if ($passwords->needsRehash($hash)) { + $newHash = $passwords->hash($password); + // Update database with new hash +} +``` + +**Cost parameter** (higher = slower = more secure): +- Cost 10: ~80ms (default) +- Cost 11: ~160ms +- Cost 12: ~320ms (recommended for production) + +Configure in NEON: + +```neon +services: + security.passwords: Nette\Security\Passwords(::PASSWORD_BCRYPT, [cost: 12]) +``` + +## NEON Configuration + +### Simple Authentication (Testing Only) + +Define users directly in configuration using `SimpleAuthenticator`: + +```neon +security: + # Show user panel in Tracy Bar + debugger: true # (bool) defaults to true + + users: + # Simple format: username: password + johndoe: secret123 + + # Extended format with roles and data + janedoe: + password: secret123 + roles: [admin] + data: + name: Jane Doe + email: jane@example.com +``` + +### ACL Configuration + +Define roles and resources as configuration basis for `Permission`: + +```neon +security: + roles: + guest: + registered: [guest] # Inherits from guest + admin: [registered] # Inherits from registered + + resources: + article: + comment: [article] # Inherits from article + poll: +``` + +### User Storage Configuration + +```neon +security: + authentication: + # Period of inactivity before logout + expiration: 30 minutes + + # Storage type: session (default) or cookie + storage: session +``` + +### Cookie Storage Configuration + +When using `storage: cookie`, additional options are available: + +```neon +security: + authentication: + storage: cookie + cookieName: userId # (string) defaults to 'userid' + cookieDomain: 'example.com' # (string|domain) + cookieSamesite: Lax # (Strict|Lax|None) defaults to Lax +``` + +**Important**: Cookie storage requires implementing `IdentityHandler` to store only auth token (not full identity) in cookie. + +### DI Services + +These services are automatically registered in the DI container: + +| Service Name | Type | Description | +|---------------------------|------------------------------|----------------------------------| +| `security.authenticator` | `Authenticator` | Credential verification | +| `security.authorizator` | `Authorizator` | Permission checking (ACL) | +| `security.passwords` | `Passwords` | Password hashing utilities | +| `security.user` | `User` | Current user management | +| `security.userStorage` | `UserStorage` | Identity persistence layer | + +Access via dependency injection: + +```php +class MyService +{ + public function __construct( + private Nette\Security\User $user, + private Nette\Security\Passwords $passwords, + ) { + } +} +``` + +## Presenter Integration Patterns + +### Verify Login in Presenters + +Use `startup()` method to enforce authentication: + +```php +protected function startup() +{ + parent::startup(); + if (!$this->getUser()->isLoggedIn()) { + $this->redirect('Sign:in'); + } +} +``` + +### Verify Permissions in Presenters + +Use `startup()` method to enforce authorization: + +```php +protected function startup() +{ + parent::startup(); + if (!$this->getUser()->isAllowed('backend')) { + $this->error('Forbidden', 403); + } +} +``` + +**"Less Code, More Security" Principle**: When checking roles with `isInRole()`, you don't need to verify `isLoggedIn()` first. The method automatically works with effective roles: logged-in users get their assigned roles, logged-out users automatically get the special `guest` role. This reduces boilerplate while maintaining security: + +```php +// GOOD: Simple and secure +if ($user->isInRole('admin')) { + deleteItem(); +} + +// BAD: Unnecessary verbosity +if ($user->isLoggedIn() && $user->isInRole('admin')) { + deleteItem(); +} +``` + +### Set Session Namespace for Module + +Use `checkRequirements()` in BasePresenter: + +```php +public function checkRequirements($element): void +{ + $this->getUser()->getStorage()->setNamespace('backend'); + parent::checkRequirements($element); +} +``` + +## Common Pitfalls + +1. **Don't check isLoggedIn() before isInRole()** - `isInRole()` handles logged-out users with automatic 'guest' role +2. **Don't assume logout() clears identity** - Use `logout(true)` if you need to clear identity +3. **Remember ACL inheritance order** - Last parent role has highest weight in conflicts +4. **Session expiration must be ≤ session lifetime** - Set with `$user->setExpiration('30 minutes')` +5. **Password hash storage needs 255 chars** - Hash contains algorithm, cost, and salt; use VARCHAR(255) or TEXT +6. **Cookie storage requires IdentityHandler** - Never store full identity in cookie, only auth token +7. **Don't use SimpleAuthenticator in production** - It's for testing only; implement custom Authenticator with database +8. **Update hashes when algorithm changes** - Use `needsRehash()` to detect and upgrade old hashes on login + +## Commit Message Style + +- Lowercase, imperative mood +- No period at end +- Format: `component: change description` or direct action +- Examples: `User: support for custom authenticators`, `fixed session expiration handling` + +## CI/CD Pipeline + +GitHub Actions run on all pull requests: +- Tests across PHP 8.1, 8.2, 8.3, 8.4, 8.5 +- Code style checks (Nette Code Checker) +- Static analysis (PHPStan Level 5) +- Code coverage tracking From b2e4e1f163566b4cbcb383ca0a93f209d9dcf30c Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 1 Mar 2021 15:30:34 +0100 Subject: [PATCH 10/18] opened 4.0-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 570486c..0c6f74f 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-master": "4.0-dev" } }, "config": { From d55726d0f6238009a46771ff02b21919ab3ede81 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 30 Oct 2025 23:51:04 +0100 Subject: [PATCH 11/18] requires PHP 8.2 --- .github/workflows/static-analysis.yml | 2 +- .github/workflows/tests.yml | 6 +++--- CLAUDE.md | 4 ++-- composer.json | 2 +- readme.md | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 3d934c9..f0e8616 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none - run: composer install --no-progress --prefer-dist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f08ada3..e5a81f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + php: ['8.2', '8.3', '8.4', '8.5'] fail-fast: false @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none - run: composer install --no-progress --prefer-dist diff --git a/CLAUDE.md b/CLAUDE.md index a3044c2..e73d003 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Nette Security is a standalone PHP library providing authentication, authorization, and role-based access control (ACL) for the Nette Framework ecosystem. - **Type**: Reusable PHP library/package -- **PHP Support**: 8.1 - 8.5 +- **PHP Support**: 8.2 - 8.5 - **Key Components**: Authentication (User login/logout), Authorization (Permission checking), ACL (Access Control Lists) - **Documentation**: https://doc.nette.org/access-control @@ -556,7 +556,7 @@ public function checkRequirements($element): void ## CI/CD Pipeline GitHub Actions run on all pull requests: -- Tests across PHP 8.1, 8.2, 8.3, 8.4, 8.5 +- Tests across PHP 8.2, 8.3, 8.4, 8.5 - Code style checks (Nette Code Checker) - Static analysis (PHPStan Level 5) - Code coverage tracking diff --git a/composer.json b/composer.json index 0c6f74f..c734a6f 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "8.1 - 8.5", + "php": "8.2 - 8.5", "nette/utils": "^4.0" }, "require-dev": { diff --git a/readme.md b/readme.md index 45be02f..37e6496 100644 --- a/readme.md +++ b/readme.md @@ -20,7 +20,7 @@ Authentication & Authorization library for Nette. Documentation can be found on the [website](https://doc.nette.org/access-control). -It requires PHP version 8.1 and supports PHP up to 8.5. +It requires PHP version 8.2 and supports PHP up to 8.5. [Support Me](https://github.com/sponsors/dg) From 48aa99706fc887c5f0c17b27ba4d087aa71de76f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 11 Dec 2023 13:59:38 +0100 Subject: [PATCH 12/18] uses nette/http 4 --- composer.json | 4 ++-- src/Bridges/SecurityHttp/CookieStorage.php | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index c734a6f..74e3cca 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ }, "require-dev": { "nette/di": "^3.1", - "nette/http": "^3.2", + "nette/http": "^4.0", "nette/tester": "^2.6", "tracy/tracy": "^2.9", "phpstan/phpstan": "^2.1@stable", @@ -30,7 +30,7 @@ }, "conflict": { "nette/di": "<3.0-stable", - "nette/http": "<3.1.3" + "nette/http": "<4" }, "autoload": { "classmap": ["src/"], diff --git a/src/Bridges/SecurityHttp/CookieStorage.php b/src/Bridges/SecurityHttp/CookieStorage.php index ecf10e7..e3a267f 100644 --- a/src/Bridges/SecurityHttp/CookieStorage.php +++ b/src/Bridges/SecurityHttp/CookieStorage.php @@ -47,11 +47,8 @@ public function saveAuthentication(IIdentity $identity): void $this->cookieName, $uid, $this->cookieExpiration, - null, - $this->cookieDomain, - null, - true, - $this->cookieSameSite, + domain: $this->cookieDomain, + sameSite: $this->cookieSameSite, ); } @@ -61,8 +58,7 @@ public function clearAuthentication(bool $clearIdentity): void $this->uid = ''; $this->response->deleteCookie( $this->cookieName, - null, - $this->cookieDomain, + domain: $this->cookieDomain, ); } From 4d5679d251f4f259278faa0046d13f34c34c2f78 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 23 Dec 2025 22:50:45 +0100 Subject: [PATCH 13/18] used PHP 8.2 features --- src/Security/Identity.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Security/Identity.php b/src/Security/Identity.php index 223aadd..ba20bb2 100644 --- a/src/Security/Identity.php +++ b/src/Security/Identity.php @@ -37,9 +37,7 @@ public function __construct(string|int $id, string|array|null $roles = null, ?it { $this->setId($id); $this->setRoles((array) $roles); - $this->data = $data instanceof \Traversable - ? iterator_to_array($data) - : (array) $data; + $this->data = iterator_to_array($data ?? []); } From 2adeb1029fbb23ef55a900ae7db6c4433568a2ee Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 20 Sep 2021 18:39:13 +0200 Subject: [PATCH 14/18] removed deprecated IAuthenticator (BC break) --- src/Bridges/SecurityDI/SecurityExtension.php | 2 +- src/Security/Authenticator.php | 2 +- src/Security/IAuthenticator.php | 37 -------------------- src/Security/User.php | 14 ++++---- 4 files changed, 8 insertions(+), 47 deletions(-) delete mode 100644 src/Security/IAuthenticator.php diff --git a/src/Bridges/SecurityDI/SecurityExtension.php b/src/Bridges/SecurityDI/SecurityExtension.php index 531c10f..8771bb0 100644 --- a/src/Bridges/SecurityDI/SecurityExtension.php +++ b/src/Bridges/SecurityDI/SecurityExtension.php @@ -95,7 +95,7 @@ public function loadConfiguration(): void } $builder->addDefinition($this->prefix('authenticator')) - ->setType(Nette\Security\IAuthenticator::class) + ->setType(Nette\Security\Authenticator::class) ->setFactory(Nette\Security\SimpleAuthenticator::class, [$usersList, $usersRoles, $usersData]); if ($this->name === 'security') { diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index 500331d..28ba2b2 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -13,7 +13,7 @@ /** * Performs authentication. */ -interface Authenticator extends IAuthenticator +interface Authenticator { /** Exception error code */ public const diff --git a/src/Security/IAuthenticator.php b/src/Security/IAuthenticator.php deleted file mode 100644 index bf0ad2b..0000000 --- a/src/Security/IAuthenticator.php +++ /dev/null @@ -1,37 +0,0 @@ -identity = $username; } else { $authenticator = $this->getAuthenticator(); - $this->identity = $authenticator instanceof Authenticator - ? $authenticator->authenticate(...func_get_args()) - : $authenticator->authenticate(func_get_args()); + $this->identity = $authenticator->authenticate(...func_get_args()); } $id = $this->authenticator instanceof IdentityHandler @@ -189,7 +187,7 @@ final public function refreshStorage(): void /** * Sets authentication handler. */ - public function setAuthenticator(IAuthenticator $handler): static + public function setAuthenticator(Authenticator $handler): static { $this->authenticator = $handler; return $this; @@ -199,7 +197,7 @@ public function setAuthenticator(IAuthenticator $handler): static /** * Returns authentication handler. */ - final public function getAuthenticator(): IAuthenticator + final public function getAuthenticator(): Authenticator { if (!$this->authenticator) { throw new Nette\InvalidStateException('Authenticator has not been set.'); @@ -212,7 +210,7 @@ final public function getAuthenticator(): IAuthenticator /** * Returns authentication handler. */ - final public function getAuthenticatorIfExists(): ?IAuthenticator + final public function getAuthenticatorIfExists(): ?Authenticator { return $this->authenticator; } From da84c3fff6a26feec3b04a897923330b4922fed3 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 12 Dec 2021 18:12:59 +0100 Subject: [PATCH 15/18] IIdentity: added return typehint (BC break) --- src/Security/IIdentity.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Security/IIdentity.php b/src/Security/IIdentity.php index e6356cc..f881229 100644 --- a/src/Security/IIdentity.php +++ b/src/Security/IIdentity.php @@ -18,9 +18,8 @@ interface IIdentity { /** * Returns the ID of user. - * @return string|int */ - function getId(); + function getId(): string|int; /** * Returns a list of roles that the user is a member of. From 3f3440e4643b1d1c02221fbf8e8de38fbf2e846e Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 12 Feb 2026 11:32:44 +0100 Subject: [PATCH 16/18] Identity -> SimpleIdentity Identity class must exist as an alias in order to read it from session --- src/Security/Identity.php | 127 -------------------------------- src/Security/SimpleIdentity.php | 109 ++++++++++++++++++++++++++- src/compatibility-intf.php | 37 ++++++++++ src/compatibility.php | 26 +------ tests/Security/Identity.phpt | 36 --------- 5 files changed, 149 insertions(+), 186 deletions(-) delete mode 100644 src/Security/Identity.php create mode 100644 src/compatibility-intf.php delete mode 100644 tests/Security/Identity.phpt diff --git a/src/Security/Identity.php b/src/Security/Identity.php deleted file mode 100644 index ba20bb2..0000000 --- a/src/Security/Identity.php +++ /dev/null @@ -1,127 +0,0 @@ - $data - */ -class Identity implements IIdentity -{ - private string|int $id; - - /** @var string[] */ - private array $roles; - - /** @var array */ - private array $data; - - - /** - * @param string|string[]|null $roles - * @param ?iterable $data - */ - public function __construct(string|int $id, string|array|null $roles = null, ?iterable $data = null) - { - $this->setId($id); - $this->setRoles((array) $roles); - $this->data = iterator_to_array($data ?? []); - } - - - /** - * Sets the ID of user. - */ - public function setId(string|int $id): static - { - $this->id = is_numeric($id) && !is_float($tmp = $id * 1) ? $tmp : $id; - return $this; - } - - - /** - * Returns the ID of user. - */ - public function getId(): string|int - { - return $this->id; - } - - - /** - * Sets a list of roles that the user is a member of. - * @param string[] $roles - */ - public function setRoles(array $roles): static - { - $this->roles = $roles; - return $this; - } - - - /** - * Returns a list of roles that the user is a member of. - * @return string[] - */ - public function getRoles(): array - { - return $this->roles; - } - - - /** - * Returns a user data. - * @return array - */ - public function getData(): array - { - return $this->data; - } - - - /** - * Sets user data value. - */ - public function __set(string $key, mixed $value): void - { - match ($key) { - 'id' => $this->setId($value), - 'roles' => $this->setRoles($value), - 'data' => $this->data = $value, - default => $this->data[$key] = $value, - }; - } - - - /** - * Returns user data value. - */ - public function &__get(string $key): mixed - { - if (in_array($key, ['id', 'roles', 'data'], strict: true)) { - $res = $this->{'get' . ucfirst($key)}(); - return $res; - - } else { - return $this->data[$key]; - } - } - - - public function __isset(string $key): bool - { - return isset($this->data[$key]) || in_array($key, ['id', 'roles', 'data'], strict: true); - } -} diff --git a/src/Security/SimpleIdentity.php b/src/Security/SimpleIdentity.php index 279aadd..e9896a7 100644 --- a/src/Security/SimpleIdentity.php +++ b/src/Security/SimpleIdentity.php @@ -12,7 +12,114 @@ /** * Default implementation of IIdentity. + * @property string|int $id + * @property string[] $roles + * @property array $data */ -class SimpleIdentity extends Identity +class SimpleIdentity implements IIdentity { + private string|int $id; + + /** @var string[] */ + private array $roles; + + /** @var array */ + private array $data; + + + /** + * @param string|string[]|null $roles + * @param ?iterable $data + */ + public function __construct(string|int $id, string|array|null $roles = null, ?iterable $data = null) + { + $this->setId($id); + $this->setRoles((array) $roles); + $this->data = iterator_to_array($data ?? []); + } + + + /** + * Sets the ID of user. + */ + public function setId(string|int $id): static + { + $this->id = is_numeric($id) && !is_float($tmp = $id * 1) ? $tmp : $id; + return $this; + } + + + /** + * Returns the ID of user. + */ + public function getId(): string|int + { + return $this->id; + } + + + /** + * Sets a list of roles that the user is a member of. + * @param string[] $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + return $this; + } + + + /** + * Returns a list of roles that the user is a member of. + * @return string[] + */ + public function getRoles(): array + { + return $this->roles; + } + + + /** + * Returns a user data. + * @return array + */ + public function getData(): array + { + return $this->data; + } + + + /** + * Sets user data value. + */ + public function __set(string $key, mixed $value): void + { + match ($key) { + 'id' => $this->setId($value), + 'roles' => $this->setRoles($value), + 'data' => $this->data = $value, + default => $this->data[$key] = $value, + }; + } + + + /** + * Returns user data value. + */ + public function &__get(string $key): mixed + { + if (in_array($key, ['id', 'roles', 'data'], strict: true)) { + $res = $this->{'get' . ucfirst($key)}(); + return $res; + + } else { + return $this->data[$key]; + } + } + + + public function __isset(string $key): bool + { + return isset($this->data[$key]) || in_array($key, ['id', 'roles', 'data'], strict: true); + } } diff --git a/src/compatibility-intf.php b/src/compatibility-intf.php new file mode 100644 index 0000000..ebfd19d --- /dev/null +++ b/src/compatibility-intf.php @@ -0,0 +1,37 @@ + 'John']); - - Assert::same(12, $id->getId()); - Assert::same(12, $id->id); - Assert::same(['admin'], $id->getRoles()); - Assert::same(['admin'], $id->roles); - Assert::same(['name' => 'John'], $id->getData()); - Assert::same(['name' => 'John'], $id->data); - Assert::same('John', $id->name); -}); - - -test('', function () { - $id = new Identity('12'); - Assert::same(12, $id->getId()); - - - $id = new Identity('12345678901234567890'); - Assert::same('12345678901234567890', $id->getId()); -}); From eb0d79591ba3cc660d715a62d210515f929762ea Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 20 Sep 2021 18:29:35 +0200 Subject: [PATCH 17/18] SimpleIdentity: uses __serialize & __unserialize --- src/Security/SimpleIdentity.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Security/SimpleIdentity.php b/src/Security/SimpleIdentity.php index e9896a7..0f6d59e 100644 --- a/src/Security/SimpleIdentity.php +++ b/src/Security/SimpleIdentity.php @@ -122,4 +122,22 @@ public function __isset(string $key): bool { return isset($this->data[$key]) || in_array($key, ['id', 'roles', 'data'], strict: true); } + + + public function __serialize(): array + { + return [ + 'id' => $this->id, + 'roles' => $this->roles, + 'data' => $this->data, + ]; + } + + + public function __unserialize(array $data): void + { + $this->id = $data['id'] ?? $data["\00Nette\\Security\\Identity\00id"] ?? 0; + $this->roles = $data['roles'] ?? $data["\00Nette\\Security\\Identity\00roles"] ?? []; + $this->data = $data['data'] ?? $data["\00Nette\\Security\\Identity\00data"] ?? []; + } } From e7e0e5c3b1031869fc8741d0f44662a5e50f5485 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 24 Sep 2021 14:12:52 +0200 Subject: [PATCH 18/18] User: deprecated magic properties (BC break) --- src/Security/User.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Security/User.php b/src/Security/User.php index 1eee94c..f6de8bb 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -19,11 +19,11 @@ * * @property-read bool $loggedIn * @property-read ?IIdentity $identity - * @property-read string|int|null $id - * @property-read string[] $roles - * @property-read ?int $logoutReason - * @property Authenticator $authenticator - * @property Authorizator $authorizator + * @property-deprecated string|int|null $id + * @property-deprecated string[] $roles + * @property-deprecated ?int $logoutReason + * @property-deprecated Authenticator $authenticator + * @property-deprecated Authorizator $authorizator */ class User {