diff --git a/.gitattributes b/.gitattributes index f6cd3871..433a2de9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,10 @@ -.gitattributes export-ignore -.gitignore export-ignore -.travis.yml export-ignore -tests/ export-ignore -*.sh eol=lf +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +ncs.* export-ignore +phpstan*.neon export-ignore +src/**/*.latte export-ignore +tests/ export-ignore + +*.php* diff=php +*.sh text eol=lf diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml new file mode 100644 index 00000000..f553ca17 --- /dev/null +++ b/.github/workflows/coding-style.yml @@ -0,0 +1,31 @@ +name: Coding Style + +on: [push, pull_request] + +jobs: + nette_cc: + name: Nette Code Checker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - run: composer create-project nette/code-checker temp/code-checker ^3 --no-progress + - run: php temp/code-checker/code-checker --strict-types + + + nette_cs: + name: Nette Coding Standard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - run: composer create-project nette/coding-standard temp/coding-standard ^3 --no-progress + - run: php temp/coding-standard/ecs check diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 00000000..1d8d0645 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,17 @@ +name: Static Analysis + +on: [push, pull_request] + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer phpstan diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..686f085b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,61 @@ +name: Tests + +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.3', '8.4', '8.5'] + + fail-fast: false + + name: PHP ${{ matrix.php }} tests + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer tester + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: output-${{ matrix.php }} + path: tests/**/output + + + lowest_dependencies: + name: Lowest Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable + - run: composer tester + + + code_coverage: + name: Code Coverage + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - run: composer install --no-progress --prefer-dist + - 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 }} + run: php php-coveralls.phar --verbose --config tests/.coveralls.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d93f0eea..00000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: php -php: - - 5.3.3 - - 5.4 - - 5.5 - - 5.6 - - hhvm - -matrix: - allow_failures: - - php: hhvm - -script: - - vendor/bin/tester tests -s - - php code-checker/src/code-checker.php -d src - -after_failure: - # Print *.actual content - - for i in $(find tests -name \*.actual); do echo "--- $i"; cat $i; echo; echo; done - -before_script: - # Install Nette Tester & Code Checker - - composer install --no-interaction --dev --prefer-source - - composer create-project nette/code-checker code-checker ~2.2 --no-interaction --prefer-source diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..594c3963 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,580 @@ +# 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.3 - 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() +} +``` + +To always discard the identity on logout or expiration, set `$user->persistIdentity = false` (or the `persistIdentity` config option). Retaining the identity is best-effort and depends on the storage implementation. + +### 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 + +### Guest Identity + +An `IdentityHandler` may optionally implement `getGuestIdentity(): ?IIdentity` to provide an identity for anonymous (not logged-in) users. When present, `getIdentity()`, `getId()` and `getRoles()` fall back to it, so guests can carry their own roles and data instead of just the `$guestRole` string. + +```php +public function getGuestIdentity(): ?IIdentity +{ + return new SimpleIdentity('guest', ['guest'], ['name' => 'Guest']); +} +``` + +The guest identity is resolved on read only and is never written to storage. While not logged in, `getRoles()` (and thus `isAllowed()`/`isInRole()`) uses the guest roles. + +### 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 + + # Keep identity available after logout/expiration for personalization (defaults to true) + persistIdentity: true +``` + +### 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.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 4547e66a..84842ec0 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,56 @@ { "name": "nette/security", - "description": "Nette Security: Access Control Component", - "homepage": "http://nette.org", - "license": ["BSD-3-Clause", "GPL-2.0", "GPL-3.0"], + "description": "🔑 Nette Security: provides authentication, authorization and a role-based access control management via ACL (Access Control List)", + "keywords": ["nette", "authentication", "authorization", "ACL"], + "homepage": "https://nette.org", + "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], "authors": [ { "name": "David Grudl", - "homepage": "http://davidgrudl.com" + "homepage": "https://davidgrudl.com" }, { "name": "Nette Community", - "homepage": "http://nette.org/contributors" + "homepage": "https://nette.org/contributors" } ], "require": { - "php": ">=5.3.1", - "nette/utils": "~2.2" + "php": "8.3 - 8.5", + "nette/utils": "^4.0" }, "require-dev": { - "nette/tester": "~1.0" + "nette/di": "^3.1", + "nette/http": "^4.0", + "nette/tester": "^2.6", + "tracy/tracy": "^2.9", + "phpstan/phpstan": "^2.1@stable", + "phpstan/extension-installer": "^1.4@stable", + "nette/phpstan-rules": "^1.0", + "mockery/mockery": "^1.6@stable" }, "conflict": { - "nette/nette": "<2.2" + "nette/di": "<3.0-stable", + "nette/http": "<4" }, "autoload": { - "classmap": ["src/"] + "classmap": ["src/"], + "psr-4": { + "Nette\\": "src" + } }, "minimum-stability": "dev", + "scripts": { + "phpstan": "phpstan analyse", + "tester": "tester tests -s" + }, "extra": { "branch-alias": { - "dev-master": "2.3-dev" + "dev-master": "4.0-dev" + } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true } } } diff --git a/license.md b/license.md index af571d59..a955f5d3 100644 --- a/license.md +++ b/license.md @@ -21,7 +21,7 @@ If your stuff is good, it will not take long to establish a reputation for yours New BSD License --------------- -Copyright (c) 2004, 2014 David Grudl (http://davidgrudl.com) +Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) All rights reserved. Redistribution and use in source and binary forms, with or without modification, @@ -56,5 +56,5 @@ GNU General Public License GPL licenses are very very long, so instead of including them here we offer you URLs with full text: -- [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) -- [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) +- [GPL version 2](https://www.gnu.org/licenses/gpl-2.0.html) +- [GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html) diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..d957064d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,35 @@ +parameters: + level: 8 + + paths: + - src + + excludePaths: + - src/compatibility.php + + fileExtensions: + - php + - phtml + + ignoreErrors: + - # generated Latte code: attribute interpolation emits a dead `=== null` check for values that are never null + identifier: identical.alwaysFalse + count: 2 + path: src/Bridges/SecurityTracy/dist/tab.phtml + - + 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/readme.md b/readme.md index f444d1c7..9853eab9 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,15 @@ Nette Security: Access Control ============================== [![Downloads this Month](https://img.shields.io/packagist/dm/nette/security.svg)](https://packagist.org/packages/nette/security) -[![Build Status](https://travis-ci.org/nette/security.svg?branch=master)](https://travis-ci.org/nette/security) +[![Tests](https://github.com/nette/security/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/security/actions) +[![Coverage Status](https://coveralls.io/repos/github/nette/security/badge.svg?branch=master)](https://coveralls.io/github/nette/security?branch=master) +[![Latest Stable Version](https://poser.pugx.org/nette/security/v/stable)](https://github.com/nette/security/releases) +[![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/security/blob/master/license.md) + +Introduction +============ + +Authentication & Authorization library for Nette. - user login and logout - verifying user privileges @@ -10,165 +18,193 @@ Nette Security: Access Control - how to create custom authenticators and authorizators - Access Control List +Documentation can be found on the [website](https://doc.nette.org/access-control). + +It requires PHP version 8.3 and supports PHP up to 8.5. + + +[Support Me](https://github.com/sponsors/dg) +-------------------------------------------- + +Do you like Nette Security? Are you looking forward to the new features? + +[![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) + +Thank you! + Authentication ============== -Authentication means **user login**, ie. the process during which user's identity is verified. User usually identifies himself using username and password. - -Logging user in with username and password: +Authentication means **user login**, ie. the process during which a user's identity is verified. The user usually identifies himself using username and password. Verification is performed by the so-called [authenticator](#Authenticator). If the login fails, it throws `Nette\Security\AuthenticationException`. ```php -$user->login($username, $password); +try { + $user->login($username, $password); +} catch (Nette\Security\AuthenticationException $e) { + $this->flashMessage('The username or password you entered is incorrect.'); +} ``` -Checking if user is logged in: +Logging him out: ```php -echo $user->isLoggedIn() ? 'yes' : 'no'; +$user->logout(); ``` -And logging him out: +And checking if user is logged in: ```php -$user->logout(); +echo $user->isLoggedIn() ? 'yes' : 'no'; ``` -Simple, right? - -.[note] -Logging in requires users to have cookies enabled - other methods are not safe! +Simple, right? And all security aspects are handled by Nette for you. -Besides logging the user out with the `logout()` method, it can be done automatically based on specified time interval or closing the browser window. For this configuration we have to call `setExpiration()` during the login process. As an argument, it takes a relative time in seconds, UNIX timestamp, or textual representation of time. The second argument specifies whether the user should be logged out when the browser is closed. +You can also set the time interval after which the user logs off (otherwise he logs off with session expiration). This is done by the method `setExpiration()`, which is called before `login()`. Specify a string with relative time as a parameter: ```php -// login expires after 30 minutes of inactivity or after closing browser -$user->setExpiration('30 minutes', TRUE); - -// login expires after two days of inactivity -$user->setExpiration('2 days', FALSE); +// login expires after 30 minutes of inactivity +$user->setExpiration('30 minutes'); -// login expires when a browser is closed, but not sooner (ie. without a time limit) -$user->setExpiration(0, TRUE); +// cancel expiration +$user->setExpiration(null); ``` -.[note] -Expiration must be set to value equal or lower than the expiration of [sessions]. +Expiration must be set to value equal or lower than the expiration of sessions. -The reason of last logout can be obtained by method `$user->getLogoutReason()`, which returns one of these constants: `IUserStorage::INACTIVITY` if time expired, `IUserStorage::BROWSER_CLOSED` when user has closed the browser or `IUserStorage::MANUAL` when the `logout()` method was called. +The reason of the last logout can be obtained by method `$user->getLogoutReason()`, which returns either the constant `Nette\Security\User::LogoutInactivity` if the time expired or `User::LogoutManual` when the `logout()` method was called. -To make the example above work, we in fact have to create an object that verifies user's name and password. It's called **authenticator**. Its trivial implementation is the class [api:Nette\Security\SimpleAuthenticator], which in its constructor accepts an associative array: +In presenters, you can verify login in the `startup()` method: ```php -$authenticator = new Nette\Security\SimpleAuthenticator(array( - 'john' => 'IJ^%4dfh54*', - 'kathy' => '12345', // Kathy, this is a very weak password! -)); -$user->setAuthenticator($authenticator); +protected function startup() +{ + parent::startup(); + if (!$this->getUser()->isLoggedIn()) { + $this->redirect('Sign:in'); + } +} ``` -If the login credentials are not valid, authenticator throws an [api:Nette\Security\AuthenticationException]: - -```php -try { - // we try to log the user in - $user->login($username, $password); - // ... and redirect upon success - $this->redirect(...); -} catch (Nette\Security\AuthenticationException $e) { - echo 'Login error: ', $e->getMessage(); -} -``` +Authenticator +------------- -We usually configure authenticator inside a [config file |configuring], which only creates the object if it's requested by the application. The example above would be set in `config.neon` as follows: +It is an object that verifies the login data, ie usually the name and password. The trivial implementation is the class [Nette\Security\SimpleAuthenticator](https://api.nette.org/3.0/Nette/Security/SimpleAuthenticator.html), which can be defined this way +```php +$authenticator = new Nette\Security\SimpleAuthenticator([ + # name => password + 'johndoe' => 'secret123', + 'kathy' => 'evenmoresecretpassword', +]); ``` -common: - services: - authenticator: Nette\Security\SimpleAuthenticator([ - john: IJ^%4dfh54* - kathy: 12345 - ]) -``` - -Custom authenticator --------------------- +This solution is more suitable for testing purposes. We will show you how to create an authenticator that will verify credentials against a database table. -We will create a custom authenticator that will check validity of login credentials against a database table. Every authenticator must be an implementation of [api:Nette\Security\IAuthenticator], with its only method `authenticate()`. Its only purpose is to return an [identity | #identity] or to throw an `Nette\Security\AuthenticationException`. Framework defines few error codes, that can be used to determine the reason login was not successful, such as self-explaining `IAuthenticator::IDENTITY_NOT_FOUND` or `IAuthenticator::INVALID_CREDENTIAL`. +An authenticator is an object that implements the [Nette\Security\Authenticator](https://api.nette.org/security/master/Nette/Security/Authenticator.html) interface with method `authenticate()`. Its task is either to return the so-called [identity](#Identity) or to throw an exception `Nette\Security\AuthenticationException`. It would also be possible to provide an fine-grain error code `Authenticator::IDENTITY_NOT_FOUND` or `Authenticator::INVALID_CREDENTIAL`. ```php -use Nette\Security as NS; +use Nette; -class MyAuthenticator extends Nette\Object implements NS\IAuthenticator +class MyAuthenticator implements Nette\Security\Authenticator { - public $database; + private $database; + private $passwords; - function __construct(Nette\Database\Connection $database) + public function __construct(Nette\Database\Context $database, Nette\Security\Passwords $passwords) { $this->database = $database; + $this->passwords = $passwords; } - function authenticate(array $credentials) + public function authenticate($username, $password): Nette\Security\IIdentity { - list($username, $password) = $credentials; $row = $this->database->table('users') - ->where('username', $username)->fetch(); + ->where('username', $username) + ->fetch(); if (!$row) { - throw new NS\AuthenticationException('User not found.'); + throw new Nette\Security\AuthenticationException('User not found.'); } - if ($row->password !== md5($password)) { - throw new NS\AuthenticationException('Invalid password.'); + if (!$this->passwords->verify($password, $row->password)) { + throw new Nette\Security\AuthenticationException('Invalid password.'); } - return new NS\Identity($row->id, $row->role); + return new Nette\Security\SimpleIdentity( + $row->id, + $row->role, // or array of roles + ['name' => $row->username] + ); } } ``` -Class `MyAuthenticator` communicates with the database using [Nette\Database |database] layer and works with table `users`, where it grabs `username` and md5 hash of `password` in the appropriate columns. If the password check is successful, it returns new identity with user ID and role, which we will mention [later | #roles]; +The MyAuthenticator class communicates with the database through [Nette Database Explorer](https://doc.nette.org/database) and works with table `users`, where column `username` contains the user's login name and column `password` contains [hash](https://doc.nette.org/passwords). After verifying the name and password, it returns the identity with user's ID, role (column `role` in the table), which we will mention [later ](#roles), and an array with additional data (in our case, the username). -This authenticator would be configured in the `config.neon` file like this: +$onLoggedIn, $onLoggedOut events +-------------------------------- + +Object `Nette\Security\User` has [events](https://doc.nette.org/smartobject#toc-events) `$onLoggedIn` and `$onLoggedOut`, so you can add callbacks that are triggered after a successful login or after the user logs out. + + +```php +$user->onLoggedIn[] = function () { + // user has just logged in +}; ``` -common: - services: - authenticator: MyAuthenticator -``` + Identity --------- +======== -Identity presents a set of user information, as returned by autheticator. It's an object implementing [api:Nette\Security\IIdentity] interface, with default implementation [api:Nette\Security\Identity]. -Class has methods `getId()`, that returns users ID (for example primary key for the respective database row), and `getRoles()`, which returns an array of all roles user is in. User data can be access as if they were identity properties. +An identity is a set of information about a user that is returned by the authenticator and which is then stored in a session and retrieved using `$user->getIdentity()`. So we can get the id, roles and other user data as we passed them in the authenticator: -Identity is not erased when the user is logged out. So, if identity exists, it by itself does not grant that the user is also logged in. If we would like to explicitly delete the identity for some reason, we logout the user by calling `$user->logout(TRUE)`. +```php +$user->getIdentity()->getId(); +// also works shortcut $user->getId(); + +$user->getIdentity()->getRoles(); + +// user data can be access as properties +// the name we passed on in MyAuthenticator +$user->getIdentity()->name; +``` + +Importantly, **when user logs out, identity is not deleted** and is still available. So, if identity exists, it by itself does not grant that the user is also logged in. If we want to explicitly delete the identity, we logout the user by `$user->logout(true)`. + +Thanks to this, you can still assume which user is at the computer and, for example, display personalized offers in the e-shop, however, you can only display his personal data after logging in. + +If you prefer the identity to be discarded on every logout and expiration, set `$user->persistIdentity = false`. Retaining the identity is best-effort and depends on the storage implementation. -Service `user` of class [api:Nette\Security\User] keeps the identity in session and uses it to all authorizations. -Identity can be access with `getIdentity` upon `$user`: +Identity is an object that implements the [Nette\Security\IIdentity](https://api.nette.org/master/Nette/Security/IIdentity.html) interface, the default implementation is [Nette\Security\SimpleIdentity](https://api.nette.org/3.0/Nette/Security/SimpleIdentity.html). And as mentioned, identity is stored in the session, so if, for example, we change the role of some of the logged-in users, old data will be kept in the identity until he logs in again. + + +Guest Identity +-------------- + +You can provide an identity even for users who are not logged in. If the authenticator implements `IdentityHandler` and its optional method `getGuestIdentity()`, the returned identity is used as a fallback for `getIdentity()`, `getId()` and `getRoles()`. So anonymous visitors can carry their own roles and data instead of the plain `guest` role: ```php -if ($user->isLoggedIn()) { - echo 'User logged in: ', $user->getIdentity()->getId(); -} else { - echo 'User is not logged in'; +public function getGuestIdentity(): ?Nette\Security\IIdentity +{ + return new Nette\Security\SimpleIdentity('guest', ['guest'], ['name' => 'Guest']); } ``` +The guest identity is resolved only when reading and is never stored. + Authorization ============= -Authorization detects whether the user has enough privilege to do some action, for example opening a file or deleting an article. Authorization assumes that the user has been successfully authenticated (logged in). +Authorization determines whether a user has sufficient privileges, for example, to access a specific resource or to perform an action. Authorization assumes previous successful authentication, ie that the user is logged in. -Nette Framework authorization may be based on what groups the user belongs to or on which roles were assigned to the user. We will start from the very beginning. - -For simple web sites with administration, where all users share same privileges, it is sufficient to use already mentioned `isLoggedIn()` method. Simply put, if the user is logged in, he has permissions to all actions, and vice versa. +For very simple websites with administration, where user rights are not distinguished, it is possible to use the already known method as an authorization criterion `isLoggedIn()`. In other words: once a user is logged in, he has permissions to all actions and vice versa. ```php if ($user->isLoggedIn()) { // is user logged in? @@ -180,9 +216,9 @@ if ($user->isLoggedIn()) { // is user logged in? Roles ----- -The purpose of roles is to offer a more precise privilege control while remaining independent on the user name. As soon as user logs in, he is assigned one or more roles. Roles themselves may be simple strings, such as `admin`, `member`, `guest`, etc. They are specified in the second argument of `Identity` constructor, either as a string or an array. +The purpose of roles is to offer a more precise permission management and remain independent on the user name. As soon as user logs in, he is assigned one or more roles. Roles themselves may be simple strings, for example, `admin`, `member`, `guest`, etc. They are specified in the second argument of `SimpleIdentity` constructor, either as a string or an array. -This time we will use the `isInRole()` method to check if the user is allowed to perform some action: +As an authorization criterion, we will now use the method `isInRole()`, which checks whether the user is in the given role: ```php if ($user->isInRole('admin')) { // is the admin role assigned to the user? @@ -190,39 +226,42 @@ if ($user->isInRole('admin')) { // is the admin role assigned to the user? } ``` -As you already know, logging user out does not erase his identity. Therefore the `getIdentity()` method still returns an `Identity` object, with all the assigned roles regardless on logout. Nette Framework adheres to the "less code, more security" principle, which is why it doesn't want to force coders to write `if ($user->isLoggedIn() && $user->isInRole('admin'))` everywhere and therefore the `isInRole()` method works with **efective roles**. If the user is logged in, roles assigned to identity are used, if he is logged out, an automatic special role `guest` is used instead. +As you already know, logging the user out does not erase his identity. Thus, method `getIdentity()` still returns object `SimpleIdentity`, including all granted roles. The Nette Framework adheres to the principle of "less code, more security", so when you are checking roles, you do not have to check whether the user is logged in too. Method `isInRole()` works with **effective roles**, ie if the user is logged in, roles assigned to identity are used, if he is not logged in, an automatic special role `guest` is used instead. + Authorizator ------------ -Authorizator decides, whether the user has permission to take some action. It's an implementation of [api:Nette\Security\IAuthorizator] interface with only one method `isAllowed()`. Purpose of this method is to determine, whether given role has the permission to perform certain *operation* with specific *resource*. +In addition to roles, we will introduce the terms resource and operation: - **role** is a user attribute - for example moderator, editor, visitor, registered user, administrator, ... - **resource** is a logical unit of the application - article, page, user, menu item, poll, presenter, ... -- **privilege** is a specific activity, which user may or may not do with *resource* - view, edit, delete, vote, ... +- **operation** is a specific activity, which user may or may not do with *resource* - view, edit, delete, vote, ... - -An implementation skeleton looks like this: +An authorizer is an object that decides whether a given *role* has permission to perform a certain *operation* with specific *resource*. It is an object implementing the [Nette\Security\Authorizator](https://api.nette.org/master/Nette/Security/Authorizator.html) interface with only one method `isAllowed()`: ```php -class MyAuthorizator extends Nette\Object - implements Nette\Security\IAuthorizator +class MyAuthorizator implements Nette\Security\Authorizator { - - function isAllowed($role, $resource, $privilege) + public function isAllowed($role, $resource, $operation): bool { - return ...; // returns either TRUE or FALSE - } + if ($role === 'admin') { + return true; + } + if ($role === 'user' && $resource === 'article') { + return true; + } + ... + + return false; + } } ``` -And an example of use: +And the following is an example of use. Note that this time we call the method `Nette\Security\User::isAllowed()`, not the authorizator's one, so there is not first parameter `$role`. This method calls `MyAuthorizator::isAllowed()` sequentially for all user roles and returns true if at least one of them has permission. ```php -// registers the authorizator -$user->setAuthorizator(new MyAuthorizator); - if ($user->isAllowed('file')) { // is user allowed to do everything with resource 'file'? useFile(); } @@ -232,111 +271,156 @@ if ($user->isAllowed('file', 'delete')) { // is user allowed to delete a resourc } ``` -.[note] -Do not confuse two different methods `isAllowed`: one belongs to the authorizator and the other one to the `User` class, where first argument is not `$role`. +Both arguments are optional and their default value means *everything*. -Because user may have many roles, he is granted the permission only if at least one of roles has the permission. Both arguments are optional and their default value is *everything*. Permission ACL -------------- -Nette Framework has a complete authorizator, class [api:Nette\Security\Permission] which offers a light weight and flexible ACL((Access Control List)) layer for permission and access control. When we work with this class, we define roles, resources and individual privileges. Roles and resources may form hierarchies, as shown in the following example: -- `guest`: visitor that is not logged in, allowed to read and browse public part of the web, ie. articles, comments, and to vote in a poll +Nette comes with a built-in implementation of the authorizer, the [Nette\Security\Permission](https://api.nette.org/3.0/Nette/Security/Permission.html) class, which offers a lightweight and flexible ACL (Access Control List) layer for permission and access control. When we work with this class, we define roles, resources, and individual permissions. And roles and resources may form hierarchies. To explain, we will show an example of a web application: -- `registered`: logged in user, which may on top of that post comments +- `guest`: visitor that is not logged in, allowed to read and browse public part of the web, ie. read articles, comment and vote in polls +- `registered`: logged-in user, which may on top of that post comments +- `administrator`: can manage articles, comments and polls -- `administrator`: may write and administer articles, comments and polls +So we have defined certain roles (`guest`, `registered` and `administrator`) and mentioned resources (`article`, `comments`, `poll`), which the users may access or take actions on (`view`, `vote`, `add`, `edit`). -So we have defined certain roles (`guest`, `registered` and `administrator`) and metioned resources (`article`, `comments`, `poll`), which the users may access or take actions on (`view`, `vote`, `add`, `edit`). - -We create an instance of Presmission and define the user roles. As roles may inherit each other, we may for example specify that `administrator` may do the same as an ordinary visitor (and of course more). +We create an instance of the Permission class and define **roles**. It is possible to use the inheritance of roles, which ensures that, for example, a user with a role `administrator` can do what an ordinary website visitor can do (and of course more). ```php $acl = new Nette\Security\Permission; -// roles definition $acl->addRole('guest'); $acl->addRole('registered', 'guest'); // registered inherits from guest $acl->addRole('administrator', 'registered'); // and administrator inherits from registered ``` -Trivial, isn't it? This ensures all the properties of the parents will be inheritted by their children. - -Do note the method `getRoleParents()`, which returns an array of all parent roles, and the method `roleIntheritsFrom()`, which checks whether a role extends another. Their usage: - -```php -$acl->roleInheritsFrom('administrator', 'guest'); // TRUE -$acl->getRoleParents('administrator'); // array('guest', 'registered') -``` - -Now is the right time to define the set of resources that the users may acccess: +We will now define a list of **resources** that users can access: ```php $acl->addResource('article'); -$acl->addResource('comments'); +$acl->addResource('comment'); $acl->addResource('poll'); ``` -Also resources may use inheritance. The API offers similar methods, only the names are slightly different: `resourceInheritsFrom()`, `removeResource()`. - -And now the most important part. Roles and resources alone would do us no good, we have to create rules defining who can do what with whatever: +Resources can also use inheritance, for example, we can add `$acl->addResource('perex', 'article')`. -TODO: missing example for deny() +And now the most important thing. We will define between them **rules** determining who can do what: ```php // everything is denied now -// guest may view articles, comments and polls -$acl->allow('guest', array('article', 'comments', 'poll'), 'view'); +// let the guest view articles, comments and polls +$acl->allow('guest', ['article', 'comment', 'poll'], 'view'); +// and also vote in polls +$acl->allow('guest', 'poll', 'vote'); -// registered user has also right to add comments -$acl->allow('registered', 'comments', 'add'); +// the registered inherits the permissions from guesta, we will also let him to comment +$acl->allow('registered', 'comment', 'add'); -// administrator may also edit and add everything -$acl->allow('administrator', Permission::ALL, array('view', 'edit', 'add')); +// the administrator can view and edit anything +$acl->allow('administrator', $acl::All, ['view', 'edit', 'add']); +``` + +What if we want to **prevent** someone from accessing a resource? + +```php +// administrator cannot edit polls, that would be undemocractic. +$acl->deny('administrator', 'poll', 'edit'); ``` Now when we have created the set of rules, we may simply ask the authorization queries: ```php // can guest view articles? -echo $acl->isAllowed('guest', 'article', 'view'); // TRUE +$acl->isAllowed('guest', 'article', 'view'); // true + // can guest edit an article? -echo $acl->isAllowed('guest', 'article', 'edit'); // FALSE +$acl->isAllowed('guest', 'article', 'edit'); // false + +// can guest vote in polls? +$acl->isAllowed('guest', 'poll', 'vote'); // true + // may guest add comments? -echo $acl->isAllowed('guest', 'comments', 'add'); // FALSE +$acl->isAllowed('guest', 'comment', 'add'); // false +``` + +The same applies to a registered user, but he can also comment: + +```php +$acl->isAllowed('registered', 'article', 'view'); // true +$acl->isAllowed('registered', 'comment', 'add'); // true +$acl->isAllowed('registered', 'comment', 'edit'); // false ``` -The same is true for the registered user, though he is allowed to add a comment: +The administrator can edit everything except polls: ```php -echo $acl->isAllowed('registered', 'article', 'view'); // TRUE -echo $acl->isAllowed('registered', 'comments', 'add'); // TRUE -echo $acl->isAllowed('registered', 'backend', 'view'); // FALSE +$acl->isAllowed('administrator', 'poll', 'vote'); // true +$acl->isAllowed('administrator', 'poll', 'edit'); // false +$acl->isAllowed('administrator', 'comment', 'edit'); // true +``` + +Permissions can also be evaluated dynamically and we can leave the decision to our own callback, to which all parameters are passed: + +```php +$assertion = function (Permission $acl, string $role, string $resource, string $privilege): bool { + return ...; +}; + +$acl->allow('registered', 'comment', null, $assertion); ``` -Administrator is allowed to do everything: +But how to solve a situation where the names of roles and resources are not enough, ie we would like to define that, for example, a role `registered` can edit a resource `article` only if it is its author? We will use objects instead of strings, the role will be the object [Nette\Security\IRole](https://api.nette.org/3.0/Nette/Security/IRole.html) and the source [Nette\Security\IResource](https://api.nette.org/3.0/Nette/Security/IResource.html). Their methods `getRoleId()` resp. `getResourceId()` will return the original strings: ```php -echo $acl->isAllowed('administrator', 'article', 'view'); // TRUE -echo $acl->isAllowed('administrator', 'commend', 'add'); // TRUE -echo $acl->isAllowed('administrator', 'poll', 'edit'); // TRUE +class Registered implements Nette\Security\IRole +{ + public $id; + + public function getRoleId(): string + { + return 'registered'; + } +} + + +class Article implements Nette\Security\IResource +{ + public $authorId; + + public function getResourceId(): string + { + return 'article'; + } +} ``` -Admin rules may possibly be defined without any restrictions (without inheriting from any other roles): +And now let's create a rule: ```php -$acl->addRole('supervisor'); -$acl->allow('supervisor'); // all privileges for all resources for supervisor +$assertion = function (Permission $acl, string $role, string $resource, string $privilege): bool { + $role = $acl->getQueriedRole(); // object Registered + $resource = $acl->getQueriedResource(); // object Article + return $role->id === $resource->authorId; +}; + +$acl->allow('registered', 'article', 'edit', $assertion); ``` -Whenever during the application runtime we may remove roles with `removeRolle()`, resources with `removeResource()` or rules with `removeAllow()` or `removeDeny()`. +The ACL is queried by passing objects: -Roles may inherit form one or more other roles. But what happens, if one ancestor has certain action allowed and the other one has it denied? Then the *role weight* comes into play - the last role in the array of roles to inherit has the greatest weight, first one the lowest: +```php +$user = new Registered(...); +$article = new Article(...); +$acl->isAllowed($user, $article, 'edit'); +``` + +A role may inherit form one or more other roles. But what happens, if one ancestor has certain action allowed and the other one has it denied? Then the *role weight* comes into play - the last role in the array of roles to inherit has the greatest weight, first one the lowest: ```php -$acl = new Permission(); +$acl = new Nette\Security\Permission; $acl->addRole('admin'); $acl->addRole('guest'); @@ -346,20 +430,24 @@ $acl->allow('admin', 'backend'); $acl->deny('guest', 'backend'); // example A: role admin has lower weight than role guest -$acl->addRole('john', array('admin', 'guest')); -$acl->isAllowed('john', 'backend'); // FALSE +$acl->addRole('john', ['admin', 'guest']); +$acl->isAllowed('john', 'backend'); // false // example B: role admin has greater weight than role guest -$acl->addRole('mary', array('guest', 'admin')); -$acl->isAllowed('mary', 'backend'); // TRUE +$acl->addRole('mary', ['guest', 'admin']); +$acl->isAllowed('mary', 'backend'); // true ``` +Roles and resources can also be removed (`removeRole()`, `removeResource()`), rules can also be reverted (`removeAllow()`, `removeDeny()`). The array of all direct parent roles returns `getRoleParents()`. Whether two entities inherit from each other returns `roleInheritsFrom()` and `resourceInheritsFrom()`. + -Multiple applications in one scope -================================== +Multiple Independent Authentications +==================================== -Multiple applications may work on the same server, session, etc., each with separated authentication logic. We just have to set a unique namespace for each: +It is possible to have several independent logged users within one site and one session at a time. For example, if we want to have separate authentication for frontend and backend, we will just set a unique session namespace for each of them: ```php -$user->setNamespace('forum'); +$user->getStorage()->setNamespace('forum'); ``` + +[Continue...](https://doc.nette.org/en/security/authentication) diff --git a/src/Bridges/SecurityDI/SecurityExtension.php b/src/Bridges/SecurityDI/SecurityExtension.php new file mode 100644 index 00000000..8cbcebda --- /dev/null +++ b/src/Bridges/SecurityDI/SecurityExtension.php @@ -0,0 +1,163 @@ +, data?: array}>, + * roles: array|null>, + * resources: array, + * authentication: object{ + * storage: 'session'|'cookie', + * expiration: string|null, + * persistIdentity: bool, + * cookieName: string|null, + * cookieDomain: string|null, + * cookieSamesite: 'Lax'|'Strict'|'None'|null, + * }, + * } $config + */ +class SecurityExtension extends Nette\DI\CompilerExtension +{ + public function __construct( + private readonly bool $debugMode = false, + ) { + } + + + public function getConfigSchema(): Nette\Schema\Schema + { + return Expect::structure([ + 'debugger' => Expect::bool(), + 'users' => Expect::arrayOf( + Expect::anyOf( + Expect::string()->dynamic(), // user => password + Expect::structure([ // user => password + roles + data + 'password' => Expect::string()->dynamic(), + 'roles' => Expect::anyOf(Expect::string(), Expect::listOf('string')), + 'data' => Expect::array(), + ])->castTo('array'), + ), + ), + 'roles' => Expect::arrayOf('string|array|null'), // role => parent(s) + 'resources' => Expect::arrayOf('string|null'), // resource => parent + 'authentication' => Expect::structure([ + 'storage' => Expect::anyOf('session', 'cookie')->default('session'), + 'expiration' => Expect::string()->dynamic(), + 'persistIdentity' => Expect::bool(true), + 'cookieName' => Expect::string(), + 'cookieDomain' => Expect::string(), + 'cookieSamesite' => Expect::anyOf('Lax', 'Strict', 'None'), + ]), + ]); + } + + + public function loadConfiguration(): void + { + $config = $this->config; + $builder = $this->getContainerBuilder(); + + $builder->addDefinition($this->prefix('passwords')) + ->setFactory(Nette\Security\Passwords::class); + + $auth = $config->authentication; + $storage = $builder->addDefinition($this->prefix('userStorage')) + ->setType(Nette\Security\UserStorage::class) + ->setFactory([ + 'session' => Nette\Bridges\SecurityHttp\SessionStorage::class, + 'cookie' => Nette\Bridges\SecurityHttp\CookieStorage::class, + ][$auth->storage]); + + if ($auth->storage === 'cookie') { + $cookieDomain = $auth->cookieDomain === 'domain' + ? $builder::literal('$this->getByType(Nette\Http\IRequest::class)->getUrl()->getDomain(2)') + : $auth->cookieDomain; + + $storage->addSetup('setCookieParameters', [$auth->cookieName, $cookieDomain, $auth->cookieSamesite]); + } + + $user = $builder->addDefinition($this->prefix('user')) + ->setFactory(Nette\Security\User::class); + + if ($auth->expiration) { + $user->addSetup('setExpiration', [$auth->expiration]); + } + + if (!$auth->persistIdentity) { + $user->addSetup('$persistIdentity', [false]); + } + + if ($config->users) { + $usersList = $usersRoles = $usersData = []; + foreach ($config->users as $username => $data) { + $data = is_array($data) ? $data : ['password' => $data]; + $usersList[$username] = $data['password']; + $usersRoles[$username] = $data['roles'] ?? null; + $usersData[$username] = $data['data'] ?? []; + } + + $builder->addDefinition($this->prefix('authenticator')) + ->setType(Nette\Security\Authenticator::class) + ->setFactory(Nette\Security\SimpleAuthenticator::class, [$usersList, $usersRoles, $usersData]); + + if ($this->name === 'security') { + $builder->addAlias('nette.authenticator', $this->prefix('authenticator')); + } + } + + if ($config->roles || $config->resources) { + $authorizator = $builder->addDefinition($this->prefix('authorizator')) + ->setType(Nette\Security\Authorizator::class) + ->setFactory(Nette\Security\Permission::class); + + foreach ($config->roles as $role => $parents) { + $authorizator->addSetup('addRole', [$role, $parents]); + } + + foreach ($config->resources as $resource => $parents) { + $authorizator->addSetup('addResource', [$resource, $parents]); + } + + if ($this->name === 'security') { + $builder->addAlias('nette.authorizator', $this->prefix('authorizator')); + } + } + + if ($this->name === 'security') { + $builder->addAlias('user', $this->prefix('user')); + $builder->addAlias('nette.userStorage', $this->prefix('userStorage')); + } + } + + + public function beforeCompile(): void + { + $builder = $this->getContainerBuilder(); + + if ( + $this->debugMode && + ($this->config->debugger ?? $builder->getByType(Tracy\Bar::class)) + ) { + $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/CookieStorage.php b/src/Bridges/SecurityHttp/CookieStorage.php new file mode 100644 index 00000000..03b5a2e9 --- /dev/null +++ b/src/Bridges/SecurityHttp/CookieStorage.php @@ -0,0 +1,94 @@ +getId(); + if (strlen($uid) < self::MinLength) { + throw new \LogicException('UID is too short.'); + } + + $this->uid = $uid; + $this->response->setCookie( + $this->cookieName, + $uid, + $this->cookieExpiration, + domain: $this->cookieDomain, + sameSite: $this->cookieSameSite, + ); + } + + + public function clearAuthentication(bool $clearIdentity): void + { + $this->uid = ''; + $this->response->deleteCookie( + $this->cookieName, + domain: $this->cookieDomain, + ); + } + + + public function getState(): array + { + if ($this->uid === null) { + $uid = $this->request->getCookie($this->cookieName); + $this->uid = is_string($uid) && strlen($uid) >= self::MinLength ? $uid : ''; + } + + return $this->uid + ? [true, new Nette\Security\SimpleIdentity($this->uid), null] + : [false, null, null]; + } + + + public function setExpiration(?string $expire, bool $clearIdentity): void + { + $this->cookieExpiration = $expire; + } + + + /** @param 'Lax'|'Strict'|'None'|null $sameSite */ + 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 new file mode 100644 index 00000000..e4aca259 --- /dev/null +++ b/src/Bridges/SecurityHttp/SessionStorage.php @@ -0,0 +1,154 @@ +getSessionSection(); + $section->set('authenticated', true); + $section->set('reason', null); + $section->set('authTime', time()); // informative value + $section->set('identity', $identity); + $this->setupExpiration(); + + // Session Fixation defence + $this->sessionHandler->regenerateId(); + } + + + public function clearAuthentication(bool $clearIdentity): void + { + $section = $this->getSessionSection(); + $section->set('authenticated', false); + $section->set('reason', User::LogoutManual); + $section->set('authTime', null); + if ($clearIdentity === true) { + $section->set('identity', null); + } + + // Session Fixation defence + $this->sessionHandler->regenerateId(); + } + + + public function getState(): array + { + $section = $this->getSessionSection(); + return [(bool) $section->get('authenticated'), $section->get('identity'), $section->get('reason')]; + } + + + public function setExpiration(?string $time, bool $clearIdentity = false): void + { + $this->expireTime = $time ? (int) Nette\Utils\DateTime::from($time)->format('U') : null; + $this->expireIdentity = $clearIdentity; + + if ($this->sessionSection && $this->sessionSection->get('authenticated')) { + $this->setupExpiration(); + } + } + + + private function setupExpiration(): void + { + assert($this->sessionSection !== null); + $section = $this->sessionSection; + if ($this->expireTime) { + $section->set('expireTime', $this->expireTime); + $section->set('expireDelta', $this->expireTime - time()); + } else { + $section->remove(['expireTime', 'expireDelta']); + } + + $section->set('expireIdentity', $this->expireIdentity); + $section->setExpiration($this->expireTime === null ? null : '@' . $this->expireTime, 'foo'); // time check + } + + + /** + * Changes namespace; allows more users to share a session. + */ + public function setNamespace(string $namespace): static + { + if ($this->namespace !== $namespace) { + $this->namespace = $namespace; + $this->sessionSection = null; + } + + return $this; + } + + + /** + * Returns current namespace. + */ + public function getNamespace(): string + { + return $this->namespace; + } + + + /** + * Returns and initializes $this->sessionSection. + */ + private function getSessionSection(): SessionSection + { + if ($this->sessionSection !== null) { + return $this->sessionSection; + } + + $this->sessionSection = $section = $this->sessionHandler->getSection('Nette.Http.UserStorage/' . $this->namespace); + + if (!$section->get('identity') instanceof IIdentity || !is_bool($section->get('authenticated'))) { + $section->remove(); + } + + if ($section->get('authenticated') && $section->get('expireDelta') > 0) { // check time expiration + if ($section->get('expireTime') < time()) { + $section->set('reason', User::LogoutInactivity); + $section->set('authenticated', false); + if ($section->get('expireIdentity')) { + $section->remove('identity'); + } + } else { + $section->set('expireTime', time() + $section->get('expireDelta')); // sliding expiration + } + } + + if (!$section->get('authenticated')) { + $section->remove(['expireTime', 'expireDelta', 'expireIdentity', 'authTime']); + } + + return $this->sessionSection; + } +} diff --git a/src/Bridges/SecurityTracy/UserPanel.php b/src/Bridges/SecurityTracy/UserPanel.php index 16512504..75a523d8 100644 --- a/src/Bridges/SecurityTracy/UserPanel.php +++ b/src/Bridges/SecurityTracy/UserPanel.php @@ -1,56 +1,58 @@ -user = $user; + public function __construct( + private readonly Nette\Security\User $user, + ) { } /** * Renders tab. - * @return string */ - public function getTab() + public function getTab(): ?string { - ob_start(); - $user = $this->user; - require __DIR__ . '/templates/UserPanel.tab.phtml'; - return ob_get_clean(); + if (!session_id()) { + return null; + } + + return Nette\Utils\Helpers::capture(function () { + $status = session_status() === PHP_SESSION_ACTIVE + ? $this->user->isLoggedIn() + : '?'; + require __DIR__ . '/dist/tab.phtml'; + }); } /** * Renders panel. - * @return string */ - public function getPanel() + public function getPanel(): ?string { - ob_start(); - $user = $this->user; - require __DIR__ . '/templates/UserPanel.panel.phtml'; - return ob_get_clean(); + if (session_status() !== PHP_SESSION_ACTIVE) { + return null; + } + + return Nette\Utils\Helpers::capture(function () { + $user = $this->user; + require __DIR__ . '/dist/panel.phtml'; + }); } - } diff --git a/src/Bridges/SecurityTracy/dist/panel.phtml b/src/Bridges/SecurityTracy/dist/panel.phtml new file mode 100644 index 00000000..e76d0345 --- /dev/null +++ b/src/Bridges/SecurityTracy/dist/panel.phtml @@ -0,0 +1,23 @@ +'; +if ($user->isLoggedIn()) /* pos 2:5 */ { + echo 'Logged in'; +} else /* pos 2:38 */ { + echo 'Unlogged'; +} +echo ' + +
+'; +if ($user->getIdentity()) /* pos 5:2 */ { + echo ' '; + echo Tracy\Dumper::toHtml($user->getIdentity(), [Tracy\Dumper::LIVE => true]) /* pos 6:3 */; + echo "\n"; +} else /* pos 7:2 */ { + echo '

no identity

+'; +} +echo '
+'; diff --git a/src/Bridges/SecurityTracy/dist/tab.phtml b/src/Bridges/SecurityTracy/dist/tab.phtml new file mode 100644 index 00000000..a1c13389 --- /dev/null +++ b/src/Bridges/SecurityTracy/dist/tab.phtml @@ -0,0 +1,15 @@ + 'Logged in', false => 'Unlogged', '?' => 'Session is closed'] /* pos 2:1 */; +$colors = [true => '#61A519', false => '#ababab', '?' => '#bb0000'] /* pos 3:1 */; +echo ' + + + + +'; diff --git a/src/Bridges/SecurityTracy/panel.latte b/src/Bridges/SecurityTracy/panel.latte new file mode 100644 index 00000000..fc358541 --- /dev/null +++ b/src/Bridges/SecurityTracy/panel.latte @@ -0,0 +1,10 @@ +{varType Nette\Security\User $user} +

{if $user->isLoggedIn()}Logged in{else}Unlogged{/if}

+ +
+ {if $user->getIdentity()} + {Tracy\Dumper::toHtml($user->getIdentity(), [Tracy\Dumper::LIVE => true])} + {else} +

no identity

+ {/if} +
diff --git a/src/Bridges/SecurityTracy/tab.latte b/src/Bridges/SecurityTracy/tab.latte new file mode 100644 index 00000000..06d89ee3 --- /dev/null +++ b/src/Bridges/SecurityTracy/tab.latte @@ -0,0 +1,8 @@ +{varType string|bool $status} +{do $messages = [true => 'Logged in', false => Unlogged, '?' => 'Session is closed']} +{do $colors = [true => '#61A519', false => '#ababab', '?' => '#bb0000']} + + + + + diff --git a/src/Bridges/SecurityTracy/templates/UserPanel.panel.phtml b/src/Bridges/SecurityTracy/templates/UserPanel.panel.phtml deleted file mode 100644 index daf804f9..00000000 --- a/src/Bridges/SecurityTracy/templates/UserPanel.panel.phtml +++ /dev/null @@ -1,13 +0,0 @@ - -
-

isLoggedIn()): ?>Logged inUnlogged

- - getIdentity()): echo Tracy\Dumper::toHtml($user->getIdentity()); else: ?>

no identity

-
diff --git a/src/Bridges/SecurityTracy/templates/UserPanel.tab.phtml b/src/Bridges/SecurityTracy/templates/UserPanel.tab.phtml deleted file mode 100644 index 38a461a1..00000000 --- a/src/Bridges/SecurityTracy/templates/UserPanel.tab.phtml +++ /dev/null @@ -1,12 +0,0 @@ - -isLoggedIn()): ?> -  - -  - diff --git a/src/Security/AuthenticationException.php b/src/Security/AuthenticationException.php index 7dee689c..2a03b5bf 100644 --- a/src/Security/AuthenticationException.php +++ b/src/Security/AuthenticationException.php @@ -1,19 +1,15 @@ - getData() */ interface IIdentity { - /** * Returns the ID of user. - * @return mixed */ - function getId(); + function getId(): string|int; /** * Returns a list of roles that the user is a member of. - * @return array + * @return list */ - function getRoles(); + function getRoles(): array; + /** + * Returns user data. + */ + //function getData(): array; } diff --git a/src/Security/IResource.php b/src/Security/IResource.php deleted file mode 100644 index 7be0bac9..00000000 --- a/src/Security/IResource.php +++ /dev/null @@ -1,27 +0,0 @@ -setId($id); - $this->setRoles((array) $roles); - $this->data = $data instanceof \Traversable ? iterator_to_array($data) : (array) $data; - } - - - /** - * Sets the ID of user. - * @param mixed - * @return self - */ - public function setId($id) - { - $this->id = is_numeric($id) ? 1 * $id : $id; - return $this; - } - - - /** - * Returns the ID of user. - * @return mixed - */ - public function getId() - { - return $this->id; - } - - - /** - * Sets a list of roles that the user is a member of. - * @param array - * @return self - */ - public function setRoles(array $roles) - { - $this->roles = $roles; - return $this; - } - - - /** - * Returns a list of roles that the user is a member of. - * @return array - */ - public function getRoles() - { - return $this->roles; - } - - - /** - * Returns a user data. - * @return array - */ - public function getData() - { - return $this->data; - } - - - /** - * Sets user data value. - * @param string property name - * @param mixed property value - * @return void - */ - public function __set($key, $value) - { - if (parent::__isset($key)) { - parent::__set($key, $value); - - } else { - $this->data[$key] = $value; - } - } - - - /** - * Returns user data value. - * @param string property name - * @return mixed - */ - public function &__get($key) - { - if (parent::__isset($key)) { - return parent::__get($key); - - } else { - return $this->data[$key]; - } - } - - - /** - * Is property defined? - * @param string property name - * @return bool - */ - public function __isset($key) - { - return isset($this->data[$key]) || parent::__isset($key); - } - - - /** - * Removes property. - * @param string property name - * @return void - * @throws Nette\MemberAccessException - */ - public function __unset($name) - { - Nette\Utils\ObjectMixin::remove($this, $name); - } - -} diff --git a/src/Security/IdentityHandler.php b/src/Security/IdentityHandler.php new file mode 100644 index 00000000..02523867 --- /dev/null +++ b/src/Security/IdentityHandler.php @@ -0,0 +1,30 @@ += 5.3.7. - * - * @author David Grudl + * Password hashing and verification. */ class Passwords { - const BCRYPT_COST = 10; + /** + * Configures the hashing algorithm and its options. + */ + 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 = [], + ) { + } /** - * Computes salted password hash. - * @param string - * @param array with cost (4-31), salt (22 chars) - * @return string 60 chars long + * Computes a password hash containing the algorithm ID, settings, salt, and the hash itself. */ - public static function hash($password, array $options = NULL) + public function hash( + #[\SensitiveParameter] + string $password, + ): string { - $cost = isset($options['cost']) ? (int) $options['cost'] : self::BCRYPT_COST; - $salt = isset($options['salt']) ? (string) $options['salt'] : Nette\Utils\Random::generate(22, '0-9A-Za-z./'); - - if (PHP_VERSION_ID < 50307) { - throw new Nette\NotSupportedException(__METHOD__ . ' requires PHP >= 5.3.7.'); - } elseif (($len = strlen($salt)) < 22) { - throw new Nette\InvalidArgumentException("Salt must be 22 characters long, $len given."); - } elseif ($cost < 4 || $cost > 31) { - throw new Nette\InvalidArgumentException("Cost must be in range 4-31, $cost given."); + if ($password === '') { + throw new Nette\InvalidArgumentException('Password can not be empty.'); } - $hash = crypt($password, '$2y$' . ($cost < 10 ? 0 : '') . $cost . '$' . $salt); - if (strlen($hash) < 60) { - throw new Nette\InvalidStateException('Hash returned by crypt is invalid.'); + $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'] ?? '')); } + return $hash; } /** - * Verifies that a password matches a hash. - * @return bool + * Checks whether the password matches the given hash. */ - public static function verify($password, $hash) + public function verify( + #[\SensitiveParameter] + string $password, + string $hash, + ): bool { - return preg_match('#^\$2y\$(?P\d\d)\$(?P.{22})#', $hash, $m) - && $m['cost'] >= 4 && $m['cost'] <= 31 - && self::hash($password, $m) === $hash; + return password_verify($password, $hash); } /** - * Checks if the given hash matches the options. - * @param string - * @param array with cost (4-31) - * @return bool + * Checks whether the hash needs to be rehashed with the current algorithm and options. */ - public static function needsRehash($hash, array $options = NULL) + public function needsRehash(string $hash): bool { - $cost = isset($options['cost']) ? (int) $options['cost'] : self::BCRYPT_COST; - return !preg_match('#^\$2y\$(?P\d\d)\$(?P.{22})#', $hash, $m) - || $m['cost'] < $cost; + return password_needs_rehash($hash, $this->algo, $this->options); } - } diff --git a/src/Security/Permission.php b/src/Security/Permission.php index ca1ace7d..de276ea6 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -1,53 +1,46 @@ - array( - 'allRoles' => array( - 'allPrivileges' => array( - 'type' => self::DENY, - 'assert' => NULL, - ), - 'byPrivilege' => array(), - ), - 'byRole' => array(), - ), - 'byResource' => array(), - ); - - /** @var mixed */ - private $queriedRole, $queriedResource; + /** @var array, children: array}> */ + private array $roles = []; + + /** @var array}> */ + private array $resources = []; + + /** @var array Access Control List rules; whitelist (deny everything to all) by default */ + private array $rules = [ + 'allResources' => [ + 'allRoles' => [ + 'allPrivileges' => [ + 'type' => self::Deny, + 'assert' => null, + ], + 'byPrivilege' => [], + ], + 'byRole' => [], + ], + 'byResource' => [], + ]; + + private string|Role|null $queriedRole; + private string|Resource|null $queriedResource; /********************* roles ****************d*g**/ @@ -56,67 +49,60 @@ class Permission extends Nette\Object implements IAuthorizator /** * Adds a Role to the list. The most recently added parent * takes precedence over parents that were previously added. - * @param string - * @param string|array + * @param string|list|null $parents * @throws Nette\InvalidArgumentException * @throws Nette\InvalidStateException - * @return self */ - public function addRole($role, $parents = NULL) + public function addRole(string $role, string|array|null $parents = null): static { - $this->checkRole($role, FALSE); + $this->checkRole($role, exists: false); if (isset($this->roles[$role])) { throw new Nette\InvalidStateException("Role '$role' already exists in the list."); } - $roleParents = array(); + $roleParents = []; - if ($parents !== NULL) { + if ($parents !== null) { if (!is_array($parents)) { - $parents = array($parents); + $parents = [$parents]; } foreach ($parents as $parent) { $this->checkRole($parent); - $roleParents[$parent] = TRUE; - $this->roles[$parent]['children'][$role] = TRUE; + $roleParents[$parent] = true; + $this->roles[$parent]['children'][$role] = true; } } - $this->roles[$role] = array( - 'parents' => $roleParents, - 'children' => array(), - ); + $this->roles[$role] = [ + 'parents' => $roleParents, + 'children' => [], + ]; return $this; } /** - * Returns TRUE if the Role exists in the list. - * @param string - * @return bool + * Checks whether the Role exists in the list. */ - public function hasRole($role) + public function hasRole(string $role): bool { - $this->checkRole($role, FALSE); + $this->checkRole($role, exists: false); return isset($this->roles[$role]); } /** * Checks whether Role is valid and exists in the list. - * @param string - * @param bool * @throws Nette\InvalidStateException - * @return void */ - private function checkRole($role, $need = TRUE) + private function checkRole(string $role, bool $exists = true): void { - if (!is_string($role) || $role === '') { + if ($role === '') { throw new Nette\InvalidArgumentException('Role must be a non-empty string.'); - } elseif ($need && !isset($this->roles[$role])) { + } elseif ($exists && !isset($this->roles[$role])) { throw new Nette\InvalidStateException("Role '$role' does not exist."); } } @@ -124,9 +110,9 @@ private function checkRole($role, $need = TRUE) /** * Returns all Roles. - * @return array + * @return list */ - public function getRoles() + public function getRoles(): array { return array_keys($this->roles); } @@ -134,10 +120,9 @@ public function getRoles() /** * Returns existing Role's parents ordered by ascending priority. - * @param string - * @return array + * @return list */ - public function getRoleParents($role) + public function getRoleParents(string $role): array { $this->checkRole($role); return array_keys($this->roles[$role]['parents']); @@ -145,15 +130,11 @@ public function getRoleParents($role) /** - * Returns TRUE if $role inherits from $inherit. If $onlyParents is TRUE, + * Returns true if $role inherits from $inherit. If $onlyParents is true, * then $role must inherit directly from $inherit. - * @param string - * @param string - * @param bool * @throws Nette\InvalidStateException - * @return bool */ - public function roleInheritsFrom($role, $inherit, $onlyParents = FALSE) + public function roleInheritsFrom(string $role, string $inherit, bool $onlyParents = false): bool { $this->checkRole($role); $this->checkRole($inherit); @@ -166,22 +147,20 @@ public function roleInheritsFrom($role, $inherit, $onlyParents = FALSE) foreach ($this->roles[$role]['parents'] as $parent => $foo) { if ($this->roleInheritsFrom($parent, $inherit)) { - return TRUE; + return true; } } - return FALSE; + return false; } /** * Removes the Role from the list. * - * @param string * @throws Nette\InvalidStateException - * @return self */ - public function removeRole($role) + public function removeRole(string $role): static { $this->checkRole($role); @@ -217,12 +196,10 @@ public function removeRole($role) /** * Removes all Roles from the list. - * - * @return self */ - public function removeAllRoles() + public function removeAllRoles(): static { - $this->roles = array(); + $this->roles = []; foreach ($this->rules['allResources']['byRole'] as $roleCurrent => $rules) { unset($this->rules['allResources']['byRole'][$roleCurrent]); @@ -242,61 +219,53 @@ public function removeAllRoles() /** - * Adds a Resource having an identifier unique to the list. + * Adds a Resource to the list. * - * @param string - * @param string * @throws Nette\InvalidArgumentException * @throws Nette\InvalidStateException - * @return self */ - public function addResource($resource, $parent = NULL) + public function addResource(string $resource, ?string $parent = null): static { - $this->checkResource($resource, FALSE); + $this->checkResource($resource, exists: false); if (isset($this->resources[$resource])) { throw new Nette\InvalidStateException("Resource '$resource' already exists in the list."); } - if ($parent !== NULL) { + if ($parent !== null) { $this->checkResource($parent); - $this->resources[$parent]['children'][$resource] = TRUE; + $this->resources[$parent]['children'][$resource] = true; } - $this->resources[$resource] = array( - 'parent' => $parent, - 'children' => array() - ); + $this->resources[$resource] = [ + 'parent' => $parent, + 'children' => [], + ]; return $this; } /** - * Returns TRUE if the Resource exists in the list. - * @param string - * @return bool + * Checks whether the Resource exists in the list. */ - public function hasResource($resource) + public function hasResource(string $resource): bool { - $this->checkResource($resource, FALSE); + $this->checkResource($resource, exists: false); return isset($this->resources[$resource]); } /** * Checks whether Resource is valid and exists in the list. - * @param string - * @param bool * @throws Nette\InvalidStateException - * @return void */ - private function checkResource($resource, $need = TRUE) + private function checkResource(string $resource, bool $exists = true): void { - if (!is_string($resource) || $resource === '') { + if ($resource === '') { throw new Nette\InvalidArgumentException('Resource must be a non-empty string.'); - } elseif ($need && !isset($this->resources[$resource])) { + } elseif ($exists && !isset($this->resources[$resource])) { throw new Nette\InvalidStateException("Resource '$resource' does not exist."); } } @@ -304,69 +273,63 @@ private function checkResource($resource, $need = TRUE) /** * Returns all Resources. - * @return array + * @return list */ - public function getResources() + public function getResources(): array { return array_keys($this->resources); } /** - * Returns TRUE if $resource inherits from $inherit. If $onlyParents is TRUE, + * Returns true if $resource inherits from $inherit. If $onlyParent is true, * then $resource must inherit directly from $inherit. * - * @param string - * @param string - * @param bool * @throws Nette\InvalidStateException - * @return bool */ - public function resourceInheritsFrom($resource, $inherit, $onlyParent = FALSE) + public function resourceInheritsFrom(string $resource, string $inherit, bool $onlyParent = false): bool { $this->checkResource($resource); $this->checkResource($inherit); - if ($this->resources[$resource]['parent'] === NULL) { - return FALSE; + if ($this->resources[$resource]['parent'] === null) { + return false; } $parent = $this->resources[$resource]['parent']; if ($inherit === $parent) { - return TRUE; + return true; } elseif ($onlyParent) { - return FALSE; + return false; } - while ($this->resources[$parent]['parent'] !== NULL) { + while ($this->resources[$parent]['parent'] !== null) { $parent = $this->resources[$parent]['parent']; if ($inherit === $parent) { - return TRUE; + return true; } } - return FALSE; + return false; } /** * Removes a Resource and all of its children. * - * @param string * @throws Nette\InvalidStateException - * @return self */ - public function removeResource($resource) + public function removeResource(string $resource): static { $this->checkResource($resource); $parent = $this->resources[$resource]['parent']; - if ($parent !== NULL) { + if ($parent !== null) { unset($this->resources[$parent]['children'][$resource]); } - $removed = array($resource); + $removed = [$resource]; foreach ($this->resources[$resource]['children'] as $child => $foo) { $this->removeResource($child); $removed[] = $child; @@ -387,9 +350,8 @@ public function removeResource($resource) /** * Removes all Resources. - * @return self */ - public function removeAllResources() + public function removeAllResources(): static { foreach ($this->resources as $resource => $foo) { foreach ($this->rules['byResource'] as $resourceCurrent => $rules) { @@ -399,7 +361,7 @@ public function removeAllResources() } } - $this->resources = array(); + $this->resources = []; return $this; } @@ -409,88 +371,102 @@ public function removeAllResources() /** * 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|array|Permission::ALL roles - * @param string|array|Permission::ALL resources - * @param string|array|Permission::ALL privileges - * @param callable assertion - * @return self + * If $assertion is provided, then it must return true in order for rule to apply. + * @param string|list|null $roles + * @param string|list|null $resources + * @param string|list|null $privileges + * @param callable(self, ?string, ?string, ?string): bool $assertion */ - public function allow($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL, $assertion = NULL) + public function allow( + string|array|null $roles = self::All, + string|array|null $resources = self::All, + string|array|null $privileges = self::All, + ?callable $assertion = null, + ): static { - $this->setRule(TRUE, self::ALLOW, $roles, $resources, $privileges, $assertion); + $this->setRule(true, self::Allow, $roles, $resources, $privileges, $assertion); return $this; } /** * 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|array|Permission::ALL roles - * @param string|array|Permission::ALL resources - * @param string|array|Permission::ALL privileges - * @param callable assertion - * @return self + * If $assertion is provided, then it must return true in order for rule to apply. + * @param string|list|null $roles + * @param string|list|null $resources + * @param string|list|null $privileges + * @param callable(self, ?string, ?string, ?string): bool $assertion */ - public function deny($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL, $assertion = NULL) + public function deny( + string|array|null $roles = self::All, + string|array|null $resources = self::All, + string|array|null $privileges = self::All, + ?callable $assertion = null, + ): static { - $this->setRule(TRUE, self::DENY, $roles, $resources, $privileges, $assertion); + $this->setRule(true, self::Deny, $roles, $resources, $privileges, $assertion); return $this; } /** * Removes "allow" permissions from the list in the context of the given Roles, Resources, and privileges. - * - * @param string|array|Permission::ALL roles - * @param string|array|Permission::ALL resources - * @param string|array|Permission::ALL privileges - * @return self + * @param string|list|null $roles + * @param string|list|null $resources + * @param string|list|null $privileges */ - public function removeAllow($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL) + public function removeAllow( + string|array|null $roles = self::All, + string|array|null $resources = self::All, + string|array|null $privileges = self::All, + ): static { - $this->setRule(FALSE, self::ALLOW, $roles, $resources, $privileges); + $this->setRule(false, self::Allow, $roles, $resources, $privileges); return $this; } /** * Removes "deny" restrictions from the list in the context of the given Roles, Resources, and privileges. - * - * @param string|array|Permission::ALL roles - * @param string|array|Permission::ALL resources - * @param string|array|Permission::ALL privileges - * @return self + * @param string|list|null $roles + * @param string|list|null $resources + * @param string|list|null $privileges */ - public function removeDeny($roles = self::ALL, $resources = self::ALL, $privileges = self::ALL) + public function removeDeny( + string|array|null $roles = self::All, + string|array|null $resources = self::All, + string|array|null $privileges = self::All, + ): static { - $this->setRule(FALSE, self::DENY, $roles, $resources, $privileges); + $this->setRule(false, self::Deny, $roles, $resources, $privileges); return $this; } /** * Performs operations on Access Control List rules. - * @param bool operation add? - * @param bool type - * @param string|array|Permission::ALL roles - * @param string|array|Permission::ALL resources - * @param string|array|Permission::ALL privileges - * @param callable assertion + * @param string|list|null $roles + * @param string|list|null $resources + * @param string|list|null $privileges + * @param callable(self, ?string, ?string, ?string): bool $assertion * @throws Nette\InvalidStateException - * @return self */ - protected function setRule($toAdd, $type, $roles, $resources, $privileges, $assertion = NULL) + protected function setRule( + bool $toAdd, + bool $type, + string|array|null $roles, + string|array|null $resources, + string|array|null $privileges, + ?callable $assertion = null, + ): void { - // ensure that all specified Roles exist; normalize input to array of Roles or NULL - if ($roles === self::ALL) { - $roles = array(self::ALL); + // ensure that all specified Roles exist; normalize input to array of Roles or null + if ($roles === self::All) { + $roles = [self::All]; } else { if (!is_array($roles)) { - $roles = array($roles); + $roles = [$roles]; } foreach ($roles as $role) { @@ -498,13 +474,13 @@ protected function setRule($toAdd, $type, $roles, $resources, $privileges, $asse } } - // ensure that all specified Resources exist; normalize input to array of Resources or NULL - if ($resources === self::ALL) { - $resources = array(self::ALL); + // ensure that all specified Resources exist; normalize input to array of Resources or null + if ($resources === self::All) { + $resources = [self::All]; } else { if (!is_array($resources)) { - $resources = array($resources); + $resources = [$resources]; } foreach ($resources as $resource) { @@ -513,22 +489,22 @@ protected function setRule($toAdd, $type, $roles, $resources, $privileges, $asse } // normalize privileges to array - if ($privileges === self::ALL) { - $privileges = array(); + if ($privileges === self::All) { + $privileges = []; } elseif (!is_array($privileges)) { - $privileges = array($privileges); + $privileges = [$privileges]; } if ($toAdd) { // add to the rules foreach ($resources as $resource) { foreach ($roles as $role) { - $rules = & $this->getRules($resource, $role, TRUE); + $rules = &$this->getRules($resource, $role, create: true); if (count($privileges) === 0) { $rules['allPrivileges']['type'] = $type; $rules['allPrivileges']['assert'] = $assertion; if (!isset($rules['byPrivilege'])) { - $rules['byPrivilege'] = array(); + $rules['byPrivilege'] = []; } } else { foreach ($privileges as $privilege) { @@ -538,27 +514,29 @@ protected function setRule($toAdd, $type, $roles, $resources, $privileges, $asse } } } - } else { // remove from the rules foreach ($resources as $resource) { foreach ($roles as $role) { - $rules = & $this->getRules($resource, $role); - if ($rules === NULL) { + $rules = &$this->getRules($resource, $role); + if ($rules === null) { continue; } + if (count($privileges) === 0) { - if ($resource === self::ALL && $role === self::ALL) { + if ($resource === self::All && $role === self::All) { if ($type === $rules['allPrivileges']['type']) { - $rules = array( - 'allPrivileges' => array( - 'type' => self::DENY, - 'assert' => NULL - ), - 'byPrivilege' => array() - ); + $rules = [ + 'allPrivileges' => [ + 'type' => self::Deny, + 'assert' => null, + ], + 'byPrivilege' => [], + ]; } + continue; } + if ($type === $rules['allPrivileges']['type']) { unset($rules['allPrivileges']); } @@ -574,7 +552,6 @@ protected function setRule($toAdd, $type, $roles, $resources, $privileges, $asse } } } - return $this; } @@ -582,86 +559,88 @@ protected function setRule($toAdd, $type, $roles, $resources, $privileges, $asse /** - * Returns TRUE if and only if the Role has access to [certain $privileges upon] the Resource. + * Returns true if and only if the Role has access to [certain $privileges upon] the Resource. * * This method checks Role inheritance using a depth-first traversal of the Role list. * The highest priority parent (i.e., the parent most recently added) is checked first, * and its respective parents are checked similarly before the lower-priority parents of * the Role are checked. * - * @param string|Permission::ALL|IRole role - * @param string|Permission::ALL|IResource resource - * @param string|Permission::ALL privilege * @throws Nette\InvalidStateException - * @return bool */ - public function isAllowed($role = self::ALL, $resource = self::ALL, $privilege = self::ALL) + public function isAllowed( + string|Role|null $role = self::All, + string|Resource|null $resource = self::All, + string|null $privilege = self::All, + ): bool { $this->queriedRole = $role; - if ($role !== self::ALL) { - if ($role instanceof IRole) { + if ($role !== self::All) { + if ($role instanceof Role) { $role = $role->getRoleId(); } + $this->checkRole($role); } $this->queriedResource = $resource; - if ($resource !== self::ALL) { - if ($resource instanceof IResource) { + if ($resource !== self::All) { + if ($resource instanceof Resource) { $resource = $resource->getResourceId(); } + $this->checkResource($resource); } do { // depth-first search on $role if it is not 'allRoles' pseudo-parent - if ($role !== NULL && NULL !== ($result = $this->searchRolePrivileges($privilege === self::ALL, $role, $resource, $privilege))) { + if ( + $role !== null + && ($result = $this->searchRolePrivileges($privilege === self::All, $role, $resource, $privilege)) !== null + ) { break; } - if ($privilege === self::ALL) { - if ($rules = $this->getRules($resource, self::ALL)) { // look for rule on 'allRoles' psuedo-parent + if ($privilege === self::All) { + if ($rules = $this->getRules($resource, self::All)) { // look for rule on 'allRoles' psuedo-parent foreach ($rules['byPrivilege'] as $privilege => $rule) { - if (self::DENY === ($result = $this->getRuleType($resource, NULL, $privilege))) { + if (($result = $this->getRuleType($resource, null, $privilege)) === self::Deny) { break 2; } } - if (NULL !== ($result = $this->getRuleType($resource, NULL, NULL))) { + + if (($result = $this->getRuleType($resource, null, null)) !== null) { break; } } - } else { - if (NULL !== ($result = $this->getRuleType($resource, NULL, $privilege))) { // look for rule on 'allRoles' pseudo-parent - break; - - } elseif (NULL !== ($result = $this->getRuleType($resource, NULL, NULL))) { - break; - } + } elseif (($result = $this->getRuleType($resource, null, $privilege)) !== null) { // look for rule on 'allRoles' pseudo-parent + break; + } elseif (($result = $this->getRuleType($resource, null, null)) !== null) { + break; } + assert(is_string($resource)); $resource = $this->resources[$resource]['parent']; // try next Resource - } while (TRUE); + } while (true); - $this->queriedRole = $this->queriedResource = NULL; + $this->queriedRole = $this->queriedResource = null; return $result; } /** - * Returns real currently queried Role. Use by assertion. - * @return mixed + * Returns the role currently being queried. Used by assertion callbacks. */ - public function getQueriedRole() + public function getQueriedRole(): string|Role|null { return $this->queriedRole; } /** - * Returns real currently queried Resource. Use by assertion. - * @return mixed + * Returns the resource currently being queried. Used by assertion callbacks. */ - public function getQueriedResource() + public function getQueriedResource(): string|Resource|null { return $this->queriedResource; } @@ -673,123 +652,119 @@ public function getQueriedResource() /** * Performs a depth-first search of the Role DAG, starting at $role, in order to find a rule * allowing/denying $role access to a/all $privilege upon $resource. - * @param bool all (true) or one? - * @param string - * @param string - * @param string only for one - * @return mixed NULL if no applicable rule is found, otherwise returns ALLOW or DENY + * @param bool $all match a rule covering all privileges (true) or the given $privilege (false) */ - private function searchRolePrivileges($all, $role, $resource, $privilege) + private function searchRolePrivileges(bool $all, ?string $role, ?string $resource, ?string $privilege): ?bool { - $dfs = array( - 'visited' => array(), - 'stack' => array($role), - ); + $dfs = [ + 'visited' => [], + 'stack' => [$role], + ]; - while (NULL !== ($role = array_pop($dfs['stack']))) { + while (($role = array_pop($dfs['stack'])) !== null) { if (isset($dfs['visited'][$role])) { continue; } + if ($all) { if ($rules = $this->getRules($resource, $role)) { foreach ($rules['byPrivilege'] as $privilege2 => $rule) { - if (self::DENY === $this->getRuleType($resource, $role, $privilege2)) { - return self::DENY; + if ($this->getRuleType($resource, $role, $privilege2) === self::Deny) { + return self::Deny; } } - if (NULL !== ($type = $this->getRuleType($resource, $role, NULL))) { + + if (($type = $this->getRuleType($resource, $role, null)) !== null) { return $type; } } } else { - if (NULL !== ($type = $this->getRuleType($resource, $role, $privilege))) { + if (($type = $this->getRuleType($resource, $role, $privilege)) !== null) { return $type; - } elseif (NULL !== ($type = $this->getRuleType($resource, $role, NULL))) { + } elseif (($type = $this->getRuleType($resource, $role, null)) !== null) { return $type; } } - $dfs['visited'][$role] = TRUE; + $dfs['visited'][$role] = true; foreach ($this->roles[$role]['parents'] as $roleParent => $foo) { $dfs['stack'][] = $roleParent; } } - return NULL; + + return null; } /** * Returns the rule type associated with the specified Resource, Role, and privilege. - * @param string|Permission::ALL - * @param string|Permission::ALL - * @param string|Permission::ALL - * @return mixed NULL if a rule does not exist or assertion fails, otherwise returns ALLOW or DENY */ - private function getRuleType($resource, $role, $privilege) + private function getRuleType(?string $resource, ?string $role, ?string $privilege): ?bool { if (!$rules = $this->getRules($resource, $role)) { - return NULL; + return null; } - if ($privilege === self::ALL) { + if ($privilege === self::All) { if (isset($rules['allPrivileges'])) { $rule = $rules['allPrivileges']; } else { - return NULL; + return null; } } elseif (!isset($rules['byPrivilege'][$privilege])) { - return NULL; + return null; } else { $rule = $rules['byPrivilege'][$privilege]; } - if ($rule['assert'] === NULL || Nette\Utils\Callback::invoke($rule['assert'], $this, $role, $resource, $privilege)) { + if ($rule['assert'] === null || $rule['assert']($this, $role, $resource, $privilege)) { return $rule['type']; - } elseif ($resource !== self::ALL || $role !== self::ALL || $privilege !== self::ALL) { - return NULL; + } elseif ($resource !== self::All || $role !== self::All || $privilege !== self::All) { + return null; - } elseif (self::ALLOW === $rule['type']) { - return self::DENY; + } elseif ($rule['type'] === self::Allow) { + return self::Deny; } else { - return self::ALLOW; + return self::Allow; } } /** - * 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. - * @param string|Permission::ALL - * @param string|Permission::ALL - * @param bool - * @return array|NULL + * 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($resource, $role, $create = FALSE) + private function &getRules(?string $resource, ?string $role, bool $create = false): ?array { - $null = NULL; - if ($resource === self::ALL) { - $visitor = & $this->rules['allResources']; + $null = null; + if ($resource === self::All) { + $visitor = &$this->rules['allResources']; } else { if (!isset($this->rules['byResource'][$resource])) { if (!$create) { return $null; } - $this->rules['byResource'][$resource] = array(); + + $this->rules['byResource'][$resource] = []; } - $visitor = & $this->rules['byResource'][$resource]; + + $visitor = &$this->rules['byResource'][$resource]; } - if ($role === self::ALL) { + if ($role === self::All) { if (!isset($visitor['allRoles'])) { if (!$create) { return $null; } - $visitor['allRoles']['byPrivilege'] = array(); + + $visitor['allRoles']['byPrivilege'] = []; } + return $visitor['allRoles']; } @@ -797,10 +772,10 @@ private function & getRules($resource, $role, $create = FALSE) if (!$create) { return $null; } - $visitor['byRole'][$role]['byPrivilege'] = array(); + + $visitor['byRole'][$role]['byPrivilege'] = []; } return $visitor['byRole'][$role]; } - } diff --git a/src/Security/Resource.php b/src/Security/Resource.php new file mode 100644 index 00000000..1a3bed4f --- /dev/null +++ b/src/Security/Resource.php @@ -0,0 +1,23 @@ + password - * @param array list of pairs username => role[] - */ - public function __construct(array $userlist, array $usersRoles = array()) - { - $this->userlist = $userlist; - $this->usersRoles = $usersRoles; + public function __construct( + /** @var array */ + #[\SensitiveParameter] + private readonly array $passwords, + /** @var array|null> */ + private readonly array $roles = [], + /** @var array> */ + private readonly array $data = [], + ) { } /** - * Performs an authentication against e.g. database. - * and returns IIdentity on success or throws AuthenticationException - * @return IIdentity + * Authenticates against the in-memory list of users (case-insensitive username). * @throws AuthenticationException */ - public function authenticate(array $credentials) + public function authenticate( + string $username, + #[\SensitiveParameter] + string $password, + ): IIdentity { - list($username, $password) = $credentials; - foreach ($this->userlist as $name => $pass) { + foreach ($this->passwords as $name => $pass) { if (strcasecmp($name, $username) === 0) { - if ((string) $pass === (string) $password) { - return new Identity($name, isset($this->usersRoles[$name]) ? $this->usersRoles[$name] : NULL); + if ($this->verifyPassword($password, $pass)) { + return new SimpleIdentity($name, $this->roles[$name] ?? null, $this->data[$name] ?? []); } else { - throw new AuthenticationException('Invalid password.', self::INVALID_CREDENTIAL); + throw new AuthenticationException('Invalid password.', self::InvalidCredential); } } } - throw new AuthenticationException("User '$username' not found.", self::IDENTITY_NOT_FOUND); + + throw new AuthenticationException("User '$username' not found.", self::IdentityNotFound); } + + protected function verifyPassword(string $password, string $passOrHash): bool + { + return $password === $passOrHash; + } } diff --git a/src/Security/SimpleIdentity.php b/src/Security/SimpleIdentity.php new file mode 100644 index 00000000..ac749cc1 --- /dev/null +++ b/src/Security/SimpleIdentity.php @@ -0,0 +1,143 @@ + $roles + * @property array $data + */ +class SimpleIdentity implements IIdentity +{ + private string|int $id; + + /** @var list */ + private array $roles; + + /** @var array */ + private array $data; + + + /** + * @param string|list|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_string($id) && ctype_digit($id) && (string) ($tmp = (int) $id) === $id ? $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 list $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 list + */ + public function getRoles(): array + { + return $this->roles; + } + + + /** + * Returns 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); + } + + + 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"] ?? []; + } +} diff --git a/src/Security/User.php b/src/Security/User.php index b8d5faae..f9c9742c 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -1,70 +1,80 @@ - $roles + * @property-deprecated ?int $logoutReason + * @property-deprecated Authenticator $authenticator + * @property-deprecated Authorizator $authorizator */ -class User extends Nette\Object +class User { - /** @deprecated */ - const MANUAL = IUserStorage::MANUAL, - INACTIVITY = IUserStorage::INACTIVITY, - BROWSER_CLOSED = IUserStorage::BROWSER_CLOSED; + use Nette\SmartObject; - /** @var string default role for unauthenticated user */ - public $guestRole = 'guest'; + /** Log-out reason */ + public const + LogoutManual = 1, + LogoutInactivity = 2; - /** @var string default role for authenticated user without own identity */ - public $authenticatedRole = 'authenticated'; + /** @deprecated use User::LogoutManual */ + public const LOGOUT_MANUAL = self::LogoutManual; - /** @var array of function(User $sender); Occurs when the user is successfully logged in */ - public $onLoggedIn; + /** @deprecated use User::LogoutManual */ + public const MANUAL = self::LogoutManual; - /** @var array of function(User $sender); Occurs when the user is logged out */ - public $onLoggedOut; + /** @deprecated use User::LogoutInactivity */ + public const LOGOUT_INACTIVITY = self::LogoutInactivity; - /** @var IUserStorage Session storage for current user */ - private $storage; + /** @deprecated use User::LogoutInactivity */ + public const INACTIVITY = self::LogoutInactivity; - /** @var IAuthenticator */ - private $authenticator; + /** role for an unauthenticated user, unless a guest identity provides its own roles */ + public string $guestRole = 'guest'; - /** @var IAuthorizator */ - private $authorizator; + /** default role for authenticated user without own identity */ + public string $authenticatedRole = 'authenticated'; + /** keep identity available (via getIdentity() and getId()) after logout or expiration; depends on the storage implementation */ + public bool $persistIdentity = true; - public function __construct(IUserStorage $storage, IAuthenticator $authenticator = NULL, IAuthorizator $authorizator = NULL) - { - $this->storage = $storage; - $this->authenticator = $authenticator; - $this->authorizator = $authorizator; + /** @var array Occurs when the user is successfully logged in */ + public array $onLoggedIn = []; + + /** @var array Occurs when the user is logged out */ + public array $onLoggedOut = []; + + private ?IIdentity $identity = null; + private ?bool $authenticated = null; + private ?int $logoutReason = null; + private ?IIdentity $guestIdentity = null; + private bool $guestIdentityResolved = false; + + + public function __construct( + private readonly UserStorage $storage, + private ?Authenticator $authenticator = null, + private ?Authorizator $authorizator = null, + ) { } - /** - * @return IUserStorage - */ - public function getStorage() + final public function getStorage(): UserStorage { return $this->storage; } @@ -74,118 +84,188 @@ public function getStorage() /** - * Conducts the authentication process. Parameters are optional. - * @param mixed optional parameter (e.g. username or IIdentity) - * @param mixed optional parameter (e.g. password) - * @return void + * Authenticates the user. Accepts username and password, or an IIdentity directly. + * @param string|IIdentity $username username or identity * @throws AuthenticationException if authentication was not successful */ - public function login($id = NULL, $password = NULL) + public function login( + string|IIdentity $username, + #[\SensitiveParameter] + ?string $password = null, + ): void { - $this->logout(TRUE); - if (!$id instanceof IIdentity) { - $id = $this->getAuthenticator()->authenticate(func_get_args()); + $this->logout(clearIdentity: true); + if ($username instanceof IIdentity) { + $this->identity = $username; + } else { + $authenticator = $this->getAuthenticator(); + $this->identity = $authenticator->authenticate(...func_get_args()); } - $this->storage->setIdentity($id); - $this->storage->setAuthenticated(TRUE); - $this->onLoggedIn($this); + + $id = $this->authenticator instanceof IdentityHandler + ? $this->authenticator->sleepIdentity($this->identity) + : $this->identity; + + $this->storage->saveAuthentication($id); + $this->authenticated = true; + $this->logoutReason = null; + Arrays::invoke($this->onLoggedIn, $this); } /** - * Logs out the user from the current session. - * @param bool clear the identity from persistent storage? - * @return void + * Logs out the user from the current session. The identity is kept available afterwards, + * unless $clearIdentity is set or the $persistIdentity property is disabled. */ - public function logout($clearIdentity = FALSE) + final public function logout(bool $clearIdentity = false): void { - if ($this->isLoggedIn()) { - $this->onLoggedOut($this); - $this->storage->setAuthenticated(FALSE); - } - if ($clearIdentity) { - $this->storage->setIdentity(NULL); + $clearIdentity = $clearIdentity || !$this->persistIdentity; + $logged = $this->isLoggedIn(); + $this->storage->clearAuthentication($clearIdentity); + $this->authenticated = false; + $this->logoutReason = self::LogoutManual; + if ($logged) { + Arrays::invoke($this->onLoggedOut, $this); } + + $this->identity = $clearIdentity ? null : $this->identity; } /** - * Is this user authenticated? - * @return bool + * Checks whether the user is authenticated. */ - public function isLoggedIn() + final public function isLoggedIn(): bool { - return $this->storage->isAuthenticated(); + $this->loadStoredData(); + return (bool) $this->authenticated; } /** - * Returns current user identity, if any. - * @return IIdentity|NULL + * Returns the user identity. When not logged in, this is the retained identity (unless $persistIdentity + * is disabled) or a guest identity if the authenticator provides one; null otherwise. */ - public function getIdentity() + final public function getIdentity(): ?IIdentity + { + $this->loadStoredData(); + return $this->identity ?? $this->resolveGuestIdentity(); + } + + + private function loadStoredData(): void { - return $this->storage->getIdentity(); + if ($this->authenticated !== null) { + return; + } + + (function (bool $state, ?IIdentity $id, ?int $reason) use (&$identity) { + $identity = $id; + $this->authenticated = $state; + $this->logoutReason = $reason; + })(...$this->storage->getState()); + + $identity = $identity && $this->authenticator instanceof IdentityHandler + ? $this->authenticator->wakeupIdentity($identity) + : $identity; + $this->authenticated = $this->authenticated && $identity !== null; + $this->identity = !$this->authenticated && !$this->persistIdentity ? null : $identity; + } + + + /** Returns the guest identity provided by the IdentityHandler authenticator, or null. */ + private function resolveGuestIdentity(): ?IIdentity + { + if (!$this->guestIdentityResolved) { + $this->guestIdentityResolved = true; + $this->guestIdentity = $this->authenticator instanceof IdentityHandler + ? $this->authenticator->getGuestIdentity() + : null; + } + + return $this->guestIdentity; } /** - * Returns current user ID, if any. - * @return mixed + * Returns the ID of the identity returned by getIdentity(), so it may be the retained or guest + * identity's ID even when not logged in; null if there is no identity. */ - public function getId() + public function getId(): string|int|null { $identity = $this->getIdentity(); - return $identity ? $identity->getId() : NULL; + return $identity?->getId(); + } + + + /** + * Discards the cached authentication state and identity, forcing a reload on next access. + */ + final public function refreshStorage(): void + { + $this->identity = $this->authenticated = $this->logoutReason = null; + $this->guestIdentity = null; + $this->guestIdentityResolved = false; } /** * Sets authentication handler. - * @return self */ - public function setAuthenticator(IAuthenticator $handler) + public function setAuthenticator(Authenticator $handler): static { $this->authenticator = $handler; + $this->guestIdentityResolved = false; return $this; } /** * Returns authentication handler. - * @return IAuthenticator */ - public function getAuthenticator($need = TRUE) + final public function getAuthenticator(): Authenticator { - if ($need && !$this->authenticator) { + if (!$this->authenticator) { throw new Nette\InvalidStateException('Authenticator has not been set.'); } + return $this->authenticator; } /** - * Enables log out after inactivity. - * @param string|int|DateTime number of seconds or timestamp - * @param bool log out when the browser is closed? - * @param bool clear the identity from persistent storage? - * @return self + * Returns authentication handler, or null if none is set. */ - public function setExpiration($time, $whenBrowserIsClosed = TRUE, $clearIdentity = FALSE) + final public function getAuthenticatorIfExists(): ?Authenticator + { + return $this->authenticator; + } + + + /** @deprecated */ + final public function hasAuthenticator(): bool { - $flags = ($whenBrowserIsClosed ? IUserStorage::BROWSER_CLOSED : 0) | ($clearIdentity ? IUserStorage::CLEAR_IDENTITY : 0); - $this->storage->setExpiration($time, $flags); + return (bool) $this->authenticator; + } + + + /** + * Enables log out after inactivity (like '20 minutes'). The identity is kept available afterwards, + * unless $clearIdentity is set or the $persistIdentity property is disabled. + */ + public function setExpiration(?string $expire, bool $clearIdentity = false): static + { + $this->storage->setExpiration($expire, $clearIdentity || !$this->persistIdentity); return $this; } /** - * Why was user logged out? - * @return int + * Returns the logout reason: LogoutManual or LogoutInactivity, or null if not applicable. */ - public function getLogoutReason() + final public function getLogoutReason(): ?int { - return $this->storage->getLogoutReason(); + return $this->logoutReason; } @@ -193,55 +273,56 @@ public function getLogoutReason() /** - * Returns a list of effective roles that a user has been granted. - * @return array + * Returns effective roles derived from the login state, not from the (possibly retained) identity. + * Logged in: the identity's roles, or authenticatedRole. Otherwise: the guest identity's roles, or guestRole. + * @return list */ - public function getRoles() + public function getRoles(): array { if (!$this->isLoggedIn()) { - return array($this->guestRole); + return $this->resolveGuestIdentity()?->getRoles() ?? [$this->guestRole]; } $identity = $this->getIdentity(); - return $identity && $identity->getRoles() ? $identity->getRoles() : array($this->authenticatedRole); + return $identity?->getRoles() ?? [$this->authenticatedRole]; } /** - * Is a user in the specified effective role? - * @param string - * @return bool + * Checks whether the user has the specified effective role. */ - public function isInRole($role) + final public function isInRole(string $role): bool { - return in_array($role, $this->getRoles(), TRUE); + foreach ($this->getRoles() as $r) { + if ($role === ($r instanceof Role ? $r->getRoleId() : $r)) { + return true; + } + } + + return false; } /** - * Has a user effective access to the Resource? - * If $resource is NULL, then the query applies to all resources. - * @param string resource - * @param string privilege - * @return bool + * Checks whether the user has access to the given resource and privilege. + * Null means all resources or all privileges. */ - public function isAllowed($resource = IAuthorizator::ALL, $privilege = IAuthorizator::ALL) + public function isAllowed(mixed $resource = Authorizator::All, mixed $privilege = Authorizator::All): bool { foreach ($this->getRoles() as $role) { if ($this->getAuthorizator()->isAllowed($role, $resource, $privilege)) { - return TRUE; + return true; } } - return FALSE; + return false; } /** * Sets authorization handler. - * @return self */ - public function setAuthorizator(IAuthorizator $handler) + public function setAuthorizator(Authorizator $handler): static { $this->authorizator = $handler; return $this; @@ -250,14 +331,29 @@ public function setAuthorizator(IAuthorizator $handler) /** * Returns current authorization handler. - * @return IAuthorizator */ - public function getAuthorizator($need = TRUE) + final public function getAuthorizator(): Authorizator { - if ($need && !$this->authorizator) { + if (!$this->authorizator) { throw new Nette\InvalidStateException('Authorizator has not been set.'); } + + return $this->authorizator; + } + + + /** + * Returns authorization handler, or null if none is set. + */ + final public function getAuthorizatorIfExists(): ?Authorizator + { return $this->authorizator; } + + /** @deprecated */ + final public function hasAuthorizator(): bool + { + return (bool) $this->authorizator; + } } diff --git a/src/Security/UserStorage.php b/src/Security/UserStorage.php new file mode 100644 index 00000000..82e83c7f --- /dev/null +++ b/src/Security/UserStorage.php @@ -0,0 +1,42 @@ +addExtension('foo', new HttpExtension); +$compiler->addExtension('bar', new SessionExtension); +$compiler->addExtension('security', new SecurityExtension); + +$loader = new Nette\DI\Config\Loader; +$config = $loader->load(Tester\FileMock::create(' +security: + users: + john: john123 + admin: {password: admin123, roles: [admin, user]} + user: {password: user123} + moderator: {password: moderator123, roles: moderator} + dynamic1: ::trim(xxx) + dynamic2: {password: ::trim(xxx)} +', 'neon')); + +eval($compiler->addConfig($config)->compile()); +$container = new Container; + +$authenticator = $container->getService('security.authenticator'); +Assert::type(Nette\Security\SimpleAuthenticator::class, $authenticator); +Assert::same($authenticator, $container->getService('nette.authenticator')); + +$userList = [ + 'john' => 'john123', + 'admin' => 'admin123', + 'user' => 'user123', + 'moderator' => 'moderator123', + 'dynamic1' => 'xxx', + 'dynamic2' => 'xxx', +]; +$expectedRoles = [ + 'john' => [], + 'admin' => ['admin', 'user'], + 'user' => [], + 'moderator' => ['moderator'], + 'dynamic1' => [], + 'dynamic2' => [], +]; + +foreach ($userList as $username => $password) { + $identity = $authenticator->authenticate($username, $password); + Assert::equal($username, $identity->getId()); + Assert::equal($expectedRoles[$username], $identity->getRoles()); +} diff --git a/tests/Security.DI/SecurityExtension.authorizator.phpt b/tests/Security.DI/SecurityExtension.authorizator.phpt new file mode 100644 index 00000000..abdf5690 --- /dev/null +++ b/tests/Security.DI/SecurityExtension.authorizator.phpt @@ -0,0 +1,46 @@ +addExtension('foo', new HttpExtension); +$compiler->addExtension('bar', new SessionExtension); +$compiler->addExtension('security', new SecurityExtension); + +$loader = new Nette\DI\Config\Loader; +$config = $loader->load(Tester\FileMock::create(' +security: + roles: + guest: + member: [guest] + resources: + item: + article: item +', 'neon')); + +eval($compiler->addConfig($config)->compile()); +$container = new Container; + +$authorizator = $container->getService('security.authorizator'); +Assert::type(Nette\Security\Permission::class, $authorizator); +Assert::same($authorizator, $container->getService('nette.authorizator')); + +Assert::same(['guest', 'member'], $authorizator->getRoles()); +Assert::same([], $authorizator->getRoleParents('guest')); +Assert::same(['guest'], $authorizator->getRoleParents('member')); + +Assert::same(['item', 'article'], $authorizator->getResources()); +Assert::false($authorizator->resourceInheritsFrom('item', 'article')); +Assert::true($authorizator->resourceInheritsFrom('article', 'item')); diff --git a/tests/Security.DI/SecurityExtension.cookieStorage.phpt b/tests/Security.DI/SecurityExtension.cookieStorage.phpt new file mode 100644 index 00000000..db4fed31 --- /dev/null +++ b/tests/Security.DI/SecurityExtension.cookieStorage.phpt @@ -0,0 +1,48 @@ +addExtension('http', new HttpExtension); +$compiler->addExtension('session', new SessionExtension); +$compiler->addExtension('security', new SecurityExtension); + +$loader = new Nette\DI\Config\Loader; +$config = $loader->load(Tester\FileMock::create(' +security: + authentication: + storage: cookie + expiration: 1 week + cookieName: abc + cookieDomain: domain + cookieSamesite: Strict + +services: + http.request: Nette\Http\Request(Nette\Http\UrlScript("http://www.nette.org")) +', 'neon')); + +eval($compiler->addConfig($config)->compile()); +$container = new Container; + +$storage = $container->getService('security.userStorage'); +$user = $container->getService('security.user'); +Assert::type(Nette\Bridges\SecurityHttp\CookieStorage::class, $storage); + +Assert::with($storage, function () { + Assert::same('1 week', $this->cookieExpiration); + Assert::same('abc', $this->cookieName); + Assert::same('nette.org', $this->cookieDomain); + Assert::same('Strict', $this->cookieSameSite); +}); diff --git a/tests/Security.DI/SecurityExtension.passwords.phpt b/tests/Security.DI/SecurityExtension.passwords.phpt new file mode 100644 index 00000000..66d3f765 --- /dev/null +++ b/tests/Security.DI/SecurityExtension.passwords.phpt @@ -0,0 +1,25 @@ +addExtension('foo', new HttpExtension); +$compiler->addExtension('bar', new SessionExtension); +$compiler->addExtension('security', new SecurityExtension); + +eval($compiler->compile()); +$container = new Container; + +Assert::type(Nette\Security\Passwords::class, $container->getService('security.passwords')); diff --git a/tests/Security.DI/SecurityExtension.persistIdentity.phpt b/tests/Security.DI/SecurityExtension.persistIdentity.phpt new file mode 100644 index 00000000..4c839ca2 --- /dev/null +++ b/tests/Security.DI/SecurityExtension.persistIdentity.phpt @@ -0,0 +1,49 @@ +addExtension('foo', new HttpExtension); + $compiler->addExtension('bar', new SessionExtension); + $compiler->addExtension('security', new SecurityExtension); + $compiler->setClassName('ContainerDefault'); + + eval($compiler->compile()); + $container = new ContainerDefault; + + Assert::true($container->getService('security.user')->persistIdentity); +}); + + +test('disabled via configuration', function () { + $compiler = new DI\Compiler; + $compiler->addExtension('foo', new HttpExtension); + $compiler->addExtension('bar', new SessionExtension); + $compiler->addExtension('security', new SecurityExtension); + $compiler->setClassName('ContainerDisabled'); + + $loader = new Nette\DI\Config\Loader; + $config = $loader->load(Tester\FileMock::create(' +security: + authentication: + persistIdentity: false +', 'neon')); + + eval($compiler->addConfig($config)->compile()); + $container = new ContainerDisabled; + + Assert::false($container->getService('security.user')->persistIdentity); +}); diff --git a/tests/Security.DI/SecurityExtension.sessionStorage.phpt b/tests/Security.DI/SecurityExtension.sessionStorage.phpt new file mode 100644 index 00000000..923a8031 --- /dev/null +++ b/tests/Security.DI/SecurityExtension.sessionStorage.phpt @@ -0,0 +1,34 @@ +addExtension('foo', new HttpExtension); +$compiler->addExtension('session', new SessionExtension); +$compiler->addExtension('security', new SecurityExtension); + +$loader = new Nette\DI\Config\Loader; +$config = $loader->load(Tester\FileMock::create(' +session: + expiration: 1 year + +security: + authentication: + storage: session + expiration: 1 week +', 'neon')); + +eval($compiler->addConfig($config)->compile()); +$container = new Container; + +$storage = $container->getService('security.userStorage'); +$user = $container->getService('security.user'); +Assert::type(Nette\Bridges\SecurityHttp\SessionStorage::class, $storage); diff --git a/tests/Security.DI/SecurityExtension.user.phpt b/tests/Security.DI/SecurityExtension.user.phpt new file mode 100644 index 00000000..cc4e0de0 --- /dev/null +++ b/tests/Security.DI/SecurityExtension.user.phpt @@ -0,0 +1,30 @@ +addExtension('foo', new HttpExtension); +$compiler->addExtension('bar', new SessionExtension); +$compiler->addExtension('security', new SecurityExtension); + +eval($compiler->compile()); +$container = new Container; + +Assert::type(Nette\Bridges\SecurityHttp\SessionStorage::class, $container->getService('security.userStorage')); +Assert::type(Nette\Security\User::class, $container->getService('security.user')); + +// aliases +Assert::same($container->getService('security.userStorage'), $container->getService('nette.userStorage')); +Assert::same($container->getService('security.user'), $container->getService('user')); diff --git a/tests/Security.Http/CookieStorage.authentication.phpt b/tests/Security.Http/CookieStorage.authentication.phpt new file mode 100644 index 00000000..eb7047dd --- /dev/null +++ b/tests/Security.Http/CookieStorage.authentication.phpt @@ -0,0 +1,29 @@ + $storage->saveAuthentication(new SimpleIdentity('short')), + LogicException::class, +); + +// correct id +$id = '123456789123456'; +$response->expects()->setCookie('userid', $id, null, null, null, false, true, 'Lax'); +$storage->saveAuthentication(new SimpleIdentity($id)); +Assert::equal([true, new SimpleIdentity($id), null], $storage->getState()); +Mockery::close(); + +// clear id +$response->expects()->deleteCookie('userid', null, null); +$storage->clearAuthentication(true); +Assert::same([false, null, null], $storage->getState()); +Mockery::close(); diff --git a/tests/Security.Http/CookieStorage.getState.phpt b/tests/Security.Http/CookieStorage.getState.phpt new file mode 100644 index 00000000..a9c70072 --- /dev/null +++ b/tests/Security.Http/CookieStorage.getState.phpt @@ -0,0 +1,30 @@ +getState()); + +// short id +$request = new Nette\Http\Request(new Nette\Http\UrlScript, cookies: ['userid' => 'short']); +$storage = new CookieStorage($request, $response); +Assert::same([false, null, null], $storage->getState()); + +// correct id +$id = '123456789123456'; +$request = new Nette\Http\Request(new Nette\Http\UrlScript, cookies: ['userid' => $id]); +$storage = new CookieStorage($request, $response); +Assert::equal([true, new SimpleIdentity($id), null], $storage->getState()); + +// custom cookie +$request = new Nette\Http\Request(new Nette\Http\UrlScript, cookies: ['foo' => $id]); +$storage = new CookieStorage($request, $response); +$storage->setCookieParameters('foo'); +Assert::equal([true, new SimpleIdentity($id), null], $storage->getState()); diff --git a/tests/Security.Http/SessionStorage.expiration.phpt b/tests/Security.Http/SessionStorage.expiration.phpt new file mode 100644 index 00000000..13792ef1 --- /dev/null +++ b/tests/Security.Http/SessionStorage.expiration.phpt @@ -0,0 +1,132 @@ +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/MockUserStorage.php b/tests/Security/MockUserStorage.php index 4a9824c7..0f154314 100644 --- a/tests/Security/MockUserStorage.php +++ b/tests/Security/MockUserStorage.php @@ -1,34 +1,33 @@ -auth = $state; - } - function isAuthenticated() + public function saveAuthentication(Nette\Security\IIdentity $identity): void { - return $this->auth; + $this->auth = true; + $this->identity = $identity; } - function setIdentity(Nette\Security\IIdentity $identity = NULL) + + public function clearAuthentication(bool $clearIdentity): void { - $this->identity = $identity; + $this->auth = false; + $this->identity = $clearIdentity ? null : $this->identity; } - function getIdentity() + + public function getState(): array { - return $this->identity; + return [$this->auth, $this->identity, null]; } - function setExpiration($time, $flags = 0) - {} - - function getLogoutReason() - {} + public function setExpiration(?string $expire, bool $clearIdentity): void + { + } } diff --git a/tests/Security/Passwords.hash().phpt b/tests/Security/Passwords.hash().phpt index af1e511c..dabfc6a2 100644 --- a/tests/Security/Passwords.hash().phpt +++ b/tests/Security/Passwords.hash().phpt @@ -1,40 +1,34 @@ -hash('my-password')), ); Assert::truthy( - preg_match('#^\$2y\$05\$123456789012345678901.{32}\z#', - $h = Passwords::hash('dg', array('cost' => 5, 'salt' => '1234567890123456789012'))) + preg_match('#^\$2y\$05\$.{53}\z#', (new Passwords(PASSWORD_BCRYPT, ['cost' => 5]))->hash('dg')), ); -echo $h; -$hash = Passwords::hash('dg'); -Assert::same( $hash, crypt('dg', $hash) ); +$hash = (new Passwords(PASSWORD_BCRYPT))->hash('dg'); +Assert::same($hash, crypt('dg', $hash)); +Assert::exception( + fn() => (new Passwords(PASSWORD_BCRYPT, ['cost' => 3]))->hash('dg'), + ValueError::class, +); -Assert::exception(function() { - Passwords::hash('dg', array('cost' => 3)); -}, 'Nette\InvalidArgumentException', 'Cost must be in range 4-31, 3 given.'); - -Assert::exception(function() { - Passwords::hash('dg', array('cost' => 32)); -}, 'Nette\InvalidArgumentException', 'Cost must be in range 4-31, 32 given.'); - -Assert::exception(function() { - Passwords::hash('dg', array('salt' => 'abc')); -}, 'Nette\InvalidArgumentException', 'Salt must be 22 characters long, 3 given.'); +Assert::exception( + fn() => (new Passwords)->hash(''), + Nette\InvalidArgumentException::class, + 'Password can not be empty.', +); diff --git a/tests/Security/Passwords.needsRehash().phpt b/tests/Security/Passwords.needsRehash().phpt index d86a4ce5..652da5f2 100644 --- a/tests/Security/Passwords.needsRehash().phpt +++ b/tests/Security/Passwords.needsRehash().phpt @@ -1,16 +1,104 @@ - 5))); +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/Passwords.verify().phpt b/tests/Security/Passwords.verify().phpt index e2ad0ddf..e9eaf92b 100644 --- a/tests/Security/Passwords.verify().phpt +++ b/tests/Security/Passwords.verify().phpt @@ -1,17 +1,16 @@ -verify('dg', '$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); +Assert::true((new Passwords)->verify('dg', '$2x$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); +Assert::false((new Passwords)->verify('dgx', '$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); diff --git a/tests/Security/Permission.AssertionWithQueried.phpt b/tests/Security/Permission.AssertionWithQueried.phpt new file mode 100644 index 00000000..f8ce997f --- /dev/null +++ b/tests/Security/Permission.AssertionWithQueried.phpt @@ -0,0 +1,313 @@ +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/Permission.CMSExample.phpt b/tests/Security/Permission.CMSExample.phpt index b99598f4..fb211ed8 100644 --- a/tests/Security/Permission.CMSExample.phpt +++ b/tests/Security/Permission.CMSExample.phpt @@ -1,11 +1,11 @@ -addRole('editor', 'staff'); // editor inherits permissions from staff $acl->addRole('administrator'); // Guest may only view content -$acl->allow('guest', NULL, 'view'); +$acl->allow('guest', null, 'view'); // Staff inherits view privilege from guest, but also needs additional privileges -$acl->allow('staff', NULL, array('edit', 'submit', 'revise')); +$acl->allow('staff', null, ['edit', 'submit', 'revise']); // Editor inherits view, edit, submit, and revise privileges, but also needs additional privileges -$acl->allow('editor', NULL, array('publish', 'archive', 'delete')); +$acl->allow('editor', null, ['publish', 'archive', 'delete']); // Administrator inherits nothing but is allowed all privileges $acl->allow('administrator'); // Access control checks based on above permission sets -Assert::true( $acl->isAllowed('guest', NULL, 'view') ); -Assert::false( $acl->isAllowed('guest', NULL, 'edit') ); -Assert::false( $acl->isAllowed('guest', NULL, 'submit') ); -Assert::false( $acl->isAllowed('guest', NULL, 'revise') ); -Assert::false( $acl->isAllowed('guest', NULL, 'publish') ); -Assert::false( $acl->isAllowed('guest', NULL, 'archive') ); -Assert::false( $acl->isAllowed('guest', NULL, 'delete') ); -Assert::false( $acl->isAllowed('guest', NULL, 'unknown') ); -Assert::false( $acl->isAllowed('guest') ); - -Assert::true( $acl->isAllowed('staff', NULL, 'view') ); -Assert::true( $acl->isAllowed('staff', NULL, 'edit') ); -Assert::true( $acl->isAllowed('staff', NULL, 'submit') ); -Assert::true( $acl->isAllowed('staff', NULL, 'revise') ); -Assert::false( $acl->isAllowed('staff', NULL, 'publish') ); -Assert::false( $acl->isAllowed('staff', NULL, 'archive') ); -Assert::false( $acl->isAllowed('staff', NULL, 'delete') ); -Assert::false( $acl->isAllowed('staff', NULL, 'unknown') ); -Assert::false( $acl->isAllowed('staff') ); - -Assert::true( $acl->isAllowed('editor', NULL, 'view') ); -Assert::true( $acl->isAllowed('editor', NULL, 'edit') ); -Assert::true( $acl->isAllowed('editor', NULL, 'submit') ); -Assert::true( $acl->isAllowed('editor', NULL, 'revise') ); -Assert::true( $acl->isAllowed('editor', NULL, 'publish') ); -Assert::true( $acl->isAllowed('editor', NULL, 'archive') ); -Assert::true( $acl->isAllowed('editor', NULL, 'delete') ); -Assert::false( $acl->isAllowed('editor', NULL, 'unknown') ); -Assert::false( $acl->isAllowed('editor') ); - -Assert::true( $acl->isAllowed('administrator', NULL, 'view') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'edit') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'submit') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'revise') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'publish') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'archive') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'delete') ); -Assert::true( $acl->isAllowed('administrator', NULL, 'unknown') ); -Assert::true( $acl->isAllowed('administrator') ); +Assert::true($acl->isAllowed('guest', null, 'view')); +Assert::false($acl->isAllowed('guest', null, 'edit')); +Assert::false($acl->isAllowed('guest', null, 'submit')); +Assert::false($acl->isAllowed('guest', null, 'revise')); +Assert::false($acl->isAllowed('guest', null, 'publish')); +Assert::false($acl->isAllowed('guest', null, 'archive')); +Assert::false($acl->isAllowed('guest', null, 'delete')); +Assert::false($acl->isAllowed('guest', null, 'unknown')); +Assert::false($acl->isAllowed('guest')); + +Assert::true($acl->isAllowed('staff', null, 'view')); +Assert::true($acl->isAllowed('staff', null, 'edit')); +Assert::true($acl->isAllowed('staff', null, 'submit')); +Assert::true($acl->isAllowed('staff', null, 'revise')); +Assert::false($acl->isAllowed('staff', null, 'publish')); +Assert::false($acl->isAllowed('staff', null, 'archive')); +Assert::false($acl->isAllowed('staff', null, 'delete')); +Assert::false($acl->isAllowed('staff', null, 'unknown')); +Assert::false($acl->isAllowed('staff')); + +Assert::true($acl->isAllowed('editor', null, 'view')); +Assert::true($acl->isAllowed('editor', null, 'edit')); +Assert::true($acl->isAllowed('editor', null, 'submit')); +Assert::true($acl->isAllowed('editor', null, 'revise')); +Assert::true($acl->isAllowed('editor', null, 'publish')); +Assert::true($acl->isAllowed('editor', null, 'archive')); +Assert::true($acl->isAllowed('editor', null, 'delete')); +Assert::false($acl->isAllowed('editor', null, 'unknown')); +Assert::false($acl->isAllowed('editor')); + +Assert::true($acl->isAllowed('administrator', null, 'view')); +Assert::true($acl->isAllowed('administrator', null, 'edit')); +Assert::true($acl->isAllowed('administrator', null, 'submit')); +Assert::true($acl->isAllowed('administrator', null, 'revise')); +Assert::true($acl->isAllowed('administrator', null, 'publish')); +Assert::true($acl->isAllowed('administrator', null, 'archive')); +Assert::true($acl->isAllowed('administrator', null, 'delete')); +Assert::true($acl->isAllowed('administrator', null, 'unknown')); +Assert::true($acl->isAllowed('administrator')); // Some checks on specific areas, which inherit access controls from the root ACL node $acl->addResource('newsletter'); @@ -78,14 +78,14 @@ $acl->addResource('gallery'); $acl->addResource('profiles', 'gallery'); $acl->addResource('config'); $acl->addResource('hosts', 'config'); -Assert::true( $acl->isAllowed('guest', 'pending', 'view') ); -Assert::true( $acl->isAllowed('staff', 'profiles', 'revise') ); -Assert::true( $acl->isAllowed('staff', 'pending', 'view') ); -Assert::true( $acl->isAllowed('staff', 'pending', 'edit') ); -Assert::false( $acl->isAllowed('staff', 'pending', 'publish') ); -Assert::false( $acl->isAllowed('staff', 'pending') ); -Assert::false( $acl->isAllowed('editor', 'hosts', 'unknown') ); -Assert::true( $acl->isAllowed('administrator', 'pending') ); +Assert::true($acl->isAllowed('guest', 'pending', 'view')); +Assert::true($acl->isAllowed('staff', 'profiles', 'revise')); +Assert::true($acl->isAllowed('staff', 'pending', 'view')); +Assert::true($acl->isAllowed('staff', 'pending', 'edit')); +Assert::false($acl->isAllowed('staff', 'pending', 'publish')); +Assert::false($acl->isAllowed('staff', 'pending')); +Assert::false($acl->isAllowed('editor', 'hosts', 'unknown')); +Assert::true($acl->isAllowed('administrator', 'pending')); // Add a new group, marketing, which bases its permissions on staff $acl->addRole('marketing', 'staff'); @@ -93,56 +93,56 @@ $acl->addRole('marketing', 'staff'); // Refine the privilege sets for more specific needs // Allow marketing to publish and archive newsletters -$acl->allow('marketing', 'newsletter', array('publish', 'archive')); +$acl->allow('marketing', 'newsletter', ['publish', 'archive']); // Allow marketing to publish and archive latest news $acl->addResource('news'); $acl->addResource('latest', 'news'); -$acl->allow('marketing', 'latest', array('publish', 'archive')); +$acl->allow('marketing', 'latest', ['publish', 'archive']); // Deny staff (and marketing, by inheritance) rights to revise latest news $acl->deny('staff', 'latest', 'revise'); // Deny everyone access to archive news announcements $acl->addResource('announcement', 'news'); -$acl->deny(NULL, 'announcement', 'archive'); +$acl->deny(null, 'announcement', 'archive'); // Access control checks for the above refined permission sets -Assert::true( $acl->isAllowed('marketing', NULL, 'view') ); -Assert::true( $acl->isAllowed('marketing', NULL, 'edit') ); -Assert::true( $acl->isAllowed('marketing', NULL, 'submit') ); -Assert::true( $acl->isAllowed('marketing', NULL, 'revise') ); -Assert::false( $acl->isAllowed('marketing', NULL, 'publish') ); -Assert::false( $acl->isAllowed('marketing', NULL, 'archive') ); -Assert::false( $acl->isAllowed('marketing', NULL, 'delete') ); -Assert::false( $acl->isAllowed('marketing', NULL, 'unknown') ); -Assert::false( $acl->isAllowed('marketing') ); - -Assert::true( $acl->isAllowed('marketing', 'newsletter', 'publish') ); -Assert::false( $acl->isAllowed('staff', 'pending', 'publish') ); -Assert::true( $acl->isAllowed('marketing', 'pending', 'publish') ); -Assert::true( $acl->isAllowed('marketing', 'newsletter', 'archive') ); -Assert::false( $acl->isAllowed('marketing', 'newsletter', 'delete') ); -Assert::false( $acl->isAllowed('marketing', 'newsletter') ); - -Assert::true( $acl->isAllowed('marketing', 'latest', 'publish') ); -Assert::true( $acl->isAllowed('marketing', 'latest', 'archive') ); -Assert::false( $acl->isAllowed('marketing', 'latest', 'delete') ); -Assert::false( $acl->isAllowed('marketing', 'latest', 'revise') ); -Assert::false( $acl->isAllowed('marketing', 'latest') ); - -Assert::false( $acl->isAllowed('marketing', 'announcement', 'archive') ); -Assert::false( $acl->isAllowed('staff', 'announcement', 'archive') ); -Assert::false( $acl->isAllowed('administrator', 'announcement', 'archive') ); - -Assert::false( $acl->isAllowed('staff', 'latest', 'publish') ); -Assert::false( $acl->isAllowed('editor', 'announcement', 'archive') ); +Assert::true($acl->isAllowed('marketing', null, 'view')); +Assert::true($acl->isAllowed('marketing', null, 'edit')); +Assert::true($acl->isAllowed('marketing', null, 'submit')); +Assert::true($acl->isAllowed('marketing', null, 'revise')); +Assert::false($acl->isAllowed('marketing', null, 'publish')); +Assert::false($acl->isAllowed('marketing', null, 'archive')); +Assert::false($acl->isAllowed('marketing', null, 'delete')); +Assert::false($acl->isAllowed('marketing', null, 'unknown')); +Assert::false($acl->isAllowed('marketing')); + +Assert::true($acl->isAllowed('marketing', 'newsletter', 'publish')); +Assert::false($acl->isAllowed('staff', 'pending', 'publish')); +Assert::true($acl->isAllowed('marketing', 'pending', 'publish')); +Assert::true($acl->isAllowed('marketing', 'newsletter', 'archive')); +Assert::false($acl->isAllowed('marketing', 'newsletter', 'delete')); +Assert::false($acl->isAllowed('marketing', 'newsletter')); + +Assert::true($acl->isAllowed('marketing', 'latest', 'publish')); +Assert::true($acl->isAllowed('marketing', 'latest', 'archive')); +Assert::false($acl->isAllowed('marketing', 'latest', 'delete')); +Assert::false($acl->isAllowed('marketing', 'latest', 'revise')); +Assert::false($acl->isAllowed('marketing', 'latest')); + +Assert::false($acl->isAllowed('marketing', 'announcement', 'archive')); +Assert::false($acl->isAllowed('staff', 'announcement', 'archive')); +Assert::false($acl->isAllowed('administrator', 'announcement', 'archive')); + +Assert::false($acl->isAllowed('staff', 'latest', 'publish')); +Assert::false($acl->isAllowed('editor', 'announcement', 'archive')); // Remove some previous permission specifications // Marketing can no longer publish and archive newsletters -$acl->removeAllow('marketing', 'newsletter', array('publish', 'archive')); +$acl->removeAllow('marketing', 'newsletter', ['publish', 'archive']); // Marketing can no longer archive the latest news $acl->removeAllow('marketing', 'latest', 'archive'); @@ -152,19 +152,19 @@ $acl->removeDeny('staff', 'latest', 'revise'); // Access control checks for the above refinements -Assert::false( $acl->isAllowed('marketing', 'newsletter', 'publish') ); -Assert::false( $acl->isAllowed('marketing', 'newsletter', 'archive') ); +Assert::false($acl->isAllowed('marketing', 'newsletter', 'publish')); +Assert::false($acl->isAllowed('marketing', 'newsletter', 'archive')); -Assert::false( $acl->isAllowed('marketing', 'latest', 'archive') ); +Assert::false($acl->isAllowed('marketing', 'latest', 'archive')); -Assert::true( $acl->isAllowed('staff', 'latest', 'revise') ); -Assert::true( $acl->isAllowed('marketing', 'latest', 'revise') ); +Assert::true($acl->isAllowed('staff', 'latest', 'revise')); +Assert::true($acl->isAllowed('marketing', 'latest', 'revise')); // Grant marketing all permissions on the latest news $acl->allow('marketing', 'latest'); // Access control checks for the above refinement -Assert::true( $acl->isAllowed('marketing', 'latest', 'archive') ); -Assert::true( $acl->isAllowed('marketing', 'latest', 'publish') ); -Assert::true( $acl->isAllowed('marketing', 'latest', 'edit') ); -Assert::true( $acl->isAllowed('marketing', 'latest') ); +Assert::true($acl->isAllowed('marketing', 'latest', 'archive')); +Assert::true($acl->isAllowed('marketing', 'latest', 'publish')); +Assert::true($acl->isAllowed('marketing', 'latest', 'edit')); +Assert::true($acl->isAllowed('marketing', 'latest')); diff --git a/tests/Security/Permission.DefaultAssert.phpt b/tests/Security/Permission.DefaultAssert.phpt index 97646b69..54bebcb5 100644 --- a/tests/Security/Permission.DefaultAssert.phpt +++ b/tests/Security/Permission.DefaultAssert.phpt @@ -1,11 +1,11 @@ -deny(NULL, NULL, NULL, 'falseAssertion'); -Assert::true( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +$acl->deny(null, null, null, 'falseAssertion'); +Assert::true($acl->isAllowed(null, null, 'somePrivilege')); diff --git a/tests/Security/Permission.DefaultDeny.phpt b/tests/Security/Permission.DefaultDeny.phpt index 31003737..1bd4f1f9 100644 --- a/tests/Security/Permission.DefaultDeny.phpt +++ b/tests/Security/Permission.DefaultDeny.phpt @@ -1,20 +1,20 @@ -isAllowed() ); -Assert::false( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +Assert::false($acl->isAllowed()); +Assert::false($acl->isAllowed(null, null, 'somePrivilege')); $acl->addRole('guest'); -Assert::false( $acl->isAllowed('guest') ); -Assert::false( $acl->isAllowed('guest', NULL, 'somePrivilege') ); +Assert::false($acl->isAllowed('guest')); +Assert::false($acl->isAllowed('guest', null, 'somePrivilege')); diff --git a/tests/Security/Permission.DefaultRuleSet.phpt b/tests/Security/Permission.DefaultRuleSet.phpt index 6a2e612b..8e44f930 100644 --- a/tests/Security/Permission.DefaultRuleSet.phpt +++ b/tests/Security/Permission.DefaultRuleSet.phpt @@ -1,11 +1,11 @@ -allow(); -Assert::true( $acl->isAllowed() ); -Assert::true( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +Assert::true($acl->isAllowed()); +Assert::true($acl->isAllowed(null, null, 'somePrivilege')); $acl->deny(); -Assert::false( $acl->isAllowed() ); -Assert::false( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +Assert::false($acl->isAllowed()); +Assert::false($acl->isAllowed(null, null, 'somePrivilege')); diff --git a/tests/Security/Permission.IsAllowedNonExistent.phpt b/tests/Security/Permission.IsAllowedNonExistent.phpt index fd2b129f..1fd4d4be 100644 --- a/tests/Security/Permission.IsAllowedNonExistent.phpt +++ b/tests/Security/Permission.IsAllowedNonExistent.phpt @@ -1,22 +1,26 @@ -isAllowed('nonexistent'); -}, 'Nette\InvalidStateException', "Role 'nonexistent' does not exist."); +$acl = new Permission; +Assert::exception( + fn() => $acl->isAllowed('nonexistent'), + Nette\InvalidStateException::class, + "Role 'nonexistent' does not exist.", +); -Assert::exception(function() { - $acl = new Permission; - $acl->isAllowed(NULL, 'nonexistent'); -}, 'Nette\InvalidStateException', "Resource 'nonexistent' does not exist."); +$acl = new Permission; +Assert::exception( + fn() => $acl->isAllowed(null, 'nonexistent'), + Nette\InvalidStateException::class, + "Resource 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.PrivilegeAllow.phpt b/tests/Security/Permission.PrivilegeAllow.phpt index f93bd1ca..30dc4f1e 100644 --- a/tests/Security/Permission.PrivilegeAllow.phpt +++ b/tests/Security/Permission.PrivilegeAllow.phpt @@ -1,16 +1,16 @@ -allow(NULL, NULL, 'somePrivilege'); -Assert::true( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +$acl->allow(null, null, 'somePrivilege'); +Assert::true($acl->isAllowed(null, null, 'somePrivilege')); diff --git a/tests/Security/Permission.PrivilegeAssert.phpt b/tests/Security/Permission.PrivilegeAssert.phpt index 5a157bd6..9c137643 100644 --- a/tests/Security/Permission.PrivilegeAssert.phpt +++ b/tests/Security/Permission.PrivilegeAssert.phpt @@ -1,11 +1,11 @@ -allow(NULL, NULL, 'somePrivilege', 'trueAssertion'); -Assert::true( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +$acl->allow(null, null, 'somePrivilege', 'trueAssertion'); +Assert::true($acl->isAllowed(null, null, 'somePrivilege')); -$acl->allow(NULL, NULL, 'somePrivilege', 'falseAssertion'); -Assert::false( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +$acl->allow(null, null, 'somePrivilege', 'falseAssertion'); +Assert::false($acl->isAllowed(null, null, 'somePrivilege')); diff --git a/tests/Security/Permission.PrivilegeDeny.phpt b/tests/Security/Permission.PrivilegeDeny.phpt index 9d82d8cc..3164a3b2 100644 --- a/tests/Security/Permission.PrivilegeDeny.phpt +++ b/tests/Security/Permission.PrivilegeDeny.phpt @@ -1,11 +1,11 @@ -allow(); -$acl->deny(NULL, NULL, 'somePrivilege'); -Assert::false( $acl->isAllowed(NULL, NULL, 'somePrivilege') ); +$acl->deny(null, null, 'somePrivilege'); +Assert::false($acl->isAllowed(null, null, 'somePrivilege')); diff --git a/tests/Security/Permission.Privileges.phpt b/tests/Security/Permission.Privileges.phpt index 172990f7..1a92888b 100644 --- a/tests/Security/Permission.Privileges.phpt +++ b/tests/Security/Permission.Privileges.phpt @@ -1,24 +1,24 @@ -allow(NULL, NULL, array('p1', 'p2', 'p3')); -Assert::true( $acl->isAllowed(NULL, NULL, 'p1') ); -Assert::true( $acl->isAllowed(NULL, NULL, 'p2') ); -Assert::true( $acl->isAllowed(NULL, NULL, 'p3') ); -Assert::false( $acl->isAllowed(NULL, NULL, 'p4') ); -$acl->deny(NULL, NULL, 'p1'); -Assert::false( $acl->isAllowed(NULL, NULL, 'p1') ); -$acl->deny(NULL, NULL, array('p2', 'p3')); -Assert::false( $acl->isAllowed(NULL, NULL, 'p2') ); -Assert::false( $acl->isAllowed(NULL, NULL, 'p3') ); +$acl->allow(null, null, ['p1', 'p2', 'p3']); +Assert::true($acl->isAllowed(null, null, 'p1')); +Assert::true($acl->isAllowed(null, null, 'p2')); +Assert::true($acl->isAllowed(null, null, 'p3')); +Assert::false($acl->isAllowed(null, null, 'p4')); +$acl->deny(null, null, 'p1'); +Assert::false($acl->isAllowed(null, null, 'p1')); +$acl->deny(null, null, ['p2', 'p3']); +Assert::false($acl->isAllowed(null, null, 'p2')); +Assert::false($acl->isAllowed(null, null, 'p3')); diff --git a/tests/Security/Permission.RemoveDefaultAllow.phpt b/tests/Security/Permission.RemoveDefaultAllow.phpt index 5745c4dc..3990b8bb 100644 --- a/tests/Security/Permission.RemoveDefaultAllow.phpt +++ b/tests/Security/Permission.RemoveDefaultAllow.phpt @@ -1,11 +1,11 @@ -allow(); -Assert::true( $acl->isAllowed() ); +Assert::true($acl->isAllowed()); $acl->removeAllow(); -Assert::false( $acl->isAllowed() ); +Assert::false($acl->isAllowed()); diff --git a/tests/Security/Permission.RemoveDefaultAllowNonExistent.phpt b/tests/Security/Permission.RemoveDefaultAllowNonExistent.phpt index 21d0617d..bee1927f 100644 --- a/tests/Security/Permission.RemoveDefaultAllowNonExistent.phpt +++ b/tests/Security/Permission.RemoveDefaultAllowNonExistent.phpt @@ -1,11 +1,11 @@ -removeAllow(); -Assert::false( $acl->isAllowed() ); +Assert::false($acl->isAllowed()); diff --git a/tests/Security/Permission.RemoveDefaultDeny.phpt b/tests/Security/Permission.RemoveDefaultDeny.phpt index 4a031214..6fbbf19f 100644 --- a/tests/Security/Permission.RemoveDefaultDeny.phpt +++ b/tests/Security/Permission.RemoveDefaultDeny.phpt @@ -1,17 +1,17 @@ -isAllowed() ); +Assert::false($acl->isAllowed()); $acl->removeDeny(); -Assert::false( $acl->isAllowed() ); +Assert::false($acl->isAllowed()); diff --git a/tests/Security/Permission.RemoveDefaultDenyAssert.phpt b/tests/Security/Permission.RemoveDefaultDenyAssert.phpt index b8ecd180..5f060690 100644 --- a/tests/Security/Permission.RemoveDefaultDenyAssert.phpt +++ b/tests/Security/Permission.RemoveDefaultDenyAssert.phpt @@ -1,11 +1,11 @@ -deny(NULL, NULL, NULL, 'falseAssertion'); -Assert::true( $acl->isAllowed() ); +$acl->deny(null, null, null, 'falseAssertion'); +Assert::true($acl->isAllowed()); $acl->removeDeny(); -Assert::false( $acl->isAllowed() ); +Assert::false($acl->isAllowed()); diff --git a/tests/Security/Permission.RemoveDefaultDenyNonExistent.phpt b/tests/Security/Permission.RemoveDefaultDenyNonExistent.phpt index 713d464a..6d01aff7 100644 --- a/tests/Security/Permission.RemoveDefaultDenyNonExistent.phpt +++ b/tests/Security/Permission.RemoveDefaultDenyNonExistent.phpt @@ -1,11 +1,11 @@ -allow(); $acl->removeDeny(); -Assert::true( $acl->isAllowed() ); +Assert::true($acl->isAllowed()); diff --git a/tests/Security/Permission.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt b/tests/Security/Permission.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt index d3ce326a..8d60484a 100644 --- a/tests/Security/Permission.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt +++ b/tests/Security/Permission.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt @@ -1,12 +1,12 @@ -addRole('test1'); $acl->addRole('test2'); $acl->addResource('Test'); -$acl->allow(NULL,'Test','xxx'); +$acl->allow(null, 'Test', 'xxx'); // error test $acl->removeRole('test0'); // Check after fix -Assert::false( $acl->hasRole('test0') ); +Assert::false($acl->hasRole('test0')); diff --git a/tests/Security/Permission.ResourceAddAndGetOne.phpt b/tests/Security/Permission.ResourceAddAndGetOne.phpt index c34b6d93..fbbb8523 100644 --- a/tests/Security/Permission.ResourceAddAndGetOne.phpt +++ b/tests/Security/Permission.ResourceAddAndGetOne.phpt @@ -1,21 +1,21 @@ -hasResource('area') ); +Assert::false($acl->hasResource('area')); $acl->addResource('area'); -Assert::true( $acl->hasResource('area') ); +Assert::true($acl->hasResource('area')); $acl->removeResource('area'); -Assert::false( $acl->hasResource('area') ); +Assert::false($acl->hasResource('area')); diff --git a/tests/Security/Permission.ResourceAddInheritsNonExistent.phpt b/tests/Security/Permission.ResourceAddInheritsNonExistent.phpt index 1d599129..276e6c4a 100644 --- a/tests/Security/Permission.ResourceAddInheritsNonExistent.phpt +++ b/tests/Security/Permission.ResourceAddInheritsNonExistent.phpt @@ -1,17 +1,19 @@ -addResource('area', 'nonexistent'); -}, 'Nette\InvalidStateException', "Resource 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->addResource('area', 'nonexistent'), + Nette\InvalidStateException::class, + "Resource 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.ResourceDuplicate.phpt b/tests/Security/Permission.ResourceDuplicate.phpt index ea92f2e1..dac96f29 100644 --- a/tests/Security/Permission.ResourceDuplicate.phpt +++ b/tests/Security/Permission.ResourceDuplicate.phpt @@ -1,18 +1,20 @@ -addResource('area'); - $acl->addResource('area'); -}, 'Nette\InvalidStateException', "Resource 'area' already exists in the list."); +$acl = new Permission; +$acl->addResource('area'); +Assert::exception( + fn() => $acl->addResource('area'), + Nette\InvalidStateException::class, + "Resource 'area' already exists in the list.", +); diff --git a/tests/Security/Permission.ResourceInherits.phpt b/tests/Security/Permission.ResourceInherits.phpt index 46f12a8c..0d95c9b0 100644 --- a/tests/Security/Permission.ResourceInherits.phpt +++ b/tests/Security/Permission.ResourceInherits.phpt @@ -1,11 +1,11 @@ -addResource('city'); $acl->addResource('building', 'city'); $acl->addResource('room', 'building'); -Assert::same( array('city', 'building', 'room'), $acl->getResources() ); -Assert::true( $acl->resourceInheritsFrom('building', 'city', TRUE) ); -Assert::true( $acl->resourceInheritsFrom('room', 'building', TRUE) ); -Assert::true( $acl->resourceInheritsFrom('room', 'city') ); -Assert::false( $acl->resourceInheritsFrom('room', 'city', TRUE) ); -Assert::false( $acl->resourceInheritsFrom('city', 'building') ); -Assert::false( $acl->resourceInheritsFrom('building', 'room') ); -Assert::false( $acl->resourceInheritsFrom('city', 'room') ); +Assert::same(['city', 'building', 'room'], $acl->getResources()); +Assert::true($acl->resourceInheritsFrom('building', 'city', onlyParent: true)); +Assert::true($acl->resourceInheritsFrom('room', 'building', onlyParent: true)); +Assert::true($acl->resourceInheritsFrom('room', 'city')); +Assert::false($acl->resourceInheritsFrom('room', 'city', onlyParent: true)); +Assert::false($acl->resourceInheritsFrom('city', 'building')); +Assert::false($acl->resourceInheritsFrom('building', 'room')); +Assert::false($acl->resourceInheritsFrom('city', 'room')); $acl->removeResource('building'); -Assert::false( $acl->hasResource('room') ); +Assert::false($acl->hasResource('room')); diff --git a/tests/Security/Permission.ResourceInheritsNonExistent.phpt b/tests/Security/Permission.ResourceInheritsNonExistent.phpt index 1ae3545b..6da62229 100644 --- a/tests/Security/Permission.ResourceInheritsNonExistent.phpt +++ b/tests/Security/Permission.ResourceInheritsNonExistent.phpt @@ -1,11 +1,11 @@ -addResource('area'); -Assert::exception(function() use ($acl) { - $acl->resourceInheritsFrom('nonexistent', 'area'); -}, 'Nette\InvalidStateException', "Resource 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->resourceInheritsFrom('nonexistent', 'area'), + Nette\InvalidStateException::class, + "Resource 'nonexistent' does not exist.", +); -Assert::exception(function() use ($acl) { - $acl->resourceInheritsFrom('area', 'nonexistent'); -}, 'Nette\InvalidStateException', "Resource 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->resourceInheritsFrom('area', 'nonexistent'), + Nette\InvalidStateException::class, + "Resource 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.ResourceRemoveAll.phpt b/tests/Security/Permission.ResourceRemoveAll.phpt index 16401b61..49e1ba71 100644 --- a/tests/Security/Permission.ResourceRemoveAll.phpt +++ b/tests/Security/Permission.ResourceRemoveAll.phpt @@ -1,11 +1,11 @@ -addResource('area'); $acl->removeAllResources(); -Assert::false( $acl->hasResource('area') ); +Assert::false($acl->hasResource('area')); diff --git a/tests/Security/Permission.ResourceRemoveOneNonExistent.phpt b/tests/Security/Permission.ResourceRemoveOneNonExistent.phpt index 2819116a..ad7a8cff 100644 --- a/tests/Security/Permission.ResourceRemoveOneNonExistent.phpt +++ b/tests/Security/Permission.ResourceRemoveOneNonExistent.phpt @@ -1,17 +1,19 @@ -removeResource('nonexistent'); -}, 'Nette\InvalidStateException', "Resource 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->removeResource('nonexistent'), + Nette\InvalidStateException::class, + "Resource 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt b/tests/Security/Permission.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt index c655c63d..e42f7a0b 100644 --- a/tests/Security/Permission.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt +++ b/tests/Security/Permission.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt @@ -1,12 +1,12 @@ -addRole('guest'); $acl->addRole('staff', 'guest'); $acl->deny(); $acl->allow('staff'); -$acl->deny('staff', NULL, array('privilege1', 'privilege2')); -Assert::false( $acl->isAllowed('staff', NULL, 'privilege1') ); +$acl->deny('staff', null, ['privilege1', 'privilege2']); +Assert::false($acl->isAllowed('staff', null, 'privilege1')); diff --git a/tests/Security/Permission.RoleDefaultAllowRuleWithResourceDenyRule.phpt b/tests/Security/Permission.RoleDefaultAllowRuleWithResourceDenyRule.phpt index ccfa447f..7bbff1f6 100644 --- a/tests/Security/Permission.RoleDefaultAllowRuleWithResourceDenyRule.phpt +++ b/tests/Security/Permission.RoleDefaultAllowRuleWithResourceDenyRule.phpt @@ -1,12 +1,12 @@ -addResource('area1'); $acl->addResource('area2'); $acl->deny(); $acl->allow('staff'); -$acl->deny('staff', array('area1', 'area2')); -Assert::false( $acl->isAllowed('staff', 'area1') ); +$acl->deny('staff', ['area1', 'area2']); +Assert::false($acl->isAllowed('staff', 'area1')); diff --git a/tests/Security/Permission.RoleDefaultRuleSet.phpt b/tests/Security/Permission.RoleDefaultRuleSet.phpt index bc84c751..ece43050 100644 --- a/tests/Security/Permission.RoleDefaultRuleSet.phpt +++ b/tests/Security/Permission.RoleDefaultRuleSet.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->allow('guest'); -Assert::true( $acl->isAllowed('guest') ); +Assert::true($acl->isAllowed('guest')); $acl->deny('guest'); -Assert::false( $acl->isAllowed('guest') ); +Assert::false($acl->isAllowed('guest')); diff --git a/tests/Security/Permission.RoleDefaultRuleSetPrivilege.phpt b/tests/Security/Permission.RoleDefaultRuleSetPrivilege.phpt index fc1c39e5..78daa778 100644 --- a/tests/Security/Permission.RoleDefaultRuleSetPrivilege.phpt +++ b/tests/Security/Permission.RoleDefaultRuleSetPrivilege.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->allow('guest'); -Assert::true( $acl->isAllowed('guest', NULL, 'somePrivilege') ); +Assert::true($acl->isAllowed('guest', null, 'somePrivilege')); $acl->deny('guest'); -Assert::false( $acl->isAllowed('guest', NULL, 'somePrivilege') ); +Assert::false($acl->isAllowed('guest', null, 'somePrivilege')); diff --git a/tests/Security/Permission.RolePrivilegeAllow.phpt b/tests/Security/Permission.RolePrivilegeAllow.phpt index a1602693..85f354a2 100644 --- a/tests/Security/Permission.RolePrivilegeAllow.phpt +++ b/tests/Security/Permission.RolePrivilegeAllow.phpt @@ -1,11 +1,11 @@ -addRole('guest'); -$acl->allow('guest', NULL, 'somePrivilege'); -Assert::true( $acl->isAllowed('guest', NULL, 'somePrivilege') ); +$acl->allow('guest', null, 'somePrivilege'); +Assert::true($acl->isAllowed('guest', null, 'somePrivilege')); diff --git a/tests/Security/Permission.RolePrivilegeAssert.phpt b/tests/Security/Permission.RolePrivilegeAssert.phpt index 0751f135..c9d91fe4 100644 --- a/tests/Security/Permission.RolePrivilegeAssert.phpt +++ b/tests/Security/Permission.RolePrivilegeAssert.phpt @@ -1,11 +1,11 @@ -addRole('guest'); -$acl->allow('guest', NULL, 'somePrivilege', 'trueAssertion'); -Assert::true( $acl->isAllowed('guest', NULL, 'somePrivilege') ); -$acl->allow('guest', NULL, 'somePrivilege', 'falseAssertion'); -Assert::false( $acl->isAllowed('guest', NULL, 'somePrivilege') ); +$acl->allow('guest', null, 'somePrivilege', 'trueAssertion'); +Assert::true($acl->isAllowed('guest', null, 'somePrivilege')); +$acl->allow('guest', null, 'somePrivilege', 'falseAssertion'); +Assert::false($acl->isAllowed('guest', null, 'somePrivilege')); diff --git a/tests/Security/Permission.RolePrivilegeDeny.phpt b/tests/Security/Permission.RolePrivilegeDeny.phpt index 06a91395..c0c12719 100644 --- a/tests/Security/Permission.RolePrivilegeDeny.phpt +++ b/tests/Security/Permission.RolePrivilegeDeny.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->allow('guest'); -$acl->deny('guest', NULL, 'somePrivilege'); -Assert::false( $acl->isAllowed('guest', NULL, 'somePrivilege') ); +$acl->deny('guest', null, 'somePrivilege'); +Assert::false($acl->isAllowed('guest', null, 'somePrivilege')); diff --git a/tests/Security/Permission.RolePrivileges.phpt b/tests/Security/Permission.RolePrivileges.phpt index 04538715..c870a00a 100644 --- a/tests/Security/Permission.RolePrivileges.phpt +++ b/tests/Security/Permission.RolePrivileges.phpt @@ -1,11 +1,11 @@ -addRole('guest'); -$acl->allow('guest', NULL, array('p1', 'p2', 'p3')); -Assert::true( $acl->isAllowed('guest', NULL, 'p1') ); -Assert::true( $acl->isAllowed('guest', NULL, 'p2') ); -Assert::true( $acl->isAllowed('guest', NULL, 'p3') ); -Assert::false( $acl->isAllowed('guest', NULL, 'p4') ); -$acl->deny('guest', NULL, 'p1'); -Assert::false( $acl->isAllowed('guest', NULL, 'p1') ); -$acl->deny('guest', NULL, array('p2', 'p3')); -Assert::false( $acl->isAllowed('guest', NULL, 'p2') ); -Assert::false( $acl->isAllowed('guest', NULL, 'p3') ); +$acl->allow('guest', null, ['p1', 'p2', 'p3']); +Assert::true($acl->isAllowed('guest', null, 'p1')); +Assert::true($acl->isAllowed('guest', null, 'p2')); +Assert::true($acl->isAllowed('guest', null, 'p3')); +Assert::false($acl->isAllowed('guest', null, 'p4')); +$acl->deny('guest', null, 'p1'); +Assert::false($acl->isAllowed('guest', null, 'p1')); +$acl->deny('guest', null, ['p2', 'p3']); +Assert::false($acl->isAllowed('guest', null, 'p2')); +Assert::false($acl->isAllowed('guest', null, 'p3')); diff --git a/tests/Security/Permission.RoleRegistryAddAndGetOne.phpt b/tests/Security/Permission.RoleRegistryAddAndGetOne.phpt index c7617fc5..755c6a07 100644 --- a/tests/Security/Permission.RoleRegistryAddAndGetOne.phpt +++ b/tests/Security/Permission.RoleRegistryAddAndGetOne.phpt @@ -1,21 +1,21 @@ -hasRole('guest') ); +Assert::false($acl->hasRole('guest')); $acl->addRole('guest'); -Assert::true( $acl->hasRole('guest') ); +Assert::true($acl->hasRole('guest')); $acl->removeRole('guest'); -Assert::false( $acl->hasRole('guest') ); +Assert::false($acl->hasRole('guest')); diff --git a/tests/Security/Permission.RoleRegistryAddInheritsNonExistent.phpt b/tests/Security/Permission.RoleRegistryAddInheritsNonExistent.phpt index 928a54e0..5bd1e4f8 100644 --- a/tests/Security/Permission.RoleRegistryAddInheritsNonExistent.phpt +++ b/tests/Security/Permission.RoleRegistryAddInheritsNonExistent.phpt @@ -1,17 +1,19 @@ -addRole('guest', 'nonexistent'); -}, 'Nette\InvalidStateException', "Role 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->addRole('guest', 'nonexistent'), + Nette\InvalidStateException::class, + "Role 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.RoleRegistryDuplicate.phpt b/tests/Security/Permission.RoleRegistryDuplicate.phpt index 97adde8c..d7477d6d 100644 --- a/tests/Security/Permission.RoleRegistryDuplicate.phpt +++ b/tests/Security/Permission.RoleRegistryDuplicate.phpt @@ -1,18 +1,20 @@ -addRole('guest'); - $acl->addRole('guest'); -}, 'Nette\InvalidStateException', "Role 'guest' already exists in the list."); +$acl->addRole('guest'); +Assert::exception( + fn() => $acl->addRole('guest'), + Nette\InvalidStateException::class, + "Role 'guest' already exists in the list.", +); diff --git a/tests/Security/Permission.RoleRegistryInherits.phpt b/tests/Security/Permission.RoleRegistryInherits.phpt index bd2866d3..d9b551dd 100644 --- a/tests/Security/Permission.RoleRegistryInherits.phpt +++ b/tests/Security/Permission.RoleRegistryInherits.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->addRole('member', 'guest'); $acl->addRole('editor', 'member'); -Assert::same( array('guest', 'member', 'editor'), $acl->getRoles() ); -Assert::same( array(), $acl->getRoleParents('guest') ); -Assert::same( array('guest'), $acl->getRoleParents('member') ); -Assert::same( array('member'), $acl->getRoleParents('editor') ); +Assert::same(['guest', 'member', 'editor'], $acl->getRoles()); +Assert::same([], $acl->getRoleParents('guest')); +Assert::same(['guest'], $acl->getRoleParents('member')); +Assert::same(['member'], $acl->getRoleParents('editor')); -Assert::true( $acl->roleInheritsFrom('member', 'guest', TRUE) ); -Assert::true( $acl->roleInheritsFrom('editor', 'member', TRUE) ); -Assert::true( $acl->roleInheritsFrom('editor', 'guest') ); -Assert::false( $acl->roleInheritsFrom('editor', 'guest', TRUE) ); -Assert::false( $acl->roleInheritsFrom('guest', 'member') ); -Assert::false( $acl->roleInheritsFrom('member', 'editor') ); -Assert::false( $acl->roleInheritsFrom('guest', 'editor') ); +Assert::true($acl->roleInheritsFrom('member', 'guest', onlyParents: true)); +Assert::true($acl->roleInheritsFrom('editor', 'member', onlyParents: true)); +Assert::true($acl->roleInheritsFrom('editor', 'guest')); +Assert::false($acl->roleInheritsFrom('editor', 'guest', onlyParents: true)); +Assert::false($acl->roleInheritsFrom('guest', 'member')); +Assert::false($acl->roleInheritsFrom('member', 'editor')); +Assert::false($acl->roleInheritsFrom('guest', 'editor')); $acl->removeRole('member'); -Assert::same( array(), $acl->getRoleParents('editor') ); -Assert::false( $acl->roleInheritsFrom('editor', 'guest') ); +Assert::same([], $acl->getRoleParents('editor')); +Assert::false($acl->roleInheritsFrom('editor', 'guest')); diff --git a/tests/Security/Permission.RoleRegistryInheritsMultiple.phpt b/tests/Security/Permission.RoleRegistryInheritsMultiple.phpt index 2d64ffc1..c563bb39 100644 --- a/tests/Security/Permission.RoleRegistryInheritsMultiple.phpt +++ b/tests/Security/Permission.RoleRegistryInheritsMultiple.phpt @@ -1,11 +1,11 @@ -addRole('parent1'); $acl->addRole('parent2'); -$acl->addRole('child', array('parent1', 'parent2')); +$acl->addRole('child', ['parent1', 'parent2']); -Assert::same( array( +Assert::same([ 'parent1', 'parent2', -), $acl->getRoleParents('child') ); +], $acl->getRoleParents('child')); -Assert::true( $acl->roleInheritsFrom('child', 'parent1') ); -Assert::true( $acl->roleInheritsFrom('child', 'parent2') ); +Assert::true($acl->roleInheritsFrom('child', 'parent1')); +Assert::true($acl->roleInheritsFrom('child', 'parent2')); $acl->removeRole('parent1'); -Assert::same( array('parent2'), $acl->getRoleParents('child') ); -Assert::true( $acl->roleInheritsFrom('child', 'parent2') ); +Assert::same(['parent2'], $acl->getRoleParents('child')); +Assert::true($acl->roleInheritsFrom('child', 'parent2')); diff --git a/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt b/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt index 462f980c..c2cc1b69 100644 --- a/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt +++ b/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt @@ -1,11 +1,11 @@ -addRole('guest'); -Assert::exception(function() use ($acl) { - $acl->roleInheritsFrom('nonexistent', 'guest'); -}, 'Nette\InvalidStateException', "Role 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->roleInheritsFrom('nonexistent', 'guest'), + Nette\InvalidStateException::class, + "Role 'nonexistent' does not exist.", +); -Assert::exception(function() use ($acl) { - $acl->roleInheritsFrom('guest', 'nonexistent'); -}, 'Nette\InvalidStateException', "Role 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->roleInheritsFrom('guest', 'nonexistent'), + Nette\InvalidStateException::class, + "Role 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.RoleRegistryRemoveAll.phpt b/tests/Security/Permission.RoleRegistryRemoveAll.phpt index 9fbc25af..2262aee9 100644 --- a/tests/Security/Permission.RoleRegistryRemoveAll.phpt +++ b/tests/Security/Permission.RoleRegistryRemoveAll.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->removeAllRoles(); -Assert::false( $acl->hasRole('guest') ); +Assert::false($acl->hasRole('guest')); diff --git a/tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt b/tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt index a232ba27..74cb015f 100644 --- a/tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt +++ b/tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt @@ -1,17 +1,19 @@ -removeRole('nonexistent'); -}, 'Nette\InvalidStateException', "Role 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->removeRole('nonexistent'), + Nette\InvalidStateException::class, + "Role 'nonexistent' does not exist.", +); diff --git a/tests/Security/Permission.RuleRoleRemove.phpt b/tests/Security/Permission.RuleRoleRemove.phpt index 26ecb1cd..80aaa9df 100644 --- a/tests/Security/Permission.RuleRoleRemove.phpt +++ b/tests/Security/Permission.RuleRoleRemove.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->allow('guest'); -Assert::true( $acl->isAllowed('guest') ); +Assert::true($acl->isAllowed('guest')); $acl->removeRole('guest'); -Assert::exception(function() use ($acl) { - $acl->isAllowed('guest'); -}, 'Nette\InvalidStateException', "Role 'guest' does not exist."); +Assert::exception( + fn() => $acl->isAllowed('guest'), + Nette\InvalidStateException::class, + "Role 'guest' does not exist.", +); $acl->addRole('guest'); -Assert::false( $acl->isAllowed('guest') ); +Assert::false($acl->isAllowed('guest')); diff --git a/tests/Security/Permission.RuleRoleRemoveAll.phpt b/tests/Security/Permission.RuleRoleRemoveAll.phpt index 00719d58..20258ecf 100644 --- a/tests/Security/Permission.RuleRoleRemoveAll.phpt +++ b/tests/Security/Permission.RuleRoleRemoveAll.phpt @@ -1,11 +1,11 @@ -addRole('guest'); $acl->allow('guest'); -Assert::true( $acl->isAllowed('guest') ); +Assert::true($acl->isAllowed('guest')); $acl->removeAllRoles(); -Assert::exception(function() use ($acl) { - $acl->isAllowed('guest'); -}, 'Nette\InvalidStateException', "Role 'guest' does not exist."); +Assert::exception( + fn() => $acl->isAllowed('guest'), + Nette\InvalidStateException::class, + "Role 'guest' does not exist.", +); $acl->addRole('guest'); -Assert::false( $acl->isAllowed('guest') ); +Assert::false($acl->isAllowed('guest')); diff --git a/tests/Security/Permission.RulesRemove.phpt b/tests/Security/Permission.RulesRemove.phpt index 91910a01..5164fb63 100644 --- a/tests/Security/Permission.RulesRemove.phpt +++ b/tests/Security/Permission.RulesRemove.phpt @@ -1,21 +1,21 @@ -allow(NULL, NULL, array('privilege1', 'privilege2')); -Assert::false( $acl->isAllowed() ); -Assert::true( $acl->isAllowed(NULL, NULL, 'privilege1') ); -Assert::true( $acl->isAllowed(NULL, NULL, 'privilege2') ); -$acl->removeAllow(NULL, NULL, 'privilege1'); -Assert::false( $acl->isAllowed(NULL, NULL, 'privilege1') ); -Assert::true( $acl->isAllowed(NULL, NULL, 'privilege2') ); +$acl->allow(null, null, ['privilege1', 'privilege2']); +Assert::false($acl->isAllowed()); +Assert::true($acl->isAllowed(null, null, 'privilege1')); +Assert::true($acl->isAllowed(null, null, 'privilege2')); +$acl->removeAllow(null, null, 'privilege1'); +Assert::false($acl->isAllowed(null, null, 'privilege1')); +Assert::true($acl->isAllowed(null, null, 'privilege2')); diff --git a/tests/Security/Permission.RulesResourceRemove.phpt b/tests/Security/Permission.RulesResourceRemove.phpt index 078ded22..a545bd6f 100644 --- a/tests/Security/Permission.RulesResourceRemove.phpt +++ b/tests/Security/Permission.RulesResourceRemove.phpt @@ -1,11 +1,11 @@ -addResource('area'); -$acl->allow(NULL, 'area'); -Assert::true( $acl->isAllowed(NULL, 'area') ); +$acl->allow(null, 'area'); +Assert::true($acl->isAllowed(null, 'area')); $acl->removeResource('area'); -Assert::exception(function() use ($acl) { - $acl->isAllowed(NULL, 'area'); -}, 'Nette\InvalidStateException', "Resource 'area' does not exist."); +Assert::exception( + fn() => $acl->isAllowed(null, 'area'), + Nette\InvalidStateException::class, + "Resource 'area' does not exist.", +); $acl->addResource('area'); -Assert::false( $acl->isAllowed(NULL, 'area') ); +Assert::false($acl->isAllowed(null, 'area')); diff --git a/tests/Security/Permission.RulesResourceRemoveAll.phpt b/tests/Security/Permission.RulesResourceRemoveAll.phpt index c11b4c6b..0c19836f 100644 --- a/tests/Security/Permission.RulesResourceRemoveAll.phpt +++ b/tests/Security/Permission.RulesResourceRemoveAll.phpt @@ -1,11 +1,11 @@ -addResource('area'); -$acl->allow(NULL, 'area'); -Assert::true( $acl->isAllowed(NULL, 'area') ); +$acl->allow(null, 'area'); +Assert::true($acl->isAllowed(null, 'area')); $acl->removeAllResources(); -Assert::exception(function() use ($acl) { - $acl->isAllowed(NULL, 'area'); -}, 'Nette\InvalidStateException', "Resource 'area' does not exist."); +Assert::exception( + fn() => $acl->isAllowed(null, 'area'), + Nette\InvalidStateException::class, + "Resource 'area' does not exist.", +); $acl->addResource('area'); -Assert::false( $acl->isAllowed(NULL, 'area') ); +Assert::false($acl->isAllowed(null, 'area')); diff --git a/tests/Security/SimpleAuthenticator.Data.phpt b/tests/Security/SimpleAuthenticator.Data.phpt new file mode 100644 index 00000000..1622cf4d --- /dev/null +++ b/tests/Security/SimpleAuthenticator.Data.phpt @@ -0,0 +1,35 @@ + 'john123', + 'admin' => 'admin123', + 'user' => 'user123', +]; +$usersData = [ + 'admin' => ['nick' => 'admin', 'email' => 'foo@bar.com'], + 'user' => ['nick' => 'user', 'email' => 'foo@bar.com'], +]; +$expectedData = [ + 'admin' => ['nick' => 'admin', 'email' => 'foo@bar.com'], + 'user' => ['nick' => 'user', 'email' => 'foo@bar.com'], + 'john' => [], +]; + +$authenticator = new SimpleAuthenticator($users, [], $usersData); + +foreach ($users as $username => $password) { + $identity = $authenticator->authenticate($username, $password); + Assert::equal($username, $identity->getId()); + Assert::equal($expectedData[$username], $identity->getData()); +} diff --git a/tests/Security/SimpleAuthenticator.Roles.phpt b/tests/Security/SimpleAuthenticator.Roles.phpt index 4ce3174a..9f135190 100644 --- a/tests/Security/SimpleAuthenticator.Roles.phpt +++ b/tests/Security/SimpleAuthenticator.Roles.phpt @@ -1,35 +1,35 @@ - 'john123', +$users = [ + 'john' => 'john123', 'admin' => 'admin123', - 'user' => 'user123', -); -$usersRoles = array( - 'admin' => array('admin', 'user'), - 'user' => 'user', -); -$expectedRoles = array( - 'admin' => array('admin', 'user'), - 'user' => array('user'), - 'john' => array(), -); + 'user' => 'user123', +]; +$usersRoles = [ + 'admin' => ['admin', 'user'], + 'user' => 'user', +]; +$expectedRoles = [ + 'admin' => ['admin', 'user'], + 'user' => ['user'], + 'john' => [], +]; $authenticator = new SimpleAuthenticator($users, $usersRoles); foreach ($users as $username => $password) { - $identity = $authenticator->authenticate(array($username, $password)); + $identity = $authenticator->authenticate($username, $password); Assert::equal($username, $identity->getId()); Assert::equal($expectedRoles[$username], $identity->getRoles()); } diff --git a/tests/Security/SimpleAuthenticator.phpt b/tests/Security/SimpleAuthenticator.phpt index 94c7e86a..ca012c2b 100644 --- a/tests/Security/SimpleAuthenticator.phpt +++ b/tests/Security/SimpleAuthenticator.phpt @@ -1,35 +1,39 @@ - 'password123!', 'admin' => 'admin', -); +]; $authenticator = new SimpleAuthenticator($users); -$identity = $authenticator->authenticate(array('john', 'password123!')); -Assert::type( 'Nette\Security\IIdentity', $identity ); +$identity = $authenticator->authenticate('john', 'password123!'); +Assert::type(Nette\Security\IIdentity::class, $identity); Assert::equal('john', $identity->getId()); -$identity = $authenticator->authenticate(array('admin', 'admin')); -Assert::type( 'Nette\Security\IIdentity', $identity ); +$identity = $authenticator->authenticate('admin', 'admin'); +Assert::type(Nette\Security\IIdentity::class, $identity); Assert::equal('admin', $identity->getId()); -Assert::exception(function() use ($authenticator) { - $authenticator->authenticate(array('admin', 'wrong password')); -}, 'Nette\Security\AuthenticationException', 'Invalid password.'); +Assert::exception( + fn() => $authenticator->authenticate('admin', 'wrong password'), + Nette\Security\AuthenticationException::class, + 'Invalid password.', +); -Assert::exception(function() use ($authenticator) { - $authenticator->authenticate(array('nobody', 'password')); -}, 'Nette\Security\AuthenticationException', "User 'nobody' not found."); +Assert::exception( + fn() => $authenticator->authenticate('nobody', 'password'), + Nette\Security\AuthenticationException::class, + "User 'nobody' not found.", +); diff --git a/tests/Security/SimpleIdentity.phpt b/tests/Security/SimpleIdentity.phpt new file mode 100644 index 00000000..b44fa560 --- /dev/null +++ b/tests/Security/SimpleIdentity.phpt @@ -0,0 +1,34 @@ + '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 SimpleIdentity('12'); + Assert::same(12, $id->getId()); + + + $id = new SimpleIdentity('12345678901234567890'); + Assert::same('12345678901234567890', $id->getId()); +}); diff --git a/tests/Security/User.authentication.phpt b/tests/Security/User.authentication.phpt index d945a0d9..ff68f0ef 100644 --- a/tests/Security/User.authentication.phpt +++ b/tests/Security/User.authentication.phpt @@ -1,52 +1,45 @@ - 0, 'logout' => 0, -); +]; $user->onLoggedIn[] = function () use ($counter) { $counter->login++; @@ -57,65 +50,68 @@ $user->onLoggedOut[] = function () use ($counter) { }; -Assert::false( $user->isLoggedIn() ); -Assert::null( $user->getIdentity() ); -Assert::null( $user->getId() ); +Assert::false($user->isLoggedIn()); +Assert::null($user->getIdentity()); +Assert::null($user->getId()); // authenticate -Assert::exception(function() use ($user) { - // login without handler - $user->login('jane', ''); -}, 'Nette\InvalidStateException', 'Authenticator has not been set.'); +Assert::exception( + fn() => $user->login('jane', ''), + Nette\InvalidStateException::class, + 'Authenticator has not been set.', +); $handler = new Authenticator; $user->setAuthenticator($handler); -Assert::exception(function() use ($user) { - // login as jane - $user->login('jane', ''); -}, 'Nette\Security\AuthenticationException', 'Unknown user'); +Assert::exception( + fn() => $user->login('jane', ''), + Nette\Security\AuthenticationException::class, + 'Unknown user', +); -Assert::exception(function() use ($user) { - // login as john - $user->login('john', ''); -}, 'Nette\Security\AuthenticationException', 'Password not match'); +Assert::exception( + fn() => $user->login('john', ''), + Nette\Security\AuthenticationException::class, + 'Password not match', +); // login as john#2 $user->login('john', 'xxx'); -Assert::same( 1, $counter->login ); -Assert::true( $user->isLoggedIn() ); -Assert::equal( new Identity('John Doe', 'admin'), $user->getIdentity() ); -Assert::same( 'John Doe', $user->getId() ); +Assert::same(1, $counter->login); +Assert::true($user->isLoggedIn()); +Assert::equal(new SimpleIdentity('John Doe', 'admin'), $user->getIdentity()); +Assert::same('John Doe', $user->getId()); // login as john#3 -$user->logout(TRUE); -Assert::same( 1, $counter->logout ); -$user->login( new Identity('John Doe', 'admin') ); -Assert::same( 2, $counter->login ); -Assert::true( $user->isLoggedIn() ); -Assert::equal( new Identity('John Doe', 'admin'), $user->getIdentity() ); +$user->logout(true); +Assert::same(1, $counter->logout); +$user->login(new SimpleIdentity('John Doe', 'admin')); +Assert::same(2, $counter->login); +Assert::true($user->isLoggedIn()); +Assert::equal(new SimpleIdentity('John Doe', 'admin'), $user->getIdentity()); // log out // logging out... -$user->logout(FALSE); -Assert::same( 2, $counter->logout ); +$user->logout(false); +Assert::same(2, $counter->logout); -Assert::false( $user->isLoggedIn() ); -Assert::equal( new Identity('John Doe', 'admin'), $user->getIdentity() ); +Assert::false($user->isLoggedIn()); +Assert::equal(new SimpleIdentity('John Doe', 'admin'), $user->getIdentity()); // logging out and clearing identity... -$user->logout(TRUE); -Assert::same( 2, $counter->logout ); // not logged in -> logout event not triggered +$user->logout(true); +Assert::same(2, $counter->logout); // not logged in -> logout event not triggered -Assert::false( $user->isLoggedIn() ); -Assert::null( $user->getIdentity() ); +Assert::false($user->isLoggedIn()); +Assert::null($user->getIdentity()); // namespace // login as john#2? $user->login('john', 'xxx'); -Assert::same( 3, $counter->login ); -Assert::true( $user->isLoggedIn() ); +Assert::same(3, $counter->login); +Assert::true($user->isLoggedIn()); diff --git a/tests/Security/User.authorization.phpt b/tests/Security/User.authorization.phpt index ecc91371..bb1bc45a 100644 --- a/tests/Security/User.authorization.phpt +++ b/tests/Security/User.authorization.phpt @@ -1,13 +1,13 @@ -isLoggedIn() ); +Assert::false($user->isLoggedIn()); -Assert::same( array('guest'), $user->getRoles() ); -Assert::false( $user->isInRole('admin') ); -Assert::true( $user->isInRole('guest') ); +Assert::same(['guest'], $user->getRoles()); +Assert::false($user->isInRole('admin')); +Assert::false($user->isInRole('tester')); +Assert::true($user->isInRole('guest')); // authenticated @@ -77,26 +71,29 @@ $user->setAuthenticator($handler); // login as john $user->login('john', 'xxx'); -Assert::true( $user->isLoggedIn() ); -Assert::same( array('admin'), $user->getRoles() ); -Assert::true( $user->isInRole('admin') ); -Assert::false( $user->isInRole('guest') ); +Assert::true($user->isLoggedIn()); +Assert::equal(['admin', new TesterRole], $user->getRoles()); +Assert::true($user->isInRole('admin')); +Assert::true($user->isInRole('tester')); +Assert::false($user->isInRole('guest')); // authorization -Assert::exception(function() use ($user) { - $user->isAllowed('delete_file'); -}, 'Nette\InvalidStateException', 'Authorizator has not been set.'); +Assert::exception( + fn() => $user->isAllowed('delete_file'), + Nette\InvalidStateException::class, + 'Authorizator has not been set.', +); $handler = new Authorizator; $user->setAuthorizator($handler); -Assert::true( $user->isAllowed('delete_file') ); -Assert::false( $user->isAllowed('sleep_with_jany') ); +Assert::true($user->isAllowed('delete_file')); +Assert::false($user->isAllowed('sleep_with_jany')); // log out // logging out... -$user->logout(FALSE); +$user->logout(false); -Assert::false( $user->isAllowed('delete_file') ); +Assert::false($user->isAllowed('delete_file')); diff --git a/tests/Security/User.emptyRoles.phpt b/tests/Security/User.emptyRoles.phpt new file mode 100644 index 00000000..a43d4a64 --- /dev/null +++ b/tests/Security/User.emptyRoles.phpt @@ -0,0 +1,62 @@ +login('john', 'pass'); + + Assert::true($user->isLoggedIn()); + Assert::same([], $user->getRoles()); +}); + + +test('guest identity with empty roles is returned as-is (no guestRole)', function () { + $authenticator = new class implements Nette\Security\Authenticator, Nette\Security\IdentityHandler { + public function authenticate(string $username, string $password): IIdentity + { + return new SimpleIdentity('john', ['admin']); + } + + + public function sleepIdentity(IIdentity $identity): IIdentity + { + return $identity; + } + + + public function wakeupIdentity(IIdentity $identity): ?IIdentity + { + return $identity; + } + + + public function getGuestIdentity(): ?IIdentity + { + return new SimpleIdentity('guest', []); + } + }; + $user = new User(new MockUserStorage, $authenticator); + + Assert::false($user->isLoggedIn()); + Assert::same([], $user->getRoles()); +}); diff --git a/tests/Security/User.expiration.phpt b/tests/Security/User.expiration.phpt new file mode 100644 index 00000000..d38d090b --- /dev/null +++ b/tests/Security/User.expiration.phpt @@ -0,0 +1,76 @@ +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.guestIdentity.phpt b/tests/Security/User.guestIdentity.phpt new file mode 100644 index 00000000..78dbadd5 --- /dev/null +++ b/tests/Security/User.guestIdentity.phpt @@ -0,0 +1,157 @@ + 'Guest']); + } +} + + +class RecordingStorage implements Nette\Security\UserStorage +{ + /** @var list */ + public array $saved = []; + private bool $auth = false; + private ?IIdentity $identity = null; + + + public function saveAuthentication(IIdentity $identity): void + { + $this->saved[] = $identity; + $this->auth = true; + $this->identity = $identity; + } + + + public function clearAuthentication(bool $clearIdentity): void + { + $this->auth = false; + $this->identity = $clearIdentity ? null : $this->identity; + } + + + public function getState(): array + { + return [$this->auth, $this->identity, null]; + } + + + public function setExpiration(?string $expire, bool $clearIdentity): void + { + } +} + + +test('guest identity is exposed when not logged in', function () { + $user = new User(new MockUserStorage, new GuestAuthenticator); + + Assert::false($user->isLoggedIn()); + Assert::equal(new SimpleIdentity('guest', ['guest-role'], ['name' => 'Guest']), $user->getIdentity()); + Assert::same('guest', $user->getId()); + Assert::same(['guest-role'], $user->getRoles()); + Assert::true($user->isInRole('guest-role')); + Assert::false($user->isInRole('admin')); +}); + + +test('login overrides the guest identity', function () { + $user = new User(new MockUserStorage, new GuestAuthenticator); + $user->login('john', 'pass'); + + Assert::true($user->isLoggedIn()); + Assert::same('john', $user->getId()); + Assert::same(['admin'], $user->getRoles()); +}); + + +test('guest identity returns after logout that clears identity', function () { + $user = new User(new MockUserStorage, new GuestAuthenticator); + $user->login('john', 'pass'); + + $user->logout(clearIdentity: true); + + Assert::false($user->isLoggedIn()); + Assert::same('guest', $user->getId()); + Assert::same(['guest-role'], $user->getRoles()); +}); + + +test('retained identity stays for personalization but roles fall back to the guest identity', function () { + $user = new User(new MockUserStorage, new GuestAuthenticator); + $user->login('john', 'pass'); + + $user->logout(); // persistIdentity is on by default -> identity is retained + + Assert::false($user->isLoggedIn()); + Assert::same('john', $user->getId()); // retained real identity for personalization + Assert::same(['guest-role'], $user->getRoles()); // but NOT the real ['admin'] roles +}); + + +test('without a guest identity provider the behaviour is unchanged', function () { + $authenticator = new class implements Nette\Security\Authenticator { + public function authenticate(string $username, string $password): IIdentity + { + return new SimpleIdentity('john', ['admin']); + } + }; + $user = new User(new MockUserStorage, $authenticator); + + Assert::false($user->isLoggedIn()); + Assert::null($user->getIdentity()); + Assert::same(['guest'], $user->getRoles()); +}); + + +test('guest identity is never written to storage', function () { + $storage = new RecordingStorage; + $user = new User($storage, new GuestAuthenticator); + + // acting as a guest must not persist anything + Assert::same('guest', $user->getId()); + Assert::same(['guest-role'], $user->getRoles()); + Assert::equal(new SimpleIdentity('guest', ['guest-role'], ['name' => 'Guest']), $user->getIdentity()); + Assert::same([], $storage->saved); + + // login/logout cycle, then guest again + $user->login('john', 'pass'); + $user->logout(clearIdentity: true); + Assert::same('guest', $user->getId()); + + // only the real identity may ever reach the storage + Assert::same(['john'], array_map(fn(IIdentity $i) => $i->getId(), $storage->saved)); +}); diff --git a/tests/Security/User.identityHandler.phpt b/tests/Security/User.identityHandler.phpt new file mode 100644 index 00000000..dbd654d4 --- /dev/null +++ b/tests/Security/User.identityHandler.phpt @@ -0,0 +1,187 @@ + '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']); + } + + + public function getGuestIdentity(): ?IIdentity + { + return null; + } +} + + +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; + } + + + public function getGuestIdentity(): ?IIdentity + { + 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 00000000..0f072868 --- /dev/null +++ b/tests/Security/User.namespaces.phpt @@ -0,0 +1,290 @@ +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.persistIdentity.phpt b/tests/Security/User.persistIdentity.phpt new file mode 100644 index 00000000..4fdf3dcc --- /dev/null +++ b/tests/Security/User.persistIdentity.phpt @@ -0,0 +1,54 @@ +login(new SimpleIdentity('John Doe', 'admin')); + + $user->logout(); + + Assert::false($user->isLoggedIn()); + Assert::equal(new SimpleIdentity('John Doe', 'admin'), $user->getIdentity()); + Assert::same('John Doe', $user->getId()); +}); + + +test('identity is discarded after logout when persistIdentity is off', function () { + $user = new User(new MockUserStorage); + $user->persistIdentity = false; + $user->login(new SimpleIdentity('John Doe', 'admin')); + + $user->logout(); + + Assert::false($user->isLoggedIn()); + Assert::null($user->getIdentity()); + Assert::null($user->getId()); +}); + + +test('identity already stored without authentication is not exposed when persistIdentity is off', function () { + $storage = new MockUserStorage; + $user = new User($storage); + $user->login(new SimpleIdentity('John Doe', 'admin')); + $user->logout(); // identity stays in storage, not authenticated + + // fresh User over the same storage with persistIdentity off (e.g. after disabling it in config) + $user = new User($storage); + $user->persistIdentity = false; + + Assert::false($user->isLoggedIn()); + Assert::null($user->getIdentity()); + Assert::null($user->getId()); +}); diff --git a/tests/Security/User.refreshStorage.phpt b/tests/Security/User.refreshStorage.phpt new file mode 100644 index 00000000..5bfd4052 --- /dev/null +++ b/tests/Security/User.refreshStorage.phpt @@ -0,0 +1,310 @@ +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]), + ); + } + + + public function getGuestIdentity(): ?IIdentity + { + return null; + } + }; + + $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 85f7726f..a297aa39 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,19 +1,14 @@ -', $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 00000000..0bb42c6d --- /dev/null +++ b/tests/types/security-types.phpt @@ -0,0 +1,7 @@ +