diff --git a/.gitattributes b/.gitattributes index ab732073..5724eae8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,12 @@ -.gitattributes export-ignore -.gitignore export-ignore -.github 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 +AGENTS.md export-ignore +src/**/*.latte export-ignore +docs/ export-ignore +tests/ export-ignore + +*.php* diff=php +*.sh text eol=lf diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md deleted file mode 100644 index a4cd1263..00000000 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: "🐛 Bug Report" -about: "If something isn't working as expected 🤔" - ---- - -Version: ?.?.? - -### Bug Description -... A clear and concise description of what the bug is. A good bug report shouldn't leave others needing to chase you up for more information. - -### Steps To Reproduce -... If possible a minimal demo of the problem ... - -### Expected Behavior -... A clear and concise description of what you expected to happen. - -### Possible Solution -... Only if you have suggestions on a fix for the bug diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md deleted file mode 100644 index d2e21948..00000000 --- a/.github/ISSUE_TEMPLATE/Feature_request.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: "🚀 Feature Request" -about: "I have a suggestion (and may want to implement it) 🙂" - ---- - -- Is your feature request related to a problem? Please describe. -- Explain your intentions. -- It's up to you to make a strong case to convince the project's developers of the merits of this feature. diff --git a/.github/ISSUE_TEMPLATE/Support_question.md b/.github/ISSUE_TEMPLATE/Support_question.md deleted file mode 100644 index 75c48b6e..00000000 --- a/.github/ISSUE_TEMPLATE/Support_question.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: "🤗 Support Question" -about: "If you have a question 💬, please check out our forum!" - ---- - ---------------^ Click "Preview" for a nicer view! -We primarily use GitHub as an issue tracker; for usage and support questions, please check out these resources below. Thanks! 😁. - -* Nette Forum: https://forum.nette.org -* Nette Gitter: https://gitter.im/nette/nette -* Slack (czech): https://pehapkari.slack.com/messages/C2R30BLKA diff --git a/.github/ISSUE_TEMPLATE/Support_us.md b/.github/ISSUE_TEMPLATE/Support_us.md deleted file mode 100644 index 92d8a4c3..00000000 --- a/.github/ISSUE_TEMPLATE/Support_us.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: "❤️ Support us" -about: "If you would like to support our efforts in maintaining this project 🙌" - ---- - ---------------^ Click "Preview" for a nicer view! - -> https://nette.org/donate - -Help support Nette! - -We develop Nette Framework for more than 14 years. In order to make your life more comfortable. Nette cares about the safety of your sites. Nette saves you time. And gives job opportunities. - -Nette earns you money. And is absolutely free. - -To ensure future development and improving the documentation, we need your donation. - -Whether you are chief of IT company which benefits from Nette, or developer who goes for advice on our forum, if you like Nette, [please make a donation now](https://nette.org/donate). - -Thank you! diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index f8aa3f40..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,11 +0,0 @@ -- bug fix / new feature? -- BC break? yes/no -- doc PR: nette/docs#??? - - 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 d2ec5115..00000000 --- a/.travis.yml +++ /dev/null @@ -1,71 +0,0 @@ -language: php -php: - - 7.1 - - 7.2 - - 7.3 - -before_install: - # turn off XDebug - - phpenv config-rm xdebug.ini || return 0 - -install: - - travis_retry composer install --no-progress --prefer-dist - -script: - - vendor/bin/tester tests -s - -after_failure: - # Print *.actual content - - for i in $(find tests -name \*.actual); do echo "--- $i"; cat $i; echo; echo; done - -jobs: - include: - - name: Lowest Dependencies - install: - - travis_retry composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable - - - - name: Nette Code Checker - install: - - travis_retry composer create-project nette/code-checker temp/code-checker ^3 --no-progress - script: - - php temp/code-checker/code-checker --strict-types - - - - name: Nette Coding Standard - install: - - travis_retry composer create-project nette/coding-standard temp/coding-standard ^2 --no-progress - script: - - php temp/coding-standard/ecs check src tests --config temp/coding-standard/coding-standard-php71.yml - - - - stage: Static Analysis (informative) - install: - # Install PHPStan - - travis_retry composer create-project phpstan/phpstan-shim temp/phpstan --no-progress - - travis_retry composer install --no-progress --prefer-dist - script: - - php temp/phpstan/phpstan.phar analyse --autoload-file vendor/autoload.php --level 5 src - - - - stage: Code Coverage - script: - - vendor/bin/tester -p phpdbg tests -s --coverage ./coverage.xml --coverage-src ./src - after_script: - - wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar - - php coveralls.phar --verbose --config tests/.coveralls.yml - - - allow_failures: - - stage: Static Analysis (informative) - - stage: Code Coverage - - -sudo: false - -cache: - directories: - - $HOME/.composer/cache - -notifications: - email: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..70d272da --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# To My Agents! + +It is my fervent wish that this file guide every AI coding agent working with code in this repository. + +## Documentation + +Any distilled, agent-facing documentation for this package - how it works +internally and the rationale behind key design decisions - lives in `docs/`. +Consult it before non-trivial changes; it is the source of truth from which the +public manual is distilled. + +Almost all the expensive-to-reconstruct knowledge is in one subsystem - the +`Permission` ACL resolution engine - plus a few `User` and storage invariants. +This is security-critical: read `docs/internals.md` before touching +authorization logic. + +## Project Overview + +Nette Security provides authentication (login/logout via pluggable +`Authenticator`s), authorization (role-based ACL via `Permission`), and identity +persistence (`UserStorage`: session or cookie). Standalone library. Developers +**implement the interfaces, never extend the concrete classes**. + +- **PHP Version**: 8.1 - 8.5 +- **Package**: `nette/security` + +## Essential Commands + +```bash +# Run all tests +vendor/bin/tester tests -s # or: composer tester +vendor/bin/tester tests/Security.DI/ -s + +# Static analysis (PHPStan level 8) +composer phpstan +``` + +## Conventions + +- Every file starts with `declare(strict_types=1);`; **tabs**; single quotes unless + the string has an apostrophe; no `Abstract`/`Interface`/`I` prefixes; Nette Coding + Standard. Mark password parameters with `#[\SensitiveParameter]`. +- Tests are Nette Tester `.phpt` (`tests/Security/`, `tests/Security.DI/`, + `tests/Security.Http/`) plus PHPStan type tests in `tests/types/`; + `Permission*.phpt` holds 40+ ACL scenarios. +- Commit messages: lowercase imperative, no trailing period, `component: change` + (e.g. `User: support for custom authenticators`). + +## Working in this repo + +- **`Permission` rule types are plain booleans, not an enum:** `Allow = true`, + `Deny = false`, `All = null` (wildcard), and `getRuleType()` returns `?bool` + (`null` = no rule). The default state is **deny-all (whitelist)**. +- **`isAllowed()` resolves along three axes:** walk the resource tree (a more + specific resource wins over its parents) x DFS the role DAG (the **most recently + added parent has the highest weight**) x privilege (a specific privilege before + `allPrivileges`). For a whole-resource query (`privilege = All`), **any single + deny vetoes** the blanket allow. +- **A failed `assert` callback makes the rule *not apply*** (falls through as if + absent) - except on the ultimate default rule, where it returns the **inverted** + type. Assertions receive string IDs and reach objects via + `getQueriedRole()`/`getQueriedResource()`; `isAllowed()` is **not re-entrant** - + it nulls the queried role/resource on return, so an assertion must read them + before calling `isAllowed()` again. +- **Effective roles come from login state, not the retained identity.** After + `logout()` the identity is kept by default (`persistIdentity`), but roles drop to + guest - which is why `isInRole()`/`isAllowed()` need no prior `isLoggedIn()` check + and a retained identity never leaks its privileges. +- **`loadStoredData()` runs once; `wakeupIdentity()` may return `null` to revoke + auth** on a request (the per-request role-refresh/token seam); `sleepIdentity()` + is its `login()`-time counterpart. **Switching the storage namespace requires + `refreshStorage()`** or `User` serves stale authentication. +- User-facing how-to (fluent ACL API, `IdentityHandler`/guest identity, password + hashing, NEON config, presenter integration, multiple authenticators) is manual + material and lives in the public web docs, not here. diff --git a/composer.json b/composer.json index b7f4a27f..84842ec0 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "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", "GPL-3.0"], + "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], "authors": [ { "name": "David Grudl", @@ -15,25 +15,42 @@ } ], "require": { - "php": ">=7.1", - "nette/utils": "^2.4 || ^3.0" + "php": "8.3 - 8.5", + "nette/utils": "^4.0" }, "require-dev": { - "nette/di": "^3.0.0", - "nette/http": "^3.0.0", - "nette/tester": "^2.0", - "tracy/tracy": "^2.4" + "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/di": "<3.0-stable" + "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": "3.0-dev" + "dev-master": "4.0-dev" + } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true } } } diff --git a/contributing.md b/contributing.md deleted file mode 100644 index 184152c0..00000000 --- a/contributing.md +++ /dev/null @@ -1,33 +0,0 @@ -How to contribute & use the issue tracker -========================================= - -Nette welcomes your contributions. There are several ways to help out: - -* Create an issue on GitHub, if you have found a bug -* Write test cases for open bug issues -* Write fixes for open bug/feature issues, preferably with test cases included -* Contribute to the [documentation](https://nette.org/en/writing) - -Issues ------- - -Please **do not use the issue tracker to ask questions**. We will be happy to help you -on [Nette forum](https://forum.nette.org) or chat with us on [Gitter](https://gitter.im/nette/nette). - -A good bug report shouldn't leave others needing to chase you up for more -information. Please try to be as detailed as possible in your report. - -**Feature requests** are welcome. But take a moment to find out whether your idea -fits with the scope and aims of the project. It's up to *you* to make a strong -case to convince the project's developers of the merits of this feature. - -Contributing ------------- - -If you'd like to contribute, please take a moment to read [the contributing guide](https://nette.org/en/contributing). - -The best way to propose a feature is to discuss your ideas on [Nette forum](https://forum.nette.org) before implementing them. - -Please do not fix whitespace, format code, or make a purely cosmetic patch. - -Thanks! :heart: diff --git a/docs/internals.md b/docs/internals.md new file mode 100644 index 00000000..e648e2dd --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,174 @@ +# Security internals + +How `nette/security` works underneath, for agents editing it. Almost all the +expensive-to-reconstruct knowledge lives in one subsystem — the `Permission` ACL +resolution engine — so this is one file, weighted heavily toward it. `User`, +`Authenticator`, `Identity`, and `UserStorage` are mostly readable from their +signatures; only a few of their invariants are captured here. + +## Rule encoding: booleans and null, not an enum + +Three constants (`Authorizator`) permeate every branch and are easy to +misread: + +- `All = null` — the "any role / any resource / any privilege" wildcard. +- `Allow = true`, `Deny = false` — rule **types are plain booleans**. + +Consequently the internal `getRuleType()` returns **`?bool`**: `null` = "no rule +found", `true` = allow, `false` = deny. The **default state is deny-all**: the +root rule (`$rules['allResources']['allRoles']['allPrivileges']`) is seeded to +`Deny`, so an ACL with no rules denies everything (whitelist model). + +## `$rules`: a three-axis nested map with string keys + +The entire ACL state is one nested array (`Permission::$rules`) indexed on +three specificity axes: + +``` +byResource[] | allResources (resource axis) + → byRole[] | allRoles (role axis) + → byPrivilege[] | allPrivileges (privilege axis) + → { type: Allow|Deny, assert: ?callable } +``` + +`getRules($resource, $role, $create)` navigates the first two axes **by +reference** (so callers mutate in place). The `all*` string keys are magic +sentinels that cannot collide with user IDs: user-supplied names live one level +deeper, under the sibling `byResource`/`byRole`/`byPrivilege` subarrays, so even +a role literally named `allRoles` lands in `byRole['allRoles']`. Roles and +resources are stored as **string IDs**; the +`Role`/`Resource` objects passed to `isAllowed()` are reduced to IDs immediately. + +## `isAllowed()`: the resolution algorithm (the emergent model) + +This is what you can only get by tracing. `isAllowed()` resolves a +`(role, resource, privilege)` query by two nested traversals: + +1. **Resource axis — walk up the resource tree.** Starting at the queried + resource, search the role DAG (step 2); if undecided, consult the `allRoles` + pseudo-parent at this resource; then move to the resource's parent + (`resources[$resource]['parent']`) and repeat, ending at `allResources`. + **More specific resources win over their parents.** +2. **Role axis — DFS over the role DAG** (`searchRolePrivileges`). A + stack-based depth-first search from the queried role up through parents. Since + parents are pushed in insertion order and popped last-first, **the most + recently added parent has the highest weight** (`addRole('x', ['a','b'])` → `b` + wins conflicts). A `$visited` set guards the DAG against diamond re-checks. +3. **Privilege axis — specific before general.** For any (resource, role) pair, + a rule on the exact `$privilege` is consulted before the `allPrivileges` rule. + +**Deny wins for whole-resource queries.** When the privilege is `All` (asking +"may this role do *anything* here"), the search scans every `byPrivilege` rule +and returns `Deny` if **any** of them denies, before falling back to the +`allPrivileges` rule. A single specific deny vetoes the blanket allow. + +The first definitive `Allow`/`Deny` found in this order is returned; if the whole +traversal finds nothing, the deny-all root answers. + +## Assertions: conditional rules, an inverted default, and queried-state lifetime + +A rule may carry an `assert` callback. In `getRuleType()`: + +- If the assertion returns **false, the rule does not apply** — it yields `null` + and resolution falls through to the next candidate, exactly as if the rule were + absent. This is the trap for anyone reading only static rules: a present rule + can be silently skipped at query time. +- **The one exception is the ultimate default rule** (all resources, all roles, + all privileges). A failed assertion there has nothing left to fall back to, so + it returns the **inverted** type (`Allow`→`Deny`, `Deny`→`Allow`) to force a + definite answer. +- Assertions receive **string IDs**, not objects. To reach the actual queried + object they call `getQueriedRole()` / `getQueriedResource()`, which return + whatever was passed to `isAllowed()`. +- `isAllowed()` stores the queried role/resource in object state and + unconditionally **nulls both on normal return**. There is **no save/restore + and no `finally`**: a nested `isAllowed()` call from inside an assertion + clobbers the queried state and leaves it `null` for the rest of the outer + assertion, and an exception mid-query (unknown role/resource) leaves stale + values behind. An assertion must read `getQueriedRole()`/`getQueriedResource()` + **before** recursing into `isAllowed()`. + +## User: effective roles come from login state, not the retained identity + +`User` is a thin facade over `UserStorage` + an `Authenticator`/`Authorizator`. +The few non-obvious invariants: + +- **Effective roles track the login state, not the identity object** + (`getRoles()`). Logged in → the identity's roles (or `authenticatedRole`); + **not** logged in → the guest identity's roles or `[guestRole]`. So after + `logout()`, even though the identity is **retained** by default + (`persistIdentity`), the user drops to guest roles. This is why + `isInRole()`/`isAllowed()` need no prior `isLoggedIn()` check — and why a + retained identity never leaks its privileges. +- **State is loaded lazily once, and can be vetoed on load.** + `loadStoredData()` runs a single time (guarded by `$authenticated !== null`), + reads the storage state, and — if the authenticator is an `IdentityHandler` — + passes the stored identity through **`wakeupIdentity()`, which may return + `null` to revoke authentication** on this request. `wakeupIdentity` is the + per-request role-refresh / token-revalidation seam; `sleepIdentity` is its + counterpart at `login()` time (e.g. storing only a token for cookie storage). +- **`refreshStorage()` discards the cached state.** Switching the storage + **namespace** (multiple independent logins per session) without calling + `refreshStorage()` leaves `User` serving stale authentication. +- **Guest identity is resolved on read only and never written to storage**; + its resolution is cached and invalidated by `setAuthenticator`/`refreshStorage`. + It comes from `getGuestIdentity()` on the `IdentityHandler` authenticator — an + **optional** method declared only as `@method` phpDoc and detected via + `method_exists`, not part of the interface proper. +- **`User::isAllowed()` is a disjunction over effective roles**: the user + is allowed if **any single** of their roles is allowed — a deny resolved for + one role never vetoes another role's allow. Single-role resolution lives in + `Permission`; the multi-role OR lives only here. This is the opposite polarity + of the deny-veto inside a whole-resource query, which applies per role. + +## Storages: session and cookie are not interchangeable + +`UserStorage::getState()` returns the tuple `[authenticated, identity, +logoutReason]`; everything else differs between the two implementations: + +- **`SessionStorage` checks expiration lazily and it slides.** The check runs in + `getSessionSection()` on first access: expired → `LogoutInactivity` (and the + identity is dropped only if `expireIdentity` was set); not expired → + `expireTime` is pushed forward by `expireDelta` (sliding window). Both + `saveAuthentication()` **and** `clearAuthentication()` regenerate the session + ID (session-fixation defence) — login/logout mutate the whole session, not just + the `Nette.Http.UserStorage/` section. `setNamespace()` drops the + cached section; the `User`-side counterpart it needs is `refreshStorage()`. +- **`CookieStorage` stores only `$identity->getId()`** and requires it to be at + least 13 chars — i.e. a random token, never a database ID. `getState()` + reconstructs a bare `SimpleIdentity` (no roles, no data), so cookie storage is + usable only with an `IdentityHandler`: `sleepIdentity()` returns the token + identity, `wakeupIdentity()` resolves it back to the real one. + `clearAuthentication()` **ignores `$clearIdentity`** — the cookie is always + deleted, so **`persistIdentity` has no effect with cookie storage**. +- In the DI bridge (`SecurityExtension`), `cookieDomain: 'domain'` is a magic + literal resolved at runtime to the request's second-level domain. + +## Legacy seams and BC constraints + +- **`User::login()` branches on `instanceof Authenticator`**: the modern + interface gets `authenticate($username, $password)`, the deprecated + `IAuthenticator` gets a single credentials array. `IAuthenticator::authenticate()` + exists only as a commented-out declaration plus `@method` phpDoc — PHP does not + enforce it, which is why `phpstan.neon` carries an ignore for `User.php`. +- `compatibility.php` `class_alias`es the removed `IAuthorizator`/`IResource`/`IRole` + names and is excluded from PHPStan analysis. +- **`SimpleIdentity extends Identity` — the deprecated class is the parent**, + kept so identities serialized in existing sessions keep unserializing. Don't + flip the hierarchy or remove `Identity`. +- `Identity::setId()` normalizes numeric strings to int only when the round-trip + is lossless (`'123'` → `123`; `'0123'` stays a string). + +## Navigation map + +| Concern | Where | +|---|---| +| Rule constants, `?bool` typing | `Authorizator`, `Permission::getRuleType` | +| ACL state shape | `Permission::$rules`, `getRules` | +| Resolution order (resource × role × privilege) | `Permission::isAllowed`, `searchRolePrivileges` | +| Assertions, queried role/resource | `Permission::getRuleType`, `getQueriedRole`/`getQueriedResource` | +| Effective roles, login state, multi-role OR | `User::getRoles`, `isInRole`, `isAllowed` | +| Identity lifecycle, veto, seams | `User::loadStoredData`, `login`/`logout`, `IdentityHandler` | +| Sliding expiration, fixation defence, namespaces | `SessionStorage` | +| Token-only persistence, `persistIdentity` no-op | `CookieStorage` | +| Legacy authenticator dispatch, BC aliases | `User::login`, `IAuthenticator`, `compatibility.php` | diff --git a/license.md b/license.md index cf741bd0..a955f5d3 100644 --- a/license.md +++ b/license.md @@ -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 3d5d8180..9853eab9 100644 --- a/readme.md +++ b/readme.md @@ -2,12 +2,11 @@ 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 ============ @@ -21,173 +20,191 @@ Authentication & Authorization library for Nette. Documentation can be found on the [website](https://doc.nette.org/access-control). -If you like Nette, **[please make a donation now](https://nette.org/donate)**. Thank you! +It requires PHP version 8.3 and supports PHP up to 8.5. -Installation -============ +[Support Me](https://github.com/sponsors/dg) +-------------------------------------------- -The recommended way to install is via Composer: +Do you like Nette Security? Are you looking forward to the new features? -``` -composer require nette/security -``` +[![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) -It requires PHP version 7.1 and supports PHP up to 7.3. +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? +Simple, right? And all security aspects are handled by Nette for you. -Logging in requires users to have cookies enabled - other methods are not safe! - -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. +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 $user->setExpiration('30 minutes'); -// login expires after two days of inactivity -$user->setExpiration('2 days'); +// cancel expiration +$user->setExpiration(null); ``` 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 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 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 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, 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', +]); ``` -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 Nette\Security\IAuthenticator, with its only method `authenticate()`. Its only purpose is to return an 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 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 (!NS\Passwords::verify($password, $row->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 layer and works with table `users`, where it grabs `username` and 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; +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 --------- +======== + +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: + +```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. + +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. -Identity presents a set of user information, as returned by autheticator. It's an object implementing Nette\Security\IIdentity interface, with default implementation 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. -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)`. +Guest Identity +-------------- -Service `user` of class Nette\Security\User keeps the identity in session and uses it to all authorizations. -Identity can be access with `getIdentity` upon `$user`: +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? @@ -199,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? @@ -209,38 +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 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 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(); } @@ -250,110 +271,156 @@ if ($user->isAllowed('file', 'delete')) { // is user allowed to delete a resourc } ``` -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 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 direct 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('registered') - only direct parents -``` - -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()`. +Resources can also use inheritance, for example, we can add `$acl->addResource('perex', 'article')`. -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: - -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'); + +// the registered inherits the permissions from guesta, we will also let him to comment +$acl->allow('registered', 'comment', 'add'); + +// the administrator can view and edit anything +$acl->allow('administrator', $acl::All, ['view', 'edit', 'add']); +``` -// registered user has also right to add comments -$acl->allow('registered', 'comments', 'add'); +What if we want to **prevent** someone from accessing a resource? -// administrator may also edit and add everything -$acl->allow('administrator', Permission::ALL, array('view', 'edit', 'add')); +```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 is true for the registered user, though he is allowed to add a comment: +The same applies to a registered user, but he can also comment: ```php -echo $acl->isAllowed('registered', 'article', 'view'); // true -echo $acl->isAllowed('registered', 'comments', 'add'); // true -echo $acl->isAllowed('registered', 'backend', 'view'); // false +$acl->isAllowed('registered', 'article', 'view'); // true +$acl->isAllowed('registered', 'comment', 'add'); // true +$acl->isAllowed('registered', 'comment', 'edit'); // false ``` -Administrator is allowed to do everything: +The administrator can edit everything except polls: ```php -echo $acl->isAllowed('administrator', 'article', 'view'); // true -echo $acl->isAllowed('administrator', 'commend', 'add'); // true -echo $acl->isAllowed('administrator', 'poll', 'edit'); // true +$acl->isAllowed('administrator', 'poll', 'vote'); // true +$acl->isAllowed('administrator', 'poll', 'edit'); // false +$acl->isAllowed('administrator', 'comment', 'edit'); // true ``` -Admin rules may possibly be defined without any restrictions (without inheriting from any other roles): +Permissions can also be evaluated dynamically and we can leave the decision to our own callback, to which all parameters are passed: ```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 { + return ...; +}; + +$acl->allow('registered', 'comment', null, $assertion); ``` -Whenever during the application runtime we may remove roles with `removeRolle()`, resources with `removeResource()` or rules with `removeAllow()` or `removeDeny()`. +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: -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 +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'; + } +} +``` + +And now let's create a rule: ```php -$acl = new Permission(); +$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); +``` + +The ACL is queried by passing objects: + +```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 Nette\Security\Permission; $acl->addRole('admin'); $acl->addRole('guest'); @@ -363,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->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->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 index bb485da6..8cbcebda 100644 --- a/src/Bridges/SecurityDI/SecurityExtension.php +++ b/src/Bridges/SecurityDI/SecurityExtension.php @@ -1,53 +1,73 @@ -, 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 { - /** @var bool */ - private $debugMode; - - - public function __construct(bool $debugMode = false) - { - $this->debugMode = $debugMode; + public function __construct( + private readonly bool $debugMode = false, + ) { } public function getConfigSchema(): Nette\Schema\Schema { return Expect::structure([ - 'debugger' => Expect::bool(interface_exists(\Tracy\IBarPanel::class)), + 'debugger' => Expect::bool(), 'users' => Expect::arrayOf( Expect::anyOf( - Expect::string(), // user => password - Expect::structure([ // user => password + roles - 'password' => Expect::string(), + Expect::string()->dynamic(), // user => password + Expect::structure([ // user => password + roles + data + 'password' => Expect::string()->dynamic(), 'roles' => Expect::anyOf(Expect::string(), Expect::listOf('string')), - ])->castTo('array') - ) + '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() + public function loadConfiguration(): void { $config = $this->config; $builder = $this->getContainerBuilder(); @@ -55,31 +75,45 @@ public function loadConfiguration() $builder->addDefinition($this->prefix('passwords')) ->setFactory(Nette\Security\Passwords::class); - $builder->addDefinition($this->prefix('userStorage')) - ->setType(Nette\Security\IUserStorage::class) - ->setFactory(Nette\Http\UserStorage::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 ($this->debugMode && $config->debugger) { - $user->addSetup('@Tracy\Bar::addPanel', [ - new Nette\DI\Definitions\Statement(Nette\Bridges\SecurityTracy\UserPanel::class), - ]); + if ($auth->expiration) { + $user->addSetup('setExpiration', [$auth->expiration]); + } + + if (!$auth->persistIdentity) { + $user->addSetup('$persistIdentity', [false]); } if ($config->users) { - $usersList = $usersRoles = []; + $usersList = $usersRoles = $usersData = []; foreach ($config->users as $username => $data) { $data = is_array($data) ? $data : ['password' => $data]; - $this->validateConfig(['password' => null, 'roles' => null], $data, $this->prefix("security.users.$username")); $usersList[$username] = $data['password']; $usersRoles[$username] = $data['roles'] ?? null; + $usersData[$username] = $data['data'] ?? []; } $builder->addDefinition($this->prefix('authenticator')) - ->setType(Nette\Security\IAuthenticator::class) - ->setFactory(Nette\Security\SimpleAuthenticator::class, [$usersList, $usersRoles]); + ->setType(Nette\Security\Authenticator::class) + ->setFactory(Nette\Security\SimpleAuthenticator::class, [$usersList, $usersRoles, $usersData]); if ($this->name === 'security') { $builder->addAlias('nette.authenticator', $this->prefix('authenticator')); @@ -88,12 +122,13 @@ public function loadConfiguration() if ($config->roles || $config->resources) { $authorizator = $builder->addDefinition($this->prefix('authorizator')) - ->setType(Nette\Security\IAuthorizator::class) + ->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]); } @@ -108,4 +143,21 @@ public function loadConfiguration() $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/CookieIdentity.php b/src/Bridges/SecurityHttp/CookieIdentity.php new file mode 100644 index 00000000..3ffd8920 --- /dev/null +++ b/src/Bridges/SecurityHttp/CookieIdentity.php @@ -0,0 +1,57 @@ +uid = $uid; + } + + + public function getId(): string + { + return $this->uid; + } + + + /** + * @throws Nette\NotSupportedException + */ + public function getRoles(): array + { + throw new Nette\NotSupportedException; + } + + + /** + * @return array + * @throws Nette\NotSupportedException + */ + public function getData(): array + { + throw new Nette\NotSupportedException; + } +} 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 0ccbd18b..75a523d8 100644 --- a/src/Bridges/SecurityTracy/UserPanel.php +++ b/src/Bridges/SecurityTracy/UserPanel.php @@ -1,16 +1,15 @@ -user = $user; + public function __construct( + private readonly Nette\Security\User $user, + ) { } @@ -35,25 +28,31 @@ public function __construct(Nette\Security\User $user) */ public function getTab(): ?string { - if (headers_sent() && !session_id()) { + if (!session_id()) { return null; } - ob_start(function () {}); - $user = $this->user; - require __DIR__ . '/templates/UserPanel.tab.phtml'; - return ob_get_clean(); + return Nette\Utils\Helpers::capture(function () { + $status = session_status() === PHP_SESSION_ACTIVE + ? $this->user->isLoggedIn() + : '?'; + require __DIR__ . '/dist/tab.phtml'; + }); } /** * Renders panel. */ - public function getPanel(): string + public function getPanel(): ?string { - ob_start(function () {}); - $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 18e3c954..00000000 --- a/src/Bridges/SecurityTracy/templates/UserPanel.panel.phtml +++ /dev/null @@ -1,13 +0,0 @@ - -
-

isLoggedIn()): ?>Logged inUnlogged

- - getIdentity()): echo Dumper::toHtml($user->getIdentity(), [Dumper::LIVE => true]); 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 4fa7e173..00000000 --- a/src/Bridges/SecurityTracy/templates/UserPanel.tab.phtml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/src/Security/AuthenticationException.php b/src/Security/AuthenticationException.php index 79c90b44..2a03b5bf 100644 --- a/src/Security/AuthenticationException.php +++ b/src/Security/AuthenticationException.php @@ -1,12 +1,10 @@ - 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 list */ function getRoles(): array; + + /** + * Returns user data. + */ + //function getData(): array; } diff --git a/src/Security/IUserStorage.php b/src/Security/IUserStorage.php deleted file mode 100644 index 4923abbc..00000000 --- a/src/Security/IUserStorage.php +++ /dev/null @@ -1,58 +0,0 @@ -setId($id); - $this->setRoles((array) $roles); - $this->data = $data instanceof \Traversable ? iterator_to_array($data) : (array) $data; - } - - - /** - * Sets the ID of user. - * @return static - */ - public function setId($id) - { - $this->id = is_numeric($id) && !is_float($tmp = $id * 1) ? $tmp : $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. - * @return static - */ - public function setRoles(array $roles) - { - $this->roles = $roles; - return $this; - } - - - /** - * Returns a list of roles that the user is a member of. - */ - public function getRoles(): array - { - return $this->roles; - } - - - /** - * Returns a user data. - */ - public function getData(): array - { - return $this->data; - } - - - /** - * Sets user data value. - */ - public function __set(string $key, $value): void - { - if ($this->parentIsSet($key)) { - $this->parentSet($key, $value); - - } else { - $this->data[$key] = $value; - } - } - - - /** - * Returns user data value. - * @return mixed - */ - public function &__get(string $key) - { - if ($this->parentIsSet($key)) { - return $this->parentGet($key); - - } else { - return $this->data[$key]; - } - } - - - public function __isset(string $key): bool - { - return isset($this->data[$key]) || $this->parentIsSet($key); - } -} 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 @@ +algo = $algo; - $this->options = $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. + * Computes a password hash containing the algorithm ID, settings, salt, and the hash itself. */ - public function hash(string $password): string + public function hash( + #[\SensitiveParameter] + string $password, + ): string { - $hash = isset($this) - ? @password_hash($password, $this->algo, $this->options) // @ is escalated to exception - : @password_hash($password, PASSWORD_BCRYPT, func_get_args()[1] ?? []); // back compatibility with v2.x + if ($password === '') { + throw new Nette\InvalidArgumentException('Password can not be empty.'); + } + $hash = @password_hash($password, $this->algo, $this->options); // @ is escalated to exception if (!$hash) { - throw new Nette\InvalidStateException('Computed hash is invalid. ' . error_get_last()['message']); + throw new Nette\InvalidStateException('Computed hash is invalid. ' . (error_get_last()['message'] ?? '')); } + return $hash; } /** - * Verifies that a password matches a hash. + * Checks whether the password matches the given hash. */ - public function verify(string $password, string $hash): bool + public function verify( + #[\SensitiveParameter] + string $password, + string $hash, + ): bool { return password_verify($password, $hash); } /** - * Checks if the given hash matches the options. + * Checks whether the hash needs to be rehashed with the current algorithm and options. */ public function needsRehash(string $hash): bool { - return isset($this) - ? password_needs_rehash($hash, $this->algo, $this->options) - : password_needs_rehash($hash, PASSWORD_BCRYPT, func_get_args()[1] ?? []); // back compatibility with v2.x + return password_needs_rehash($hash, $this->algo, $this->options); } } diff --git a/src/Security/Permission.php b/src/Security/Permission.php index 826db639..de276ea6 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -1,40 +1,35 @@ -, children: array}> */ + private array $roles = []; - /** @var array Role storage */ - private $roles = []; + /** @var array}> */ + private array $resources = []; - /** @var array Resource storage */ - private $resources = []; - - /** @var array Access Control List rules; whitelist (deny everything to all) by default */ - private $rules = [ + /** @var array Access Control List rules; whitelist (deny everything to all) by default */ + private array $rules = [ 'allResources' => [ 'allRoles' => [ 'allPrivileges' => [ - 'type' => self::DENY, + 'type' => self::Deny, 'assert' => null, ], 'byPrivilege' => [], @@ -44,10 +39,8 @@ class Permission implements IAuthorizator 'byResource' => [], ]; - /** @var mixed */ - private $queriedRole; - - private $queriedResource; + private string|Role|null $queriedRole; + private string|Resource|null $queriedResource; /********************* roles ****************d*g**/ @@ -56,14 +49,13 @@ class Permission implements IAuthorizator /** * Adds a Role to the list. The most recently added parent * takes precedence over parents that were previously added. - * @param string|array $parents + * @param string|list|null $parents * @throws Nette\InvalidArgumentException * @throws Nette\InvalidStateException - * @return static */ - public function addRole(string $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."); } @@ -92,11 +84,11 @@ public function addRole(string $role, $parents = null) /** - * Returns true if the Role exists in the list. + * Checks whether the Role exists in the list. */ public function hasRole(string $role): bool { - $this->checkRole($role, false); + $this->checkRole($role, exists: false); return isset($this->roles[$role]); } @@ -105,12 +97,12 @@ public function hasRole(string $role): bool * Checks whether Role is valid and exists in the list. * @throws Nette\InvalidStateException */ - private function checkRole(string $role, bool $throw = true): void + private function checkRole(string $role, bool $exists = true): void { if ($role === '') { throw new Nette\InvalidArgumentException('Role must be a non-empty string.'); - } elseif ($throw && !isset($this->roles[$role])) { + } elseif ($exists && !isset($this->roles[$role])) { throw new Nette\InvalidStateException("Role '$role' does not exist."); } } @@ -118,6 +110,7 @@ private function checkRole(string $role, bool $throw = true): void /** * Returns all Roles. + * @return list */ public function getRoles(): array { @@ -127,6 +120,7 @@ public function getRoles(): array /** * Returns existing Role's parents ordered by ascending priority. + * @return list */ public function getRoleParents(string $role): array { @@ -165,9 +159,8 @@ public function roleInheritsFrom(string $role, string $inherit, bool $onlyParent * Removes the Role from the list. * * @throws Nette\InvalidStateException - * @return static */ - public function removeRole(string $role) + public function removeRole(string $role): static { $this->checkRole($role); @@ -203,10 +196,8 @@ public function removeRole(string $role) /** * Removes all Roles from the list. - * - * @return static */ - public function removeAllRoles() + public function removeAllRoles(): static { $this->roles = []; @@ -228,15 +219,14 @@ public function removeAllRoles() /** - * Adds a Resource having an identifier unique to the list. + * Adds a Resource to the list. * * @throws Nette\InvalidArgumentException * @throws Nette\InvalidStateException - * @return static */ - public function addResource(string $resource, string $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."); @@ -257,11 +247,11 @@ public function addResource(string $resource, string $parent = null) /** - * Returns true if the Resource exists in the list. + * Checks whether the Resource exists in the list. */ public function hasResource(string $resource): bool { - $this->checkResource($resource, false); + $this->checkResource($resource, exists: false); return isset($this->resources[$resource]); } @@ -270,12 +260,12 @@ public function hasResource(string $resource): bool * Checks whether Resource is valid and exists in the list. * @throws Nette\InvalidStateException */ - private function checkResource(string $resource, bool $throw = true): void + private function checkResource(string $resource, bool $exists = true): void { if ($resource === '') { throw new Nette\InvalidArgumentException('Resource must be a non-empty string.'); - } elseif ($throw && !isset($this->resources[$resource])) { + } elseif ($exists && !isset($this->resources[$resource])) { throw new Nette\InvalidStateException("Resource '$resource' does not exist."); } } @@ -283,6 +273,7 @@ private function checkResource(string $resource, bool $throw = true): void /** * Returns all Resources. + * @return list */ public function getResources(): array { @@ -291,7 +282,7 @@ public function getResources(): array /** - * 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. * * @throws Nette\InvalidStateException @@ -328,9 +319,8 @@ public function resourceInheritsFrom(string $resource, string $inherit, bool $on * Removes a Resource and all of its children. * * @throws Nette\InvalidStateException - * @return static */ - public function removeResource(string $resource) + public function removeResource(string $resource): static { $this->checkResource($resource); @@ -360,9 +350,8 @@ public function removeResource(string $resource) /** * Removes all Resources. - * @return static */ - public function removeAllResources() + public function removeAllResources(): static { foreach ($this->resources as $resource => $foo) { foreach ($this->rules['byResource'] as $resourceCurrent => $rules) { @@ -383,15 +372,19 @@ 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|string[]|null $roles - * @param string|string[]|null $resources - * @param string|string[]|null $privileges - * @return static + * @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, callable $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; } @@ -399,62 +392,77 @@ public function allow($roles = self::ALL, $resources = self::ALL, $privileges = /** * Denies one or more Roles access to [certain $privileges upon] the specified Resource(s). * If $assertion is provided, then it must return true in order for rule to apply. - * - * @param string|string[]|null $roles - * @param string|string[]|null $resources - * @param string|string[]|null $privileges - * @return static + * @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, callable $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|string[]|null $roles - * @param string|string[]|null $resources - * @param string|string[]|null $privileges - * @return static + * @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|string[]|null $roles - * @param string|string[]|null $resources - * @param string|string[]|null $privileges - * @return static + * @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 string|string[]|null $roles - * @param string|string[]|null $resources - * @param string|string[]|null $privileges + * @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 static */ - protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privileges, callable $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 = [self::ALL]; + if ($roles === self::All) { + $roles = [self::All]; } else { if (!is_array($roles)) { @@ -467,8 +475,8 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile } // ensure that all specified Resources exist; normalize input to array of Resources or null - if ($resources === self::ALL) { - $resources = [self::ALL]; + if ($resources === self::All) { + $resources = [self::All]; } else { if (!is_array($resources)) { @@ -481,7 +489,7 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile } // normalize privileges to array - if ($privileges === self::ALL) { + if ($privileges === self::All) { $privileges = []; } elseif (!is_array($privileges)) { @@ -491,7 +499,7 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile 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; @@ -506,7 +514,6 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile } } } - } else { // remove from the rules foreach ($resources as $resource) { foreach ($roles as $role) { @@ -514,19 +521,22 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile 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 = [ 'allPrivileges' => [ - 'type' => self::DENY, + 'type' => self::Deny, 'assert' => null, - ], + ], 'byPrivilege' => [], ]; } + continue; } + if ($type === $rules['allPrivileges']['type']) { unset($rules['allPrivileges']); } @@ -542,7 +552,6 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile } } } - return $this; } @@ -557,55 +566,60 @@ protected function setRule(bool $toAdd, bool $type, $roles, $resources, $privile * and its respective parents are checked similarly before the lower-priority parents of * the Role are checked. * - * @param string|null|IRole $role - * @param string|null|IResource $resource - * @param string|null $privilege * @throws Nette\InvalidStateException */ - public function isAllowed($role = self::ALL, $resource = self::ALL, $privilege = self::ALL): bool + 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 && ($result = $this->searchRolePrivileges($privilege === self::ALL, $role, $resource, $privilege)) !== null) { + 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 (($result = $this->getRuleType($resource, null, $privilege)) === self::DENY) { + if (($result = $this->getRuleType($resource, null, $privilege)) === self::Deny) { break 2; } } + if (($result = $this->getRuleType($resource, null, null)) !== null) { break; } } - } else { - if (($result = $this->getRuleType($resource, null, $privilege)) !== null) { // look for rule on 'allRoles' pseudo-parent - break; - - } elseif (($result = $this->getRuleType($resource, null, 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); @@ -615,20 +629,18 @@ public function isAllowed($role = self::ALL, $resource = self::ALL, $privilege = /** - * 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; } @@ -640,10 +652,9 @@ 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? - * @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(bool $all, $role, $resource, $privilege) + private function searchRolePrivileges(bool $all, ?string $role, ?string $resource, ?string $privilege): ?bool { $dfs = [ 'visited' => [], @@ -654,13 +665,15 @@ private function searchRolePrivileges(bool $all, $role, $resource, $privilege) if (isset($dfs['visited'][$role])) { continue; } + if ($all) { if ($rules = $this->getRules($resource, $role)) { foreach ($rules['byPrivilege'] as $privilege2 => $rule) { - if ($this->getRuleType($resource, $role, $privilege2) === self::DENY) { - return self::DENY; + if ($this->getRuleType($resource, $role, $privilege2) === self::Deny) { + return self::Deny; } } + if (($type = $this->getRuleType($resource, $role, null)) !== null) { return $type; } @@ -679,24 +692,21 @@ private function searchRolePrivileges(bool $all, $role, $resource, $privilege) $dfs['stack'][] = $roleParent; } } + return null; } /** * Returns the rule type associated with the specified Resource, Role, and privilege. - * @param string|null $resource - * @param string|null $role - * @param string|null $privilege - * @return bool|null null if a rule does not exist or assertion fails, otherwise returns ALLOW or DENY */ - private function getRuleType($resource, $role, $privilege): ?bool + private function getRuleType(?string $resource, ?string $role, ?string $privilege): ?bool { if (!$rules = $this->getRules($resource, $role)) { return null; } - if ($privilege === self::ALL) { + if ($privilege === self::All) { if (isset($rules['allPrivileges'])) { $rule = $rules['allPrivileges']; } else { @@ -712,14 +722,14 @@ private function getRuleType($resource, $role, $privilege): ?bool if ($rule['assert'] === null || $rule['assert']($this, $role, $resource, $privilege)) { return $rule['type']; - } elseif ($resource !== self::ALL || $role !== self::ALL || $privilege !== self::ALL) { + } elseif ($resource !== self::All || $role !== self::All || $privilege !== self::All) { return null; - } elseif ($rule['type'] === self::ALLOW) { - return self::DENY; + } elseif ($rule['type'] === self::Allow) { + return self::Deny; } else { - return self::ALLOW; + return self::Allow; } } @@ -727,31 +737,34 @@ private function getRuleType($resource, $role, $privilege): ?bool /** * 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|null $resource - * @param string|null $role + * @return array|null */ - private function &getRules($resource, $role, bool $create = false): ?array + private function &getRules(?string $resource, ?string $role, bool $create = false): ?array { $null = null; - if ($resource === self::ALL) { + if ($resource === self::All) { $visitor = &$this->rules['allResources']; } else { if (!isset($this->rules['byResource'][$resource])) { if (!$create) { return $null; } + $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'] = []; } + return $visitor['allRoles']; } @@ -759,6 +772,7 @@ private function &getRules($resource, $role, bool $create = false): ?array if (!$create) { return $null; } + $visitor['byRole'][$role]['byPrivilege'] = []; } diff --git a/src/Security/IResource.php b/src/Security/Resource.php similarity index 65% rename from src/Security/IResource.php rename to src/Security/Resource.php index 67765d97..1a3bed4f 100644 --- a/src/Security/IResource.php +++ b/src/Security/Resource.php @@ -1,23 +1,23 @@ - password - * @param array $usersRoles list of pairs username => role[] - */ - public function __construct(array $userlist, array $usersRoles = []) - { - $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 + * Authenticates against the in-memory list of users (case-insensitive username). * @throws AuthenticationException */ - public function authenticate(array $credentials): IIdentity + public function authenticate( + string $username, + #[\SensitiveParameter] + string $password, + ): IIdentity { - [$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, $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..ef612084 --- /dev/null +++ b/src/Security/SimpleIdentity.php @@ -0,0 +1,147 @@ + $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; + + } elseif (array_key_exists($key, $this->data)) { + return $this->data[$key]; + + } else { + Nette\Utils\ObjectHelpers::strictGet(static::class, $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 780a1cb8..c42df7c0 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -1,68 +1,80 @@ - $roles + * @property-deprecated ?int $logoutReason + * @property-deprecated Authenticator $authenticator + * @property-deprecated Authorizator $authorizator */ class User { use Nette\SmartObject; - /** @deprecated */ + /** Log-out reason */ public const - MANUAL = IUserStorage::MANUAL, - INACTIVITY = IUserStorage::INACTIVITY; + LogoutManual = 1, + LogoutInactivity = 2; - /** @var string default role for unauthenticated user */ - public $guestRole = 'guest'; + #[\Deprecated('use User::LogoutManual')] + public const LOGOUT_MANUAL = self::LogoutManual; - /** @var string default role for authenticated user without own identity */ - public $authenticatedRole = 'authenticated'; + #[\Deprecated('use User::LogoutManual')] + public const MANUAL = self::LogoutManual; - /** @var callable[] function (User $sender): void; Occurs when the user is successfully logged in */ - public $onLoggedIn; + #[\Deprecated('use User::LogoutInactivity')] + public const LOGOUT_INACTIVITY = self::LogoutInactivity; - /** @var callable[] function (User $sender): void; Occurs when the user is logged out */ - public $onLoggedOut; + #[\Deprecated('use User::LogoutInactivity')] + public const INACTIVITY = self::LogoutInactivity; - /** @var IUserStorage Session storage for current user */ - private $storage; + /** role for an unauthenticated user, unless a guest identity provides its own roles */ + public string $guestRole = 'guest'; - /** @var IAuthenticator|null */ - private $authenticator; + /** default role for authenticated user without own identity */ + public string $authenticatedRole = 'authenticated'; - /** @var IAuthorizator|null */ - private $authorizator; + /** keep identity available (via getIdentity() and getId()) after logout or expiration; depends on the storage implementation */ + public bool $persistIdentity = true; + /** @var array Occurs when the user is successfully logged in */ + public array $onLoggedIn = []; - 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 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, + ) { } - final public function getStorage(): IUserStorage + final public function getStorage(): UserStorage { return $this->storage; } @@ -72,73 +84,138 @@ final public function getStorage(): IUserStorage /** - * Conducts the authentication process. Parameters are optional. - * @param string|IIdentity $user name or Identity + * 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($user, string $password = null): void + public function login( + string|IIdentity $username, + #[\SensitiveParameter] + ?string $password = null, + ): void { - $this->logout(true); - if (!$user instanceof IIdentity) { - $user = $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($user); - $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. + * Logs out the user from the current session. The identity is kept available afterwards, + * unless $clearIdentity is set or the $persistIdentity property is disabled. */ 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? + * Checks whether the user is authenticated. */ final public function isLoggedIn(): bool { - return $this->storage->isAuthenticated(); + $this->loadStoredData(); + return (bool) $this->authenticated; } /** - * Returns current user identity, if any. + * 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. */ final public function getIdentity(): ?IIdentity { - return $this->storage->getIdentity(); + $this->loadStoredData(); + return $this->identity ?? $this->resolveGuestIdentity(); + } + + + private function loadStoredData(): void + { + 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 static */ - public function setAuthenticator(IAuthenticator $handler) + public function setAuthenticator(Authenticator $handler): static { $this->authenticator = $handler; + $this->guestIdentityResolved = false; return $this; } @@ -146,22 +223,26 @@ public function setAuthenticator(IAuthenticator $handler) /** * Returns authentication handler. */ - final public function getAuthenticator(): ?IAuthenticator + final public function getAuthenticator(): Authenticator { - if (func_num_args()) { - trigger_error(__METHOD__ . '() parameter $throw is deprecated, use hasAuthenticator()', E_USER_DEPRECATED); - $throw = func_get_arg(0); - } - if (($throw ?? true) && !$this->authenticator) { + if (!$this->authenticator) { throw new Nette\InvalidStateException('Authenticator has not been set.'); } + return $this->authenticator; } /** - * Does the authentication exist? + * Returns authentication handler, or null if none is set. */ + final public function getAuthenticatorIfExists(): ?Authenticator + { + return $this->authenticator; + } + + + /** @deprecated */ final public function hasAuthenticator(): bool { return (bool) $this->authenticator; @@ -169,35 +250,22 @@ final public function hasAuthenticator(): bool /** - * Enables log out after inactivity (like '20 minutes'). Accepts flag IUserStorage::CLEAR_IDENTITY. - * @param string|null $expire - * @param int $flags - * @return static + * 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($expire, /*int*/$flags = 0) + public function setExpiration(?string $expire, bool $clearIdentity = false): static { - $clearIdentity = $flags === IUserStorage::CLEAR_IDENTITY; - if ($expire !== null && !is_string($expire)) { - trigger_error("Expiration should be a string like '20 minutes' etc.", E_USER_DEPRECATED); - } - if (is_bool($flags)) { - trigger_error(__METHOD__ . '() second parameter $whenBrowserIsClosed was removed.', E_USER_DEPRECATED); - } - if (func_num_args() > 2) { - $clearIdentity = $clearIdentity || func_get_arg(2); - trigger_error(__METHOD__ . '() third parameter is deprecated, use flag setExpiration($time, IUserStorage::CLEAR_IDENTITY)', E_USER_DEPRECATED); - } - $this->storage->setExpiration($expire, $clearIdentity ? IUserStorage::CLEAR_IDENTITY : 0); + $this->storage->setExpiration($expire, $clearIdentity || !$this->persistIdentity); return $this; } /** - * Why was user logged out? + * Returns the logout reason: LogoutManual or LogoutInactivity, or null if not applicable. */ final public function getLogoutReason(): ?int { - return $this->storage->getLogoutReason(); + return $this->logoutReason; } @@ -205,33 +273,41 @@ final public function getLogoutReason(): ?int /** - * Returns a list of effective roles that a user has been granted. + * 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(): array { if (!$this->isLoggedIn()) { - return [$this->guestRole]; + return $this->resolveGuestIdentity()?->getRoles() ?? [$this->guestRole]; } $identity = $this->getIdentity(); - return $identity && $identity->getRoles() ? $identity->getRoles() : [$this->authenticatedRole]; + return $identity?->getRoles() ?? [$this->authenticatedRole]; } /** - * Is a user in the specified effective role? + * Checks whether the user has the specified effective 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. + * 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): bool + public function isAllowed(mixed $resource = Authorizator::All, mixed $privilege = Authorizator::All): bool { foreach ($this->getRoles() as $role) { if ($this->getAuthorizator()->isAllowed($role, $resource, $privilege)) { @@ -245,9 +321,8 @@ public function isAllowed($resource = IAuthorizator::ALL, $privilege = IAuthoriz /** * Sets authorization handler. - * @return static */ - public function setAuthorizator(IAuthorizator $handler) + public function setAuthorizator(Authorizator $handler): static { $this->authorizator = $handler; return $this; @@ -257,22 +332,26 @@ public function setAuthorizator(IAuthorizator $handler) /** * Returns current authorization handler. */ - final public function getAuthorizator(): ?IAuthorizator + final public function getAuthorizator(): Authorizator { - if (func_num_args()) { - trigger_error(__METHOD__ . '() parameter $throw is deprecated, use hasAuthorizator()', E_USER_DEPRECATED); - $throw = func_get_arg(0); - } - if (($throw ?? true) && !$this->authorizator) { + if (!$this->authorizator) { throw new Nette\InvalidStateException('Authorizator has not been set.'); } + return $this->authorizator; } /** - * Does the authorization exist? + * 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..64a397df --- /dev/null +++ b/src/Security/UserStorage.php @@ -0,0 +1,42 @@ +addConfig($config)->compile()); @@ -43,16 +43,20 @@ $userList = [ '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]); + $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/Bridges.DI/SecurityExtension.authorizator.phpt similarity index 97% rename from tests/Security.DI/SecurityExtension.authorizator.phpt rename to tests/Bridges.DI/SecurityExtension.authorizator.phpt index 0f8b3b7c..abdf5690 100644 --- a/tests/Security.DI/SecurityExtension.authorizator.phpt +++ b/tests/Bridges.DI/SecurityExtension.authorizator.phpt @@ -1,11 +1,9 @@ -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/Bridges.DI/SecurityExtension.passwords.phpt similarity index 94% rename from tests/Security.DI/SecurityExtension.passwords.phpt rename to tests/Bridges.DI/SecurityExtension.passwords.phpt index a7e6efd1..66d3f765 100644 --- a/tests/Security.DI/SecurityExtension.passwords.phpt +++ b/tests/Bridges.DI/SecurityExtension.passwords.phpt @@ -1,11 +1,9 @@ -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/Bridges.DI/SecurityExtension.sessionStorage.phpt b/tests/Bridges.DI/SecurityExtension.sessionStorage.phpt new file mode 100644 index 00000000..923a8031 --- /dev/null +++ b/tests/Bridges.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/Bridges.DI/SecurityExtension.user.phpt similarity index 84% rename from tests/Security.DI/SecurityExtension.user.phpt rename to tests/Bridges.DI/SecurityExtension.user.phpt index 242a285d..cc4e0de0 100644 --- a/tests/Security.DI/SecurityExtension.user.phpt +++ b/tests/Bridges.DI/SecurityExtension.user.phpt @@ -1,11 +1,9 @@ -addExtension('security', new SecurityExtension); eval($compiler->compile()); $container = new Container; -Assert::type(Nette\Http\UserStorage::class, $container->getService('security.userStorage')); +Assert::type(Nette\Bridges\SecurityHttp\SessionStorage::class, $container->getService('security.userStorage')); Assert::type(Nette\Security\User::class, $container->getService('security.user')); // aliases diff --git a/tests/Bridges.Http/CookieStorage.authentication.phpt b/tests/Bridges.Http/CookieStorage.authentication.phpt new file mode 100644 index 00000000..eb7047dd --- /dev/null +++ b/tests/Bridges.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/Bridges.Http/CookieStorage.getState.phpt b/tests/Bridges.Http/CookieStorage.getState.phpt new file mode 100644 index 00000000..a9c70072 --- /dev/null +++ b/tests/Bridges.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/Bridges.Http/SessionStorage.expiration.phpt b/tests/Bridges.Http/SessionStorage.expiration.phpt new file mode 100644 index 00000000..13792ef1 --- /dev/null +++ b/tests/Bridges.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/Permission.CMSExample.phpt b/tests/Permission/integration.phpt similarity index 99% rename from tests/Security/Permission.CMSExample.phpt rename to tests/Permission/integration.phpt index 6f889f63..fb211ed8 100644 --- a/tests/Security/Permission.CMSExample.phpt +++ b/tests/Permission/integration.phpt @@ -1,11 +1,9 @@ -addResource('area', 'nonexistent'); -}, Nette\InvalidStateException::class, "Resource 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->addResource('area', 'nonexistent'), + Nette\InvalidStateException::class, + "Resource 'nonexistent' does not exist.", +); diff --git a/tests/Permission/resources.duplicate.phpt b/tests/Permission/resources.duplicate.phpt new file mode 100644 index 00000000..dac96f29 --- /dev/null +++ b/tests/Permission/resources.duplicate.phpt @@ -0,0 +1,20 @@ +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/Permission/resources.inherits.phpt similarity index 71% rename from tests/Security/Permission.ResourceInherits.phpt rename to tests/Permission/resources.inherits.phpt index 2512733c..0d95c9b0 100644 --- a/tests/Security/Permission.ResourceInherits.phpt +++ b/tests/Permission/resources.inherits.phpt @@ -1,11 +1,9 @@ -addResource('building', 'city'); $acl->addResource('room', 'building'); Assert::same(['city', 'building', 'room'], $acl->getResources()); -Assert::true($acl->resourceInheritsFrom('building', 'city', true)); -Assert::true($acl->resourceInheritsFrom('room', 'building', true)); +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', true)); +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')); diff --git a/tests/Permission/resources.inheritsNonExistent.phpt b/tests/Permission/resources.inheritsNonExistent.phpt new file mode 100644 index 00000000..6da62229 --- /dev/null +++ b/tests/Permission/resources.inheritsNonExistent.phpt @@ -0,0 +1,26 @@ +addResource('area'); +Assert::exception( + fn() => $acl->resourceInheritsFrom('nonexistent', 'area'), + Nette\InvalidStateException::class, + "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/Permission/resources.removeAll.phpt similarity index 90% rename from tests/Security/Permission.ResourceRemoveAll.phpt rename to tests/Permission/resources.removeAll.phpt index e5f8576d..49e1ba71 100644 --- a/tests/Security/Permission.ResourceRemoveAll.phpt +++ b/tests/Permission/resources.removeAll.phpt @@ -1,11 +1,9 @@ -addResource('area'); $acl->allow(null, 'area'); Assert::true($acl->isAllowed(null, 'area')); $acl->removeAllResources(); -Assert::exception(function () use ($acl) { - $acl->isAllowed(null, 'area'); -}, Nette\InvalidStateException::class, "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')); diff --git a/tests/Security/Permission.RulesResourceRemove.phpt b/tests/Permission/resources.removeCascade.phpt similarity index 70% rename from tests/Security/Permission.RulesResourceRemove.phpt rename to tests/Permission/resources.removeCascade.phpt index ee1b902e..a545bd6f 100644 --- a/tests/Security/Permission.RulesResourceRemove.phpt +++ b/tests/Permission/resources.removeCascade.phpt @@ -1,11 +1,9 @@ -addResource('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::class, "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')); diff --git a/tests/Security/Permission.ResourceRemoveOneNonExistent.phpt b/tests/Permission/resources.removeNonExistent.phpt similarity index 56% rename from tests/Security/Permission.ResourceRemoveOneNonExistent.phpt rename to tests/Permission/resources.removeNonExistent.phpt index 2b981b95..ad7a8cff 100644 --- a/tests/Security/Permission.ResourceRemoveOneNonExistent.phpt +++ b/tests/Permission/resources.removeNonExistent.phpt @@ -1,11 +1,9 @@ -removeResource('nonexistent'); -}, Nette\InvalidStateException::class, "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.RoleRegistryAddAndGetOne.phpt b/tests/Permission/roles.add.phpt similarity index 92% rename from tests/Security/Permission.RoleRegistryAddAndGetOne.phpt rename to tests/Permission/roles.add.phpt index a1e75d29..755c6a07 100644 --- a/tests/Security/Permission.RoleRegistryAddAndGetOne.phpt +++ b/tests/Permission/roles.add.phpt @@ -1,11 +1,9 @@ -addRole('guest', 'nonexistent'); -}, Nette\InvalidStateException::class, "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/Permission/roles.duplicate.phpt similarity index 53% rename from tests/Security/Permission.RoleRegistryDuplicate.phpt rename to tests/Permission/roles.duplicate.phpt index 9389568d..d7477d6d 100644 --- a/tests/Security/Permission.RoleRegistryDuplicate.phpt +++ b/tests/Permission/roles.duplicate.phpt @@ -1,11 +1,9 @@ -addRole('guest'); - $acl->addRole('guest'); -}, Nette\InvalidStateException::class, "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/Permission/roles.inherits.phpt similarity index 77% rename from tests/Security/Permission.RoleRegistryInherits.phpt rename to tests/Permission/roles.inherits.phpt index aaa19373..d9b551dd 100644 --- a/tests/Security/Permission.RoleRegistryInherits.phpt +++ b/tests/Permission/roles.inherits.phpt @@ -1,11 +1,9 @@ -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('member', 'guest', onlyParents: true)); +Assert::true($acl->roleInheritsFrom('editor', 'member', onlyParents: true)); Assert::true($acl->roleInheritsFrom('editor', 'guest')); -Assert::false($acl->roleInheritsFrom('editor', 'guest', true)); +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')); diff --git a/tests/Security/Permission.RoleRegistryInheritsMultiple.phpt b/tests/Permission/roles.inheritsMultiple.phpt similarity index 95% rename from tests/Security/Permission.RoleRegistryInheritsMultiple.phpt rename to tests/Permission/roles.inheritsMultiple.phpt index ec07e746..c563bb39 100644 --- a/tests/Security/Permission.RoleRegistryInheritsMultiple.phpt +++ b/tests/Permission/roles.inheritsMultiple.phpt @@ -1,11 +1,9 @@ -addRole('guest'); +Assert::exception( + fn() => $acl->roleInheritsFrom('nonexistent', 'guest'), + Nette\InvalidStateException::class, + "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.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt b/tests/Permission/roles.removeAfterAllowAll.phpt similarity index 93% rename from tests/Security/Permission.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt rename to tests/Permission/roles.removeAfterAllowAll.phpt index 3f337d96..8d60484a 100644 --- a/tests/Security/Permission.RemovingRoleAfterItWasAllowedAccessToAllResources.phpt +++ b/tests/Permission/roles.removeAfterAllowAll.phpt @@ -1,12 +1,10 @@ -addRole('guest'); $acl->allow('guest'); Assert::true($acl->isAllowed('guest')); $acl->removeAllRoles(); -Assert::exception(function () use ($acl) { - $acl->isAllowed('guest'); -}, Nette\InvalidStateException::class, "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')); diff --git a/tests/Security/Permission.RuleRoleRemove.phpt b/tests/Permission/roles.removeCascade.phpt similarity index 70% rename from tests/Security/Permission.RuleRoleRemove.phpt rename to tests/Permission/roles.removeCascade.phpt index 39f76f0f..80aaa9df 100644 --- a/tests/Security/Permission.RuleRoleRemove.phpt +++ b/tests/Permission/roles.removeCascade.phpt @@ -1,11 +1,9 @@ -addRole('guest'); $acl->allow('guest'); Assert::true($acl->isAllowed('guest')); $acl->removeRole('guest'); -Assert::exception(function () use ($acl) { - $acl->isAllowed('guest'); -}, Nette\InvalidStateException::class, "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')); diff --git a/tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt b/tests/Permission/roles.removeNonExistent.phpt similarity index 57% rename from tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt rename to tests/Permission/roles.removeNonExistent.phpt index cbc0e6a5..74cb015f 100644 --- a/tests/Security/Permission.RoleRegistryRemoveOneNonExistent.phpt +++ b/tests/Permission/roles.removeNonExistent.phpt @@ -1,11 +1,9 @@ -removeRole('nonexistent'); -}, Nette\InvalidStateException::class, "Role 'nonexistent' does not exist."); +Assert::exception( + fn() => $acl->removeRole('nonexistent'), + Nette\InvalidStateException::class, + "Role 'nonexistent' does not exist.", +); diff --git a/tests/Permission/rules.assertionQueried.phpt b/tests/Permission/rules.assertionQueried.phpt new file mode 100644 index 00000000..f8ce997f --- /dev/null +++ b/tests/Permission/rules.assertionQueried.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.DefaultAssert.phpt b/tests/Permission/rules.default.assert.phpt similarity index 91% rename from tests/Security/Permission.DefaultAssert.phpt rename to tests/Permission/rules.default.assert.phpt index 3b930c21..54bebcb5 100644 --- a/tests/Security/Permission.DefaultAssert.phpt +++ b/tests/Permission/rules.default.assert.phpt @@ -1,11 +1,9 @@ - $acl->isAllowed('nonexistent'), + Nette\InvalidStateException::class, + "Role '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.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt b/tests/Permission/rules.precedence.privilegeDeny.phpt similarity index 93% rename from tests/Security/Permission.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt rename to tests/Permission/rules.precedence.privilegeDeny.phpt index 06feca15..e42f7a0b 100644 --- a/tests/Security/Permission.RoleDefaultAllowRuleWithPrivilegeDenyRule.phpt +++ b/tests/Permission/rules.precedence.privilegeDeny.phpt @@ -1,12 +1,10 @@ - 'John']); - - Assert::same(12, $id->getId()); - Assert::same(12, $id->id); - Assert::same(['admin'], $id->getRoles()); - Assert::same(['admin'], $id->roles); - Assert::same(['name' => 'John'], $id->getData()); - Assert::same(['name' => 'John'], $id->data); - Assert::same('John', $id->name); -}); - - -test(function () { - $id = new Identity('12'); - Assert::same(12, $id->getId()); - - - $id = new Identity('12345678901234567890'); - Assert::same('12345678901234567890', $id->getId()); -}); diff --git a/tests/Security/MockUserStorage.php b/tests/Security/MockUserStorage.php deleted file mode 100644 index 07f0a1e6..00000000 --- a/tests/Security/MockUserStorage.php +++ /dev/null @@ -1,44 +0,0 @@ -auth = $state; - } - - - public function isAuthenticated(): bool - { - return $this->auth; - } - - - public function setIdentity(Nette\Security\IIdentity $identity = null) - { - $this->identity = $identity; - } - - - public function getIdentity(): ?Nette\Security\IIdentity - { - return $this->identity; - } - - - public function setExpiration(?string $time, int $flags = 0) - { - } - - - public function getLogoutReason(): ?int - { - } -} diff --git a/tests/Security/Passwords.hash().phpt b/tests/Security/Passwords.hash().phpt deleted file mode 100644 index 7bd529b0..00000000 --- a/tests/Security/Passwords.hash().phpt +++ /dev/null @@ -1,29 +0,0 @@ -hash('')) -); - -Assert::truthy( - preg_match('#^\$2y\$05\$.{53}\z#', (new Passwords(PASSWORD_BCRYPT, ['cost' => 5]))->hash('dg')) -); - -$hash = (new Passwords(PASSWORD_BCRYPT))->hash('dg'); -Assert::same($hash, crypt('dg', $hash)); - -Assert::exception(function () { - (new Passwords(PASSWORD_BCRYPT, ['cost' => 3]))->hash('dg'); -}, Nette\InvalidStateException::class, 'Computed hash is invalid. password_hash(): Invalid bcrypt cost parameter specified: 3'); diff --git a/tests/Security/Passwords.hash.phpt b/tests/Security/Passwords.hash.phpt new file mode 100644 index 00000000..dabfc6a2 --- /dev/null +++ b/tests/Security/Passwords.hash.phpt @@ -0,0 +1,34 @@ +hash('my-password')), +); + +Assert::truthy( + preg_match('#^\$2y\$05\$.{53}\z#', (new Passwords(PASSWORD_BCRYPT, ['cost' => 5]))->hash('dg')), +); + +$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( + 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 deleted file mode 100644 index a912352c..00000000 --- a/tests/Security/Passwords.needsRehash().phpt +++ /dev/null @@ -1,17 +0,0 @@ -needsRehash('$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); -Assert::false((new Passwords(PASSWORD_BCRYPT, ['cost' => 5]))->needsRehash('$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); diff --git a/tests/Security/Passwords.needsRehash.phpt b/tests/Security/Passwords.needsRehash.phpt new file mode 100644 index 00000000..9c2ae5dc --- /dev/null +++ b/tests/Security/Passwords.needsRehash.phpt @@ -0,0 +1,89 @@ + 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')); +}); diff --git a/tests/Security/Passwords.static.phpt b/tests/Security/Passwords.static.phpt deleted file mode 100644 index 4183e0c0..00000000 --- a/tests/Security/Passwords.static.phpt +++ /dev/null @@ -1,30 +0,0 @@ - 5])) // @ is not static -); - - -Assert::true(@Passwords::verify('dg', '$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); // @ is not static -Assert::false(@Passwords::verify('dgx', '$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); // @ is not static - - -Assert::true(@Passwords::needsRehash('$2y$05$123456789012345678901uTj3G.8OMqoqrOMca1z/iBLqLNaWe6DK')); // @ is not static diff --git a/tests/Security/Passwords.verify().phpt b/tests/Security/Passwords.verify.phpt similarity index 93% rename from tests/Security/Passwords.verify().phpt rename to tests/Security/Passwords.verify.phpt index 8b6d0d8c..e9eaf92b 100644 --- a/tests/Security/Passwords.verify().phpt +++ b/tests/Security/Passwords.verify.phpt @@ -1,11 +1,9 @@ -isAllowed('nonexistent'); -}, Nette\InvalidStateException::class, "Role 'nonexistent' does not exist."); - -Assert::exception(function () { - $acl = new Permission; - $acl->isAllowed(null, 'nonexistent'); -}, Nette\InvalidStateException::class, "Resource 'nonexistent' does not exist."); diff --git a/tests/Security/Permission.ResourceDuplicate.phpt b/tests/Security/Permission.ResourceDuplicate.phpt deleted file mode 100644 index 25788827..00000000 --- a/tests/Security/Permission.ResourceDuplicate.phpt +++ /dev/null @@ -1,20 +0,0 @@ -addResource('area'); - $acl->addResource('area'); -}, Nette\InvalidStateException::class, "Resource 'area' already exists in the list."); diff --git a/tests/Security/Permission.ResourceInheritsNonExistent.phpt b/tests/Security/Permission.ResourceInheritsNonExistent.phpt deleted file mode 100644 index 5503d6e6..00000000 --- a/tests/Security/Permission.ResourceInheritsNonExistent.phpt +++ /dev/null @@ -1,24 +0,0 @@ -addResource('area'); -Assert::exception(function () use ($acl) { - $acl->resourceInheritsFrom('nonexistent', 'area'); -}, Nette\InvalidStateException::class, "Resource 'nonexistent' does not exist."); - -Assert::exception(function () use ($acl) { - $acl->resourceInheritsFrom('area', 'nonexistent'); -}, Nette\InvalidStateException::class, "Resource 'nonexistent' does not exist."); diff --git a/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt b/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt deleted file mode 100644 index 8001493d..00000000 --- a/tests/Security/Permission.RoleRegistryInheritsNonExistent.phpt +++ /dev/null @@ -1,24 +0,0 @@ -addRole('guest'); -Assert::exception(function () use ($acl) { - $acl->roleInheritsFrom('nonexistent', 'guest'); -}, Nette\InvalidStateException::class, "Role 'nonexistent' does not exist."); - -Assert::exception(function () use ($acl) { - $acl->roleInheritsFrom('guest', 'nonexistent'); -}, Nette\InvalidStateException::class, "Role 'nonexistent' does not exist."); 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.phpt b/tests/Security/SimpleAuthenticator.phpt index 9a3c3764..ca012c2b 100644 --- a/tests/Security/SimpleAuthenticator.phpt +++ b/tests/Security/SimpleAuthenticator.phpt @@ -1,11 +1,9 @@ -authenticate(['john', 'password123!']); +$identity = $authenticator->authenticate('john', 'password123!'); Assert::type(Nette\Security\IIdentity::class, $identity); Assert::equal('john', $identity->getId()); -$identity = $authenticator->authenticate(['admin', 'admin']); +$identity = $authenticator->authenticate('admin', 'admin'); Assert::type(Nette\Security\IIdentity::class, $identity); Assert::equal('admin', $identity->getId()); -Assert::exception(function () use ($authenticator) { - $authenticator->authenticate(['admin', 'wrong password']); -}, Nette\Security\AuthenticationException::class, 'Invalid password.'); - -Assert::exception(function () use ($authenticator) { - $authenticator->authenticate(['nobody', 'password']); -}, Nette\Security\AuthenticationException::class, "User 'nobody' not found."); +Assert::exception( + fn() => $authenticator->authenticate('admin', 'wrong password'), + Nette\Security\AuthenticationException::class, + 'Invalid password.', +); + +Assert::exception( + fn() => $authenticator->authenticate('nobody', 'password'), + Nette\Security\AuthenticationException::class, + "User 'nobody' not found.", +); diff --git a/tests/Security/SimpleAuthenticator.Roles.phpt b/tests/Security/SimpleAuthenticator.roles.phpt similarity index 86% rename from tests/Security/SimpleAuthenticator.Roles.phpt rename to tests/Security/SimpleAuthenticator.roles.phpt index 4cc7ffd5..9f135190 100644 --- a/tests/Security/SimpleAuthenticator.Roles.phpt +++ b/tests/Security/SimpleAuthenticator.roles.phpt @@ -1,11 +1,9 @@ - $password) { - $identity = $authenticator->authenticate([$username, $password]); + $identity = $authenticator->authenticate($username, $password); Assert::equal($username, $identity->getId()); Assert::equal($expectedRoles[$username], $identity->getRoles()); } diff --git a/tests/Security/SimpleIdentity.phpt b/tests/Security/SimpleIdentity.phpt new file mode 100644 index 00000000..bba74f2f --- /dev/null +++ b/tests/Security/SimpleIdentity.phpt @@ -0,0 +1,47 @@ + '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()); +}); + + +test('reading an undeclared data key throws and does not pollute data', function () { + $id = new SimpleIdentity(12, 'admin', ['name' => 'John', 'note' => null]); + Assert::null($id->note); // existing null value is readable + + Assert::exception( + fn() => $id->undeclared, + Nette\MemberAccessException::class, + 'Cannot read an undeclared property Nette\Security\SimpleIdentity::$undeclared.', + ); + Assert::same(['name' => 'John', 'note' => null], $id->getData()); +}); diff --git a/tests/User/MockUserStorage.php b/tests/User/MockUserStorage.php new file mode 100644 index 00000000..0f154314 --- /dev/null +++ b/tests/User/MockUserStorage.php @@ -0,0 +1,33 @@ +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 + { + } +} diff --git a/tests/Security/User.authentication.phpt b/tests/User/authentication.phpt similarity index 59% rename from tests/Security/User.authentication.phpt rename to tests/User/authentication.phpt index 7bbb31b7..ff68f0ef 100644 --- a/tests/Security/User.authentication.phpt +++ b/tests/User/authentication.phpt @@ -1,13 +1,11 @@ -getId()); // authenticate -Assert::exception(function () use ($user) { - // login without handler - $user->login('jane', ''); -}, Nette\InvalidStateException::class, '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::class, '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::class, '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::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')); +$user->login(new SimpleIdentity('John Doe', 'admin')); Assert::same(2, $counter->login); Assert::true($user->isLoggedIn()); -Assert::equal(new Identity('John Doe', 'admin'), $user->getIdentity()); +Assert::equal(new SimpleIdentity('John Doe', 'admin'), $user->getIdentity()); // log out @@ -99,7 +99,7 @@ $user->logout(false); Assert::same(2, $counter->logout); Assert::false($user->isLoggedIn()); -Assert::equal(new Identity('John Doe', 'admin'), $user->getIdentity()); +Assert::equal(new SimpleIdentity('John Doe', 'admin'), $user->getIdentity()); // logging out and clearing identity... diff --git a/tests/Security/User.authorization.phpt b/tests/User/authorization.phpt similarity index 54% rename from tests/Security/User.authorization.phpt rename to tests/User/authorization.phpt index ec819d7b..bb1bc45a 100644 --- a/tests/Security/User.authorization.phpt +++ b/tests/User/authorization.phpt @@ -1,14 +1,12 @@ -isLoggedIn()); Assert::same(['guest'], $user->getRoles()); Assert::false($user->isInRole('admin')); +Assert::false($user->isInRole('tester')); Assert::true($user->isInRole('guest')); @@ -67,15 +72,18 @@ $user->setAuthenticator($handler); $user->login('john', 'xxx'); Assert::true($user->isLoggedIn()); -Assert::same(['admin'], $user->getRoles()); +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::class, '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); diff --git a/tests/User/emptyRoles.phpt b/tests/User/emptyRoles.phpt new file mode 100644 index 00000000..a43d4a64 --- /dev/null +++ b/tests/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/User/expiration.phpt b/tests/User/expiration.phpt new file mode 100644 index 00000000..fe09a10f --- /dev/null +++ b/tests/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 ExpiringStorage; + $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 ExpiringStorage; + $user = new User($storage); + + $user->setExpiration('10 minutes', clearIdentity: true); + Assert::same('10 minutes', $storage->expireTime); + Assert::true($storage->expireIdentity); +}); diff --git a/tests/User/guestIdentity.phpt b/tests/User/guestIdentity.phpt new file mode 100644 index 00000000..78dbadd5 --- /dev/null +++ b/tests/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/User/identityHandler.phpt b/tests/User/identityHandler.phpt new file mode 100644 index 00000000..dbd654d4 --- /dev/null +++ b/tests/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/User/namespaces.phpt b/tests/User/namespaces.phpt new file mode 100644 index 00000000..0f072868 --- /dev/null +++ b/tests/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/User/persistIdentity.phpt b/tests/User/persistIdentity.phpt new file mode 100644 index 00000000..4fdf3dcc --- /dev/null +++ b/tests/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/User/refreshStorage.phpt b/tests/User/refreshStorage.phpt new file mode 100644 index 00000000..5bfd4052 --- /dev/null +++ b/tests/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 3fdbcd98..a297aa39 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,6 +1,4 @@ -', $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 @@ +