diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1b0faf3
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+/tests export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/.scrutinizer.yml export-ignore
+/.travis.yml export-ignore
+/phpunit.xml.dist export-ignore
+/phpcs.xml.dist export-ignore
+/phpstan.neon export-ignore
+/README.md export-ignore
diff --git a/.gitignore b/.gitignore
index de4a392..1c9815d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/vendor
/composer.lock
+.phpunit.result.cache
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
new file mode 100644
index 0000000..985ff19
--- /dev/null
+++ b/.scrutinizer.yml
@@ -0,0 +1,24 @@
+#language: php
+checks:
+ php: true
+filter:
+ excluded_paths:
+ - tests
+build:
+ nodes:
+ analysis:
+ environment:
+ php: 7.4
+ postgresql: false
+ redis: false
+ mongodb: false
+ tests:
+ override:
+ - phpcs-run src
+ -
+ command: vendor/bin/phpstan analyze --error-format=checkstyle | sed '/^\s*$/d' > phpstan-checkstyle.xml
+ analysis:
+ file: phpstan-checkstyle.xml
+ format: 'general-checkstyle'
+ - php-scrutinizer-run
+
diff --git a/.travis.yml b/.travis.yml
index 41ee6d6..744caed 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,17 +1,41 @@
language: php
php:
- - 5.6
- - 7.0
- - 7.1
+ - 7.4snapshot
+ - nightly
+
+matrix:
+ allow_failures:
+ - php: nightly
+
+sudo: false
+
+cache:
+ directories:
+ - $HOME/.composer/cache/files
+
+branches:
+ only:
+ - master
+ - travis
+
+before_install:
+ - test "$TRAVIS_PHP_VERSION" != "nightly" || export COMPOSER_FLAGS="$COMPOSER_FLAGS --ignore-platform-reqs"
install:
- - composer install
+ - composer install --prefer-source $COMPOSER_FLAGS
- wget https://scrutinizer-ci.com/ocular.phar -O "$HOME/ocular.phar"
-
+
+before_script: |
+ if (php -m | grep -q -i xdebug); then
+ export PHPUNIT_FLAGS="--coverage-clover cache/logs/clover.xml"
+ else
+ export PHPUNIT_FLAGS="--no-coverage"
+ fi
+
script:
- - vendor/bin/phpunit --coverage-clover cache/logs/clover.xml
+ - vendor/bin/phpunit $PHPUNIT_FLAGS
-after_success:
- - php "$HOME/ocular.phar" code-coverage:upload --format=php-clover cache/logs/clover.xml
+after_script:
+ - test "$PHPUNIT_FLAGS" == "--no-coverage" || php "$HOME/ocular.phar" code-coverage:upload --format=php-clover cache/logs/clover.xml
diff --git a/README.md b/README.md
index b569af3..6685ef4 100644
--- a/README.md
+++ b/README.md
@@ -4,15 +4,19 @@ Jasny Auth
[![Build Status](https://travis-ci.org/jasny/auth.svg?branch=master)](https://travis-ci.org/jasny/auth)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/auth/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/auth/?branch=master)
[![Code Coverage](https://scrutinizer-ci.com/g/jasny/auth/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/auth/?branch=master)
-[![SensioLabsInsight](https://insight.sensiolabs.com/projects/2413e307-8b3b-4a7c-8202-730ed969bbd4/mini.png)](https://insight.sensiolabs.com/projects/2413e307-8b3b-4a7c-8202-730ed969bbd4)
[![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/auth.svg)](https://packagist.org/packages/jasny/auth)
[![Packagist License](https://img.shields.io/packagist/l/jasny/auth.svg)](https://packagist.org/packages/jasny/auth)
Authentication, authorization and access control for PHP.
-* [Installation](#installation)
-* [Setup](#setup)
-* [Usage](#usage)
+**Features**
+
+* Multiple [authorization strategies](#authorization-services), like groups (for acl) and levels.
+* Authorization [context](#context) (eg. "is the user an _admin_ of this _team_").
+* PSR-14 [events](#events) for login and logout.
+* PSR-15 [middleware](#access-control-middleware) for access control.
+* [Confirmation tokens](#confirmation) for signup confirmation and forgot-password.
+* Customizable to meet the requirements of your application.
---
@@ -23,403 +27,633 @@ Install using composer
composer require jasny/auth
-
-Setup
+Usage
---
-`Auth` is an abstract class. You need to extend it and implement the abstract methods `fetchUserById` and
-`fetchUserByUsername`.
+`Auth` is a composition class. It takes an _authz_, _storage_, and optionally a _confirmation_ service.
+
+```php
+use Jasny\Auth\Auth;
+use Jasny\Auth\Authz\Levels;
-You also need to specify how the current user is persisted across requests. If you want to use normal PHP sessions, you
-can simply use the `Auth\Sessions` trait.
+$levels = new Levels(['user' => 1, 'moderator' => 10, 'admin' => 100]);
+$auth = new Auth($levels, new AuthStorage());
+
+session_start();
+$auth->initialize();
+
+// Later...
+if (!$auth->is('admin')) {
+ http_response_code(403);
+ echo "Access denied";
+ exit();
+}
+```
+
+The `Auth` service isn't usable until it's initialized. This should be done after the session is started.
```php
-class Auth extends Jasny\Auth
-{
- use Jasny\Auth\Sessions;
+session_start();
+$auth->initialize();
+```
+
+### Storage
+
+The `Storage` service is not provided, you'll need to create a service that can fetch a user from the database.
+```php
+use Jasny\Auth;
+
+class AuthStorage implements Auth\StorageInterface
+{
/**
* Fetch a user by ID
- *
- * @param int $id
- * @return Jasny\Auth\User
*/
- public function fetchUserById($id)
+ public function fetchUserById($id): ?Auth\UserInterface
{
// Database action that fetches a User object
}
/**
* Fetch a user by username
- *
- * @param string $username
- * @return Jasny\Auth\User
*/
- public function fetchUserByUsername($username)
+ public function fetchUserByUsername(string $username): ?Auth\UserInterface
{
// Database action that fetches a User object
}
+
+ /**
+ * Fetch the context by ID.
+ */
+ public function fetchContext($id) : ?Auth\ContextInterface
+ {
+ // Database action that fetches a context (or return null)
+ }
}
```
-The fetch methods need to return a object that implements the `Jasny\Auth\User` interface.
+### User
-```php
-class User implements Jasny\Auth\User
-{
- /**
- * @var int
- */
- public $id;
+The fetch methods need to return a object that implements the `Jasny\Auth\UserInterface` interface.
- /**
- * @var string
- */
- public $username;
-
- /**
- * Hashed password
- * @var string
- */
- public $password;
+```php
+use Jasny\Auth;
- /**
- * @var boolean
- */
- public $active;
+class User implements Auth\UserInterface
+{
+ public int $id;
+ public string $username;
+ public int $accessLevel = 0;
+ protected string $hashedPassword;
- /**
- * Get the user ID
- *
- * @return int
- */
- public function getId()
+ public function getAuthId()
{
return $this->id;
}
/**
- * Get the usermame
- *
- * @return string
+ * {@interal This method isn't required by the interface}}.
*/
- public function getUsername()
+ public function changePassword(string $password): void
{
- return $this->username;
+ $this->hashedPassword = password_hash($password, PASSWORD_BCRYPT);
}
- /**
- * Get the hashed password
- *
- * @return string
- */
- public function getHashedPassword()
+ public function verifyPassword(string $password): bool
{
- return $this->password;
+ return password_verify($password, $this->hashedPassword);
}
-
- /**
- * Event called on login.
- *
- * @return boolean false cancels the login
- */
- public function onLogin()
+ public function getAuthChecksum(): string
{
- if (!$this->active) {
- return false;
- }
-
- // You might want to log the login
+ return hash('sha256', $this->username . $this->hashedPassword);
}
-
- /**
- * Event called on logout.
- */
- public function onLogout()
+
+ public function getAuthRole(Auth\ContextInterface $context = null): int
{
- // You might want to log the logout
+ return $this->accessLevel;
}
}
```
-### Authorization
+### Authorization services
+
+The `Authz` services are used to check permissions for a user. These services are immutable, applying authorization to
+the given user and context.
+
+#### Levels
+
+The `Authz\Levels` service implements authorization based on access levels. Each user get permissions for it's level and
+all levels below. Levels must be integers.
+
+```php
+use Jasny\Auth\Auth;
+use Jasny\Auth\Authz;
+
+$levels = new Authz\Levels([
+ 1 => 'user',
+ 10 => 'moderator',
+ 20 => 'admin',
+ 50 => 'superadmin'
+]);
+
+$auth = new Auth($levels, new AuthStorage());
+```
-By default the `Auth` class only does authentication. Authorization can be added by implementing the `Authz` interface.
+#### Groups
-Two traits are predefined to do Authorization: `Authz\ByLevel` and `Authz\ByGroup`.
+The `Authz\Groups` service implements authorization using access groups. An access group may supersede other groups.
-#### By level
+```php
+use Jasny\Auth\Auth;
+use Jasny\Auth\Authz;
+
+$groups = new Authz\Groups([
+ 'users' => [],
+ 'managers' => [],
+ 'employees' => ['user'],
+ 'developers' => ['employees'],
+ 'paralegals' => ['employees'],
+ 'lawyers' => ['paralegals'],
+ 'lead-developers' => ['developers', 'managers'],
+ 'firm-partners' => ['lawyers', 'managers']
+]);
+
+$auth = new Auth($groups, new AuthStorage());
+```
-The `Authz\ByLevel` traits implements authorization based on access levels. Each user get permissions for it's level and
-all levels below.
+When using authorization groups the user may return multiple roles, which will be combined.
```php
-class Auth extends Jasny\Auth implements Jasny\Authz
+use Jasny\Auth;
+
+class User implements Auth\UserInterface
{
- use Jasny\Authz\ByLevel;
+ public int $id;
+ public string $username;
+ public array $roles = [];
- protected function getAccessLevels()
+ protected string $hashedPassword;
+
+ // ...
+
+ public function getAuthRole(?Auth\ContextInterface $context = null): array
{
- return [
- 'user' => 1,
- 'moderator' => 10,
- 'admin' => 100,
- 'superadmin' => 500
- ];
+ return $this->roles;
}
}
```
-If you get the levels from a database, make sure to save them in a property for performance.
+_It's always possible to switch from levels to groups, but usually not visa-versa._
-```php
-class Auth extends Jasny\Auth implements Jasny\Authz
-{
- use Jasny\Authz\ByGroup;
+Authentication
+---
- protected $levels;
+`Auth` is a service with a mutable state. The login and logout methods change the current user.
- protected function getAccessLevels()
- {
- if (!isset($this->levels)) {
- $this->levels = [];
- $result = $this->db->query("SELECT name, level FROM access_levels");
+### Methods
+
+#### login
+
+ Auth::login(string $username, string $password)
+
+Login with username and password.
+
+Triggers a [login event](#events), which may be used to cancel the login.
+
+The method will throw a `LoginException` if login failed. The code will either be `LoginException::INVALID_CREDENTIALS`
+or `LoginException::CANCELLED` (if cancelled via the login event).
+
+#### loginAs
+
+ Auth::loginAs(UserInterface $user)
- while (($row = $result->fetchAssoc())) {
- $this->levels[$row['name']] = (int)$row['level'];
- }
+Set user without verification.
+
+Triggers a [login event](#events), which may be used to cancel the login. The method will throw a `LoginException` if
+the login is cancelled.
+
+#### logout
+
+ Auth::logout()
+
+Clear the current user and context.
+
+Triggers a [logout event](#events).
+
+#### user
+
+ Auth::user(): UserInterface|null
+
+Get the current user. Returns `null` if no user is logged in.
+
+### Events
+
+Calling `login`, `loginAs` and `logout` will trigger an event. To capture these event, register a
+[PSR-14](https://www.php-fig.org/psr/psr-14/) event dispatcher.
+
+```php
+use Jasny\Auth\Auth;
+use Jasny\Auth\Authz;
+use Jasny\Auth\Event;
+use Jasny\EventDispatcher\EventDispatcher;
+use Jasny\EventDispatcher\ListenerProvider;
+
+$accessLog = ...; // Some access log service
+
+$listener = (new ListenerProvider)
+ ->withListener(function(Event\Login $login): void {
+ if ($login->user()->isSuspended()) {
+ $login->cancel("Sorry, you're account is suspended'");
}
+ })
+ ->withListener(function(Event\Login $login): void {
+ // do something
+ })
+ ->withListener(function(Event\Logout $logout): void {
+ // do something
+ });
+
+$levels = new Authz\Levels(['user' => 1, 'moderator' => 10, 'admin' => 100]);
+
+$auth = (new Auth($levels, new AuthStorage()))
+ ->withEventDispatcher(new EventDispatcher($listener));
+```
- return $this->levels;
- }
-}
+### Recalc
+
+Recalculate the authz roles and store the current auth information in the session.
+
+`Auth::recalc()` typically doesn't have to be called explicitly. If the current user modifies his/her password (causing an auth
+checksum mismatch), this needs to be called to prevent the current user from being logged out.
+
+```php
+$auth->user()->changePassword($_GET['new_password']);
+$auth->recalc();
```
-For authorization the user object also needs to implement `Jasny\Authz\User`, adding the `getRole()` method. This method
-must return the access level of the user, either as string or as integer.
+If the role of the current user is changed, this also needs to be called to use the modified role for authorization.
```php
-/**
- * Get the access level of the user
- *
- * @return int
- */
-public function getRole()
-{
- return $this->access_level;
-}
+$auth->user()->setRole('admin');
+$auth->is('admin'); // still returns false
+
+$auth->recalc();
+$auth->is('admin'); // returns true
```
-#### By group
+Context
+---
-The `Auth\ByGroup` traits implements authorization using access groups. An access group may supersede other groups.
+By default authorization is global, aka application-wide. However it's possible to set an authz context like an
+organization, team, or board. Rather than checking if a user is an admin in the application, you'd verify is the user
+is an admin of the organization.
-You must implement the `getGroupStructure()` method which should return an array. The keys are the names of the
-groups. The value should be an array with groups the group supersedes.
+Any object that implements 'Jasny\Auth\ContextInterface' can be used as context. The `getAuthId()` method should
+return a value that can be used by the [`Storage`](#storage) implementation to fetch the context.
```php
-class Auth extends Jasny\Auth implements Jasny\Authz
+use Jasny\Auth;
+
+class Organization implements Auth\ContextInterface
{
- use Jasny\Authz\ByGroup;
+ public int $id;
- protected function getGroupStructure()
+ public function getAuthId()
{
- return [
- 'users' => [],
- 'managers' => [],
- 'employees' => ['user'],
- 'developers' => ['employees'],
- 'paralegals' => ['employees'],
- 'lawyers' => ['paralegals'],
- 'lead-developers' => ['developers', 'managers'],
- 'firm-partners' => ['lawyers', 'managers']
- ];
+ return $this->id;
}
}
```
-If you get the structure from a database, make sure to save them in a property for performance.
-
```php
-class Auth extends Jasny\Auth implements Jasny\Authz
+use Jasny\Auth;
+
+class User implements Auth\UserInterface
{
- use Jasny\Authz\ByGroup;
+ public int $id;
+ public string $username;
+ public array $roles = [];
+ public array $memberships;
- protected $groups;
+ protected string $hashedPassword;
- protected function getGroupStructure()
+ // ...
+
+ public function getAuthRole(Auth\ContextInterface $context = null): array
{
- if (!isset($this->groups)) {
- $this->groups = [];
- $result = $this->db->query("SELECT ...");
+ $membership = $context !== null ? $this->getMembership($context) : null;
- while (($row = $result->fetchAssoc())) {
- $this->groups[$row['group']] = explode(';', $row['supersedes']);
- }
- }
-
- return $this->groups;
+ return array_merge($this->roles, $membership->roles ?? []);
}
}
```
-For authorization the user object also needs to implement `Jasny\Authz\User`, adding the `getRole()` method. This method
-must return the role of the user or array of roles.
+### Methods
+
+#### setContext
+
+ Auth::setContext(ContextInterface $context)
+
+Set the current authorization context for the user.
+
+#### context
+
+ Auth::context(): ContextInterface|null
+
+Get the current context. Returns `null` if the global context is used.
+
+### setContext vs inContextOf
+
+In some applications the context will be determined on a slug in the URL (like `ltonetwork` in
+`https://github.com/ltonetwork/`). In that case `Context::getAuthId()` and `Storage::fetchContext()` should
+return `null`.
+
+You can either set the context for this request
```php
-/**
- * Get the access groups of the user
- *
- * @return string[]
- */
-public function getRoles()
-{
- return $this->roles;
+if (!$auth->inContextOf($organization)->is('admin')) {
+ return forbidden();
}
+
+$auth->context(); // returns null
+```
+
+, or use an `Authz` object for that context with `inContextOf()`.
+
+```php
+$auth->setContext($organization);
+
+if (!$auth->is('admin')) {
+ return forbidden();
+}
+
+$auth->context(); // returns $organization
```
-### Confirmation
+### Different type of contexts
-By using the `Auth\Confirmation` trait, you can generate and verify confirmation tokens. This is useful to require a
-use to confirm signup by e-mail or for a password reset functionality.
+In some cases an application has multiple types of authorization contexts. Take Trello for instance, it defines
+application-wide, organization and board privileges.
-You need to add a `getConfirmationSecret()` that returns a string that is unique and only known to your application.
-Make sure the confirmation secret is suffiently long, like 20 random characters. For added security, it's better to
- configure it through an environment variable rather than putting it in your code.
+In case the context is derived from the URL, both the `Organization` and `Board` class can return `null` for
+`getAuthId()`. If the context needs to be stored in the session, prepend the id with the type;
```php
-class Auth extends Jasny\Auth
+use Jasny\Auth;
+
+class Organization implements Auth\ContextInterface
{
- use Jasny\Auth\Confirmation;
+ public int $id;
- public function getConfirmationSecret()
- {
- return getenv('AUTH_CONFIRMATION_SECRET');
- }
+ public function getAuthId()
+ {
+ return "organization:{$this->id}";
+ }
}
```
-#### Security
+Authorization
+---
-The confirmation token exists of the user id and a checksum, which is obfuscated using [hashids](http://hashids.org/).
+The `is()` method checks if the current user has the given role, or has a role that supersedes the given role.
-A casual user will be unable to get the userid from the hash, but hashids is _not a true encryption algorithm_ and with
-enough tokens a hacker might be able to determine the salt and extract the user id and checksum from tokens. _Note that
-knowing the salt doesn't mean you know the configured secret._
+```php
+if (!$auth->is('moderator')) {
+ http_response_code(403);
+ echo "You're not allowed to see this page";
+ exit();
+}
-The checksum is the first 16 bytes of the sha256 hash of user id + secret. For better security you might add want to
-use more than 12 characters. This does result in a larger string for the token.
+$auth->user()->getAuthRole(); // Returns 'admin' which supersedes 'moderator'.
+```
-```php
-class Auth extends Jasny\Auth
-{
- ...
+### Methods
+
+#### is
+
+ Auth::is(string $role): bool
+
+Check if a user has a specific role or superseding role
+
+#### getAvailableRoles
+
+ Auth::getAvailableRoles(): string[]
+
+Get all defined authorization roles (levels or groups).
+
+#### authz
+
+ Auth::authz(): Authz
+
+Returns a copy of the `Authz` service with the current user and context.
+
+#### forUser
- protected function getConfirmationChecksum($id, $len = 32)
- {
- return parent::getConfirmationChecksum($id, $len);
- }
+ Auth::authz(User $user): Authz
- ...
+Returns a copy of the `Authz` service with the given user, in the current context.
+
+#### inContextOf
+
+ Auth::inContextOf(Context $context): Authz
+
+Returns a copy of the `Authz` service with the current user, in the given context.
+
+
+### Immutable state
+
+The `Auth` service has a mutable state. This means that calling a method a second time with the same arguments can
+give a different result, if the state has changed (by logging in or out, or changing the context).
+
+```php
+if (!$auth->is('admin')) { // We only want to send the info to an admin.
+ return forbidden();
}
+
+doSomething(); // If this function changed the current user,
+sendInfoToAdmin($auth->user()); // the info could be send to a non-admin.
```
+Use `authz()` to prevent such issues.
-Usage
----
+```php
+$authz = $auth->authz();
-### Authentication
+if (!$authz->is('admin')) { // We only want to send the info to an admin.
+ return forbidden();
+}
-Verify username and password
+doSomething(); // If this function changed the current user,
+sendInfoToAdmin($authz->user()); // the info will still be send to the admin.
+```
- boolean verify(User $user, string $password)
+#### Authorize other user
-Login with username and password
+`Authz` services have an immutable state. Calling `forUser()` and `inContextOf()` will return a modified copy of the
+authorization service.
- User|null login(string $username, string $password);
+```php
+$authz = $auth->authz();
-Set user without verification
+$arnold = fetchUserByUsername('arnold');
+$authzArnold = $authz->forUser($arnold);
- User|null setUser(User $user)
+// $authz and $authzArnold are *not* the same object
+$authzArnold->is('admin'); // returns true
+$authz->is('admin'); // returns false, as no user is set
-_If `$user->onLogin()` returns `false`, the user isn't set and the function returns `null`._
+$jasny = fetchOrganizationByName("Jasny");
+$authzArnoldAtJasny->inContextOf($jasny);
+$authzArnoldAtJasny->is('owner'); // returns true;
+```
-Logout
+`Auth::forUser()` and `Auth::inContextOf()` give a copy of the underlying authorization service. The following
+statements are the equivalent
- void logout()
+```php
+$auth->forUser();
+$auth->authz()->forUser();
-Get current user
+$auth->inContextOf()
+$auth->authz()->inContextOf()
+```
- User|null user()
+Use `is()` to authorize the given user. The `user()` and `context()` methods are available to get the underlying user
+and context.
+```php
+$authzArnoldAtJasny = $auth->forUser($arnold)->inContextOf($jasny);
-### Authorization
+// $auth isn't modified
-Check if a user has a specific role or superseding role
+$authzArnoldAtJasny->is('owner'); // return true
+$authzArnoldAtJasny->user(); // returns the $arnold user
+$authzArnoldAtJasny->context(); // returns the $jasny organization
+```
+
+#### Recalc
- boolean is(string $role)
+The roles of the user are calculated and stored, so subsequent calls will always give the same result, even if the
+underlying user object is modified.
```php
-if (!$auth->is('admin')) {
- http_response_code(403);
- echo "You're not allowed to see this page";
- exit();
-}
+$authz->is('admin'); // returns true
+$authz->user()->setRole('user');
+
+$authz->is('admin'); // still returns true
+
+$updatedAuthz = $authz->recalc();
+$updatedAuthz->is('admin'); // returns false
```
-### Access control (middleware)
+Access control (middleware)
+---
+
+You can apply access control manually using the `is()` method. Alteratively, if you're using a PSR-7 compatible router,
+you can use middleware. `AuthMiddleware` implements [PSR-15 `MiddlewareInterface`](https://www.php-fig.org/psr/psr-15/).
-Check if a user has a specific role or superseding role
+The constructor takes a callback, which should get the required authorization role / level from the request.
- Jasny\Authz\Middleware asMiddleware(callback $getRequiredRole)
+The callback may return `null` to indicate that anybody can visit the page. Returning `true` means a the request will
+only be handled if a user is logged in, and `false` means that the user may not be logged in.
-You can apply access control manually using the `is()` method. Alteratively, if you're using a PSR-7 compatible router
-with middleware support (like [Jasny Router](https://github.com/jasny/router)]).
+```php
+use Jasny\Auth\AuthMiddleware;
+use Psr\Http\Message\ServerRequestInterface ;
+
+$middleware = new AuthMiddleware($auth, function (ServerRequestInterface $request) {
+ if (strpos($request->getUri()->getPath(), '/account/') === 0) {
+ return true; // Pages under `/account/` are only available if logged in
+ }
+
+ if ($request->getUri()->getPath() === '/signup') {
+ return false; // Don't signup if you're already logged in
+ }
+
+ return null;
+});
-The `$getRequiredRole` callback should return a boolean, string or array of string.
+$router->add($middleware);
+```
-Returning true means a the request will only be handled if a user is logged in.
+If the callback returns a string, the middleware will check if the user is authorized for that role.
```php
$auth = new Auth(); // Implements the Jasny\Authz interface
$router->add($auth->asMiddleware(function(ServerRequest $request) {
- return strpos($request->getUri()->getPath(), '/account/') === 0; // `/account/` is only available if logged in
+ return $request->getAttribute('route.auth');
}));
```
-If the `Auth` class implements authorization (`Authz`) and the callback returns a string, the middleware will check if
-the user is authorized for that role. If an array of string is returned, the user should be authorized for at least one
-of the roles.
+If an array of strings is returned, the user should be authorized for at least one of the roles. So returning
+`['admin', 'provider']` means the user needs to be an admin OR provider.
+
+#### Initialization
+
+`AuthMiddleware` will automatically initialize `Auth` if required.
+
+Confirmation
+---
+
+The `Auth` class takes as confirmation service that can be use to create and verify confirmation tokens. This is useful
+to require a user to confirm signup by e-mail or for a password reset functionality.
+
+### No confirmation
+
+By default the `Auth` service has a stub object that can't create confirmation tokens. Using `$auth->confirm()`, without
+passing a confirmation when creating `Auth`, will throw an exception.
+
+### Hashids
+
+The `HashidsConfirmation` service creates tokens that includes the user id, expire date, and a checksum using the
+[Hashids](https://hashids.org/php/) library.
+
+ composer require hashids/hashids
+
+#### Setup
```php
-$auth = new Auth(); // Implements the Jasny\Authz interface
+use Jasny\Auth\Auth;
+use Jasny\Auth\Authz;
+use Jasny\Auth\Confirmation\HashidsConfirmation;
-$router->add($auth->asMiddleware(function(ServerRequest $request) {
- $route = $request->getAttribute('route');
- return isset($route->auth) ? $route->auth : null;
-}));
+$confirmation = new HashidsConfirmation(getenv('AUTH_CONFIRMATION_SECRET'));
+
+$levels = new Authz\Levels(['user' => 1, 'admin' => 20]);
+$auth = new Auth($levels, new AuthStorage(), $confirmation);
```
-### Confirmation
+#### Security
+
+**The token doesn't depend on hashids for security**, since hashids is _not a true encryption algorithm_. While the user
+id and expire date are obfuscated for a casual user, a hacker might be able to extract this information.
+
+The token contains a SHA-256 checksum. This checksum includes a confirmation secret. To keep others from generating
+tokens, the a strong secret must be used. Make sure the confirmation secret is sufficiently long, like 32 random
+characters. A short secret might be guessed through brute forcing.
+
+It's recommended to configure the secret through an environment variable and not put it in your code.
+
+### Examples
#### Signup confirmation
Get a verification token. Use it in an url and set that url in an e-mail to the user.
```php
-// Create a new $user
+$user = new User();
+// Set the user info
-$auth = new Auth();
-$confirmationToken = $auth->getConfirmationToken($user, 'signup');
+$expire = new \DateTime('+30days');
+$token = $auth->confirm('signup')->getToken($user, $expire);
-$host = $_SERVER['HTTP_HOST'];
-$url = "http://$host/confirm.php?token=$confirmationToken";
+$url = "http://{$_SERVER['HTTP_HOST']}/confirm.php?token=$token";
mail(
- $user->getEmail(),
+ $user->email,
"Welcome to our site",
"Please confirm your account by visiting $url"
);
@@ -428,12 +662,11 @@ mail(
Use the confirmation token to fetch and verify the user
```php
-// --- confirm.php
+use Jasny\Auth\Confirmation\InvalidTokenException;
-$auth = new Auth();
-$user = $auth->fetchUserForConfirmation($_GET['token'], 'signup');
-
-if (!$user) {
+try {
+ $user = $auth->confirm('signup')->from($_GET['token']);
+} catch (InvalidTokenException $exception) {
http_response_code(400);
echo "The token is not valid";
exit();
@@ -447,20 +680,16 @@ if (!$user) {
Get a verification token. Use it in an url and set that url in an e-mail to the user.
-Setting the 3th argument to `true` will use the hashed password of the user in the checksum. This means that the token
-will stop working once the password is changed.
-
```php
-// Fetch $user by e-mail
+$user = ...; // Get the user from the DB by email
-$auth = new MyAuth();
-$confirmationToken = $auth->getConfirmationToken($user, 'reset-password', true);
+$expire = new \DateTime('+48hours');
+$token = $auth->confirm('reset-password')->getToken($user, $expire);
-$host = $_SERVER['HTTP_HOST'];
-$url = "http://$host/reset.php?token=$confirmationToken";
+$url = "http://{$_SERVER['HTTP_HOST']}/reset.php?token=$token";
mail(
- $user->getEmail(),
+ $user->email,
"Password reset request",
"You may reset your password by visiting $url"
);
@@ -469,16 +698,120 @@ mail(
Use the confirmation token to fetch and verify resetting the password
```php
-$auth = new MyAuth();
-$user = $auth->fetchUserForConfirmation($_GET['token'], 'reset-password', true);
+use Jasny\Auth\Confirmation\InvalidTokenException;
-if (!$user) {
+try {
+ $user = $auth->confirm('reset-password')->from($_GET['token']);
+} catch (InvalidTokenException $exception) {
http_response_code(400);
echo "The token is not valid";
exit();
}
+$expire = new \DateTime('+1hour');
+$postToken = $auth->confirm('change-password')->getToken($user, $expire);
+
// Show form to set a password
// ...
```
+Use the new 'change-password' token to verify changing the password
+
+```php
+use Jasny\Auth\Confirmation\InvalidTokenException;
+
+try {
+ $user = $auth->confirm('change-password')->from($_POST['token']);
+} catch (InvalidTokenException $exception) {
+ http_response_code(400);
+ echo "The token is not valid";
+ exit();
+}
+
+$user->changePassword($_POST['password']);
+
+// Save the user to the DB
+```
+
+### Custom confirmation service
+
+Hashids tokens contain all the relevant information and a checksum, which can make the quite long. An alternative is
+generating a random value and storing it to the DB.
+
+It's possible to create a custom confirmation service by implementing the `ConfirmationInterface`. The service should
+be immutable.
+
+```php
+use Jasny\Auth\Confirmation\ConfirmationInterface;
+use Jasny\Auth\Confirmation\InvalidTokenException;
+use Jasny\Auth\StorageInterface;
+use Jasny\Auth\UserInterface;
+
+class MyCustomConfirmation implements ConfirmationInterface
+{
+ protected Storage $storage;
+ protected string $subject;
+
+ protected function storeToken(string $token, string $uid, string $authChecksum, \DateTimeInterface $expire): void
+ {
+ // Store token with user id, auth checksum, subject and expire date to DB
+ }
+
+ protected function fetchTokenInfo(string $token): ?array
+ {
+ // Query DB and return uid, expire date and subject for given token
+ }
+
+
+ public function withStorage(StorageInterface $storage)
+ {
+ $clone = clone $this;
+ $clone->storage = $storage;
+
+ return $clone;
+ }
+
+ public function withSubject(string $subject)
+ {
+ $clone = clone $this;
+ $clone->subject = $subject;
+
+ return $clone;
+ }
+
+ public function getToken(UserInterface $user, \DateTimeInterface $expire): string
+ {
+ $token = base_convert(bin2hex(random_bytes(32)), 16, 36);
+ $this->storeToken($token, $user->getAuthId(), $user->getAuthChecksum(), $expire);
+
+ return $token;
+ }
+
+ public function from(string $token): UserInterface
+ {
+ $info = $this->fetchTokenInfo($token);
+
+ if ($info === null) {
+ throw new InvalidTokenException("Invalid token");
+ }
+
+ ['uid' => $uid, 'authChecksum' => $authChecksum, 'expire' => $expire, 'subject' => $subject] = $info;
+
+ if ($expire < new \DateTime()) {
+ throw new InvalidTokenException("Token expired");
+ }
+
+ if ($subject !== $this->subject) {
+ throw new InvalidTokenException("Invalid token");
+ }
+
+ $user = $this->storage->fetchUserById($uid);
+
+ if ($user === null || $user->getAuthChecksum() !== $authChecksum) {
+ throw new InvalidTokenException("Invalid token");
+ }
+
+ return $user;
+ }
+}
+```
diff --git a/composer.json b/composer.json
index 74737c5..5e9c1ae 100644
--- a/composer.json
+++ b/composer.json
@@ -16,22 +16,45 @@
"source": "https://github.com/jasny/auth"
},
"require": {
- "php": ">=5.6.0",
- "psr/http-message": "^1.0"
+ "php": ">=7.4.0",
+ "ext-ctype": "*",
+ "psr/http-message": "^1.0",
+ "improved/iterable": "^0.1.4",
+ "psr/http-server-middleware": "^1.0",
+ "jasny/immutable": "^2.0",
+ "psr/event-dispatcher": "^1.0",
+ "nesbot/carbon": "^2.27",
+ "psr/http-factory": "^1.0"
+ },
+ "require-dev": {
+ "jasny/php-code-quality": "~2.6.0",
+ "hashids/hashids": "~2.0.0"
},
"autoload": {
"psr-4": {
- "Jasny\\": "src/"
+ "Jasny\\Auth\\": "src/"
}
},
"autoload-dev": {
- "classmap": ["tests/support"]
- },
- "require-dev": {
- "jasny/php-code-quality": "^2.1.2",
- "hashids/hashids": "~2.0.0"
+ "psr-4": {
+ "Jasny\\Auth\\Tests\\": "tests/"
+ }
},
"suggest": {
"hashids/hashids": "For generating confirmation tokens"
- }
+ },
+ "scripts": {
+ "test": [
+ "phpstan analyse",
+ "phpunit --testdox --colors=always",
+ "phpcs -p src"
+ ]
+ },
+ "config": {
+ "preferred-install": "dist",
+ "sort-packages": true,
+ "optimize-autoloader": true
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..5897613
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,12 @@
+
+
+ The Jasny coding standard.
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..02a049e
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,11 @@
+parameters:
+ level: 7
+ paths:
+ - src
+ reportUnmatchedIgnoredErrors: false
+ ignoreErrors:
+ - "/^Variable property access/"
+ - "/^Parameter #2 \\$value of method .+::withProperty\\(\\) expects string, .+ given/"
+ - "/^Constructor of class .+ has an unused parameter \\$_+/"
+includes:
+ - vendor/phpstan/phpstan-strict-rules/rules.neon
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 1861d7c..25d9f45 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -8,7 +8,7 @@
convertWarningsToExceptions="true"
>
-
+
tests/
@@ -18,7 +18,7 @@
-
+
diff --git a/src/Auth.php b/src/Auth.php
index 69b0952..c4f7fad 100644
--- a/src/Auth.php
+++ b/src/Auth.php
@@ -1,181 +1,335 @@
- * class Auth extends Jasny\Auth
- * {
- * use Jasny\Auth\ByLevel;
- * use Jasny\Auth\Sessions;
- *
- * protected $levels = [
- * 'user' => 1,
- * 'admin' => 10
- * ];
- *
- * public function fetchUserById($id)
- * {
- * ...
- * }
- *
- * public function fetchUserByUsername($username)
- * {
- * ...
- * }
- * }
- *
+ * Authentication and authorization.
*/
-abstract class Auth
+class Auth implements Authz
{
+ use Immutable\With;
+
/**
- * Current authenticated user
- * @var User|false
+ * Stateful authz service.
+ * A new copy will be set if user is logged in or out, or if context changes.
*/
- protected $user;
-
-
+ protected Authz $authz;
+
+ protected Session $session;
+ protected Storage $storage;
+ protected Confirmation $confirmation;
+ protected EventDispatcher $dispatcher;
+
+ /** The service can't be used before it's initialized */
+ protected bool $initialized = false;
+
/**
- * Persist the current user id across requests
- *
- * @return void
+ * Auth constructor.
*/
- abstract protected function persistCurrentUser();
-
+ public function __construct(Authz $authz, Storage $storage, ?Confirmation $confirmation = null)
+ {
+ $this->authz = $authz;
+ $this->storage = $storage;
+ $this->confirmation = $confirmation ?? new NoConfirmation();
+
+ // Set default services
+ $this->session = new PhpSession();
+ $this->dispatcher = self::dummyDispatcher();
+ }
+
/**
- * Get current authenticated user id
- *
- * @return mixed
+ * Get a copy with a different session manager.
*/
- abstract protected function getCurrentUserId();
-
+ public function withSession(Session $session): self
+ {
+ return $this->withProperty('session', $session);
+ }
+
/**
- * Fetch a user by ID
- *
- * @param int|string $id
- * @return User|null
+ * Get a copy with an event dispatcher.
*/
- abstract public function fetchUserById($id);
+ public function withEventDispatcher(EventDispatcher $dispatcher): self
+ {
+ return $this->withProperty('dispatcher', $dispatcher);
+ }
+
/**
- * Fetch a user by username
- *
- * @param string $username
- * @return User|null
+ * Initialize the service using session information.
*/
- abstract public function fetchUserByUsername($username);
-
-
+ public function initialize(): void
+ {
+ if ($this->initialized) {
+ throw new \LogicException("Auth service is already initialized");
+ }
+
+ ['uid' => $uid, 'context' => $cid, 'checksum' => $checksum] = $this->session->getInfo();
+
+ $user = $uid !== null ? $this->storage->fetchUserById($uid) : null;
+ $context = $cid !== null ? $this->storage->fetchContext($cid) : null;
+
+ if ($user !== null && $user->getAuthChecksum() !== $checksum) {
+ $user = null;
+ $context = null;
+ }
+
+ $this->authz = $this->authz->forUser($user)->inContextOf($context);
+ $this->initialized = true;
+ }
+
/**
- * Get current authenticated user
- *
- * @return User|null
+ * Is the service is initialized?
*/
- public function user()
+ public function isInitialized(): bool
{
- if (!isset($this->user)) {
- $uid = $this->getCurrentUserId();
- $this->user = $uid ? ($this->fetchUserById($uid) ?: false) : false;
+ return $this->initialized;
+ }
+
+ /**
+ * Throw an exception if the service hasn't been initialized yet.
+ *
+ * @throws \LogicException
+ */
+ protected function assertInitialized(): void
+ {
+ if (!$this->initialized) {
+ throw new \LogicException("Auth needs to be initialized before use");
}
-
- return $this->user ?: null;
}
-
+
+
+ /**
+ * Get all available authorization roles (for the current context).
+ *
+ * @return string[]
+ */
+ final public function getAvailableRoles(): array
+ {
+ return $this->authz->getAvailableRoles();
+ }
+
+ /**
+ * Check if the current user is logged in and has specified role.
+ *
+ *
+ * if (!$auth->is('manager')) {
+ * http_response_code(403); // Forbidden
+ * echo "You are not allowed to view this page";
+ * exit();
+ * }
+ *
+ */
+ final public function is(string $role): bool
+ {
+ $this->assertInitialized();
+
+ return $this->authz->is($role);
+ }
+
/**
- * Set the current user
- *
- * @param User $user
+ * Get current authenticated user.
+ *
* @return User|null
*/
- public function setUser(User $user)
+ final public function user(): ?User
+ {
+ $this->assertInitialized();
+
+ return $this->authz->user();
+ }
+
+ /**
+ * Get the current context.
+ */
+ final public function context(): ?Context
+ {
+ $this->assertInitialized();
+
+ return $this->authz->context();
+ }
+
+
+ /**
+ * Set the current user.
+ *
+ * @throws LoginException
+ */
+ public function loginAs(User $user): void
{
- if ($user->onLogin() === false) {
- return null;
+ $this->assertInitialized();
+
+ if ($this->authz->user() !== null) {
+ throw new \LogicException("Already logged in");
}
-
- $this->user = $user;
- $this->persistCurrentUser();
-
- return $this->user;
+
+ $this->loginUser($user);
}
-
-
+
/**
- * Hash a password
- *
- * @param string $password
- * @return string
+ * Login with username and password.
+ *
+ * @throws LoginException
*/
- public function hashPassword($password)
+ public function login(string $username, string $password): void
{
- if (!is_string($password) || $password === '') {
- throw new \InvalidArgumentException("Password should be a (non-empty) string");
+ $this->assertInitialized();
+
+ if ($this->authz->user() !== null) {
+ throw new \LogicException("Already logged in");
+ }
+
+ $user = $this->storage->fetchUserByUsername($username);
+
+ if ($user === null || !$user->verifyPassword($password)) {
+ throw new LoginException('Invalid credentials', LoginException::INVALID_CREDENTIALS);
}
-
- return password_hash($password, PASSWORD_BCRYPT);
+
+ $this->loginUser($user);
}
-
+
/**
- * Fetch user and verify credentials.
- *
- * @param User|null $user
- * @param string $password
- * @return boolean
+ * Set the current user and dispatch login event.
+ *
+ * @throws LoginException
*/
- public function verifyCredentials($user, $password)
+ private function loginUser(User $user): void
{
- return isset($user) && password_verify($password, $user->getHashedPassword());
+ $event = new Event\Login($this, $user);
+ $this->dispatcher->dispatch($event);
+
+ if ($event->isCancelled()) {
+ throw new LoginException($event->getCancellationReason(), LoginException::CANCELLED);
+ }
+
+ $this->authz = $this->authz->forUser($user);
+
+ $this->updateSession();
}
-
+
/**
- * Login with username and password
- *
- * @param string $username
- * @param string $password
- * @return User|null
+ * Logout current user.
*/
- public function login($username, $password)
+ public function logout(): void
{
- $user = $this->fetchUserByUsername($username);
+ $this->assertInitialized();
+
+ $user = $this->authz->user();
- if (!isset($user) || !$this->verifyCredentials($user, $password)) {
- return null;
+ if ($user === null) {
+ return; // already logged out
}
-
- return $this->setUser($user);
+
+ $this->authz = $this->authz->forUser(null)->inContextOf(null);
+ $this->updateSession();
+
+ $this->dispatcher->dispatch(new Event\Logout($this, $user));
+ }
+
+ /**
+ * Set the current context.
+ */
+ public function setContext(?Context $context): void
+ {
+ $this->assertInitialized();
+
+ $this->authz = $this->authz->inContextOf($context);
+ $this->updateSession();
+ }
+
+ /**
+ * Recalculate authz roles for current user and context.
+ * Store the current auth information in the session.
+ *
+ * @return $this
+ */
+ public function recalc(): self
+ {
+ $this->authz = $this->authz->recalc();
+ $this->updateSession();
+
+ return $this;
}
-
+
/**
- * Logout
+ * Store the current auth information in the session.
*/
- public function logout()
+ protected function updateSession(): void
{
- $user = $this->user();
-
- if (!$user) {
+ $user = $this->authz->user();
+ $context = $this->authz->context();
+
+ if ($user === null) {
+ $this->session->clear();
return;
}
-
- $user->onLogout();
-
- $this->user = false;
- $this->persistCurrentUser();
+
+ $uid = $user->getAuthId();
+ $cid = $context !== null ? $context->getAuthId() : null;
+ $checksum = $user->getAuthChecksum();
+
+ $this->session->persist($uid, $cid, $checksum);
+ }
+
+
+ /**
+ * Return read-only service for authorization of the current user and context.
+ */
+ public function authz(): Authz
+ {
+ return $this->authz;
}
-
-
+
/**
- * Create auth middleware interface for access control.
- *
- * @param callable $getRequiredRole
- * @return Middleware
+ * Return read-only service for authorization of the specified user.
+ */
+ public function forUser(?User $user): Authz
+ {
+ return $this->authz->forUser($user);
+ }
+
+ /**
+ * Get an authz service for the given context.
+ */
+ public function inContextOf(?Context $context): Authz
+ {
+ return $this->authz->inContextOf($context);
+ }
+
+
+ /**
+ * Get service to create or validate confirmation token.
+ */
+ public function confirm(string $subject): Confirmation
+ {
+ return $this->confirmation->withStorage($this->storage)->withSubject($subject);
+ }
+
+
+ /**
+ * Create an event dispatcher as null object.
+ * @codeCoverageIgnore
*/
- public function asMiddleware($getRequiredRole)
+ private static function dummyDispatcher(): EventDispatcher
{
- return new Middleware($this, $getRequiredRole);
+ return new class () implements EventDispatcher {
+ /** @inheritDoc */
+ public function dispatch(object $event): object
+ {
+ return $event;
+ }
+ };
}
}
diff --git a/src/Auth/Confirmation.php b/src/Auth/Confirmation.php
deleted file mode 100644
index 3a4e631..0000000
--- a/src/Auth/Confirmation.php
+++ /dev/null
@@ -1,141 +0,0 @@
-
- * class Auth extends Jasny\Auth
- * {
- * use Jasny\Auth\Confirmation;
- *
- * public function getConfirmationSecret()
- * {
- * return "f)lk3sd^92qlj$%f8321*(&lk";
- * }
- *
- * ...
- * }
- *
- */
-trait Confirmation
-{
- /**
- * Fetch a user by ID
- *
- * @param int|string $id
- * @return User|null
- */
- abstract public function fetchUserById($id);
-
- /**
- * Get secret for the confirmation hash
- *
- * @return string
- */
- abstract protected function getConfirmationSecret();
-
-
- /**
- * Create a heashids interface
- *
- * @param string $subject
- * @return Hashids
- */
- protected function createHashids($subject)
- {
- if (!class_exists(Hashids::class)) {
- // @codeCoverageIgnoreStart
- throw new \Exception("Unable to generate a confirmation hash: Hashids library is not installed");
- // @codeCoverageIgnoreEnd
- }
-
- $salt = hash('sha256', $this->getConfirmationSecret() . $subject);
-
- return new Hashids($salt);
- }
-
- /**
- * Generate a confirm checksum based on a user id and secret.
- *
- * For more entropy overwrite this method:
- *
- * protected function getConfirmationChecksum($id, $len = 32)
- * {
- * return parent::getConfirmationChecksum($id, $len);
- * }
- *
- *
- * @param string $id
- * @param int $len The number of characters of the hash (max 64)
- * @return int
- */
- protected function getConfirmationChecksum($id, $len = 16)
- {
- $hash = hash('sha256', $id . $this->getConfirmationSecret());
- return substr($hash, 0, $len);
- }
-
- /**
- * Generate a confirmation token
- *
- * @param User $user
- * @param string $subject What needs to be confirmed?
- * @param boolean $usePassword Use password hash in checksum
- * @return string
- */
- public function getConfirmationToken(User $user, $subject, $usePassword = false)
- {
- $hashids = $this->createHashids($subject);
-
- $id = $user->getId();
- $pwd = $usePassword ? $user->getHashedPassword() : '';
-
- $confirm = $this->getConfirmationChecksum($id . $pwd);
-
- return $hashids->encodeHex($confirm . $id);
- }
-
- /**
- * Get user by confirmation hash
- *
- * @param string $token Confirmation token
- * @param string $subject What needs to be confirmed?
- * @param boolean $usePassword Use password hash in checksum
- * @return User|null
- */
- public function fetchUserForConfirmation($token, $subject, $usePassword = false)
- {
- $hashids = $this->createHashids($subject);
-
- $idAndConfirm = $hashids->decodeHex($token);
-
- if (empty($idAndConfirm)) {
- return null;
- }
-
- $len = strlen($this->getConfirmationChecksum(''));
- $id = substr($idAndConfirm, $len);
- $confirm = substr($idAndConfirm, 0, $len);
-
- $user = $this->fetchUserById($id);
-
- if (!isset($user)) {
- return null;
- }
-
- $pwd = $usePassword ? $user->getHashedPassword() : '';
-
- if ($confirm !== $this->getConfirmationChecksum($id . $pwd)) {
- return null;
- }
-
- return $user;
- }
-}
diff --git a/src/Auth/Middleware.php b/src/Auth/Middleware.php
deleted file mode 100644
index 3b65adc..0000000
--- a/src/Auth/Middleware.php
+++ /dev/null
@@ -1,109 +0,0 @@
-auth = $auth;
-
- if (!is_callable($getRequiredRole)) {
- throw new \InvalidArgumentException("'getRequiredRole' should be callable");
- }
-
- $this->getRequiredRole = $getRequiredRole;
- }
-
- /**
- * Check if the current user has one of the roles
- *
- * @param array|string|boolean $requiredRole
- * @return
- */
- protected function hasRole($requiredRole)
- {
- if (is_bool($requiredRole)) {
- return $this->auth->user() !== null;
- }
-
- $ret = false;
-
- if ($this->auth instanceof Authz) {
- $roles = (array)$requiredRole;
-
- foreach ($roles as $role) {
- $ret = $ret || $this->auth->is($role);
- }
- }
-
- return $ret;
- }
-
- /**
- * Respond with forbidden (or unauthorized)
- *
- * @param ServerRequestInterface $request
- * @param ResponseInterface $response
- * @return ResponseInterface
- */
- protected function forbidden(ServerRequestInterface $request, ResponseInterface $response)
- {
- $unauthorized = $this->auth->user() === null;
-
- $forbiddenResponse = $response
- ->withProtocolVersion($request->getProtocolVersion())
- ->withStatus($unauthorized ? 401 : 403);
- $forbiddenResponse->getBody()->write('Access denied');
-
- return $forbiddenResponse;
- }
-
- /**
- * Run middleware action
- *
- * @param ServerRequestInterface $request
- * @param ResponseInterface $response
- * @param callable $next
- * @return ResponseInterface
- */
- public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next)
- {
- if (!is_callable($next)) {
- throw new \InvalidArgumentException("'next' should be callable");
- }
-
- $requiredRole = call_user_func($this->getRequiredRole, $request);
-
- if (!empty($requiredRole) && !$this->hasRole($requiredRole)) {
- return $this->forbidden($request, $response);
- }
-
- return $next($request, $response);
- }
-}
diff --git a/src/Auth/Sessions.php b/src/Auth/Sessions.php
deleted file mode 100644
index af0719f..0000000
--- a/src/Auth/Sessions.php
+++ /dev/null
@@ -1,66 +0,0 @@
-getSessionData('auth_uid');
- }
-
- /**
- * Store the current user id in the session
- */
- protected function persistCurrentUser()
- {
- $user = $this->user();
- $this->updateSessionData('auth_uid', $user ? $user->getId() : null);
- }
-}
diff --git a/src/Auth/User.php b/src/Auth/User.php
deleted file mode 100644
index 3b368f3..0000000
--- a/src/Auth/User.php
+++ /dev/null
@@ -1,45 +0,0 @@
-auth = $auth;
+ $this->responseFactory = $responseFactory;
+ $this->getRequiredRole = \Closure::fromCallable($getRequiredRole);
+ }
+
+ /**
+ * Process an incoming server request (PSR-15).
+ *
+ * @param ServerRequest $request
+ * @param RequestHandler $handler
+ * @return Response
+ */
+ public function process(ServerRequest $request, RequestHandler $handler): Response
+ {
+ $this->initialize();
+
+ if (!$this->isAllowed($request)) {
+ return $this->forbidden($request);
+ }
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * Get a callback that can be used as double pass middleware.
+ *
+ * @return callable
+ */
+ public function asDoublePass(): callable
+ {
+ return function (ServerRequest $request, Response $response, callable $next): Response {
+ $this->initialize();
+
+ if (!$this->isAllowed($request)) {
+ return $this->forbidden($request, $response);
+ }
+
+ return $next($request, $response);
+ };
+ }
+
+ /**
+ * Initialize the auth service.
+ */
+ protected function initialize(): void
+ {
+ if ($this->auth instanceof Auth && !$this->auth->isInitialized()) {
+ $this->auth->initialize();
+ }
+ }
+
+ /**
+ * Check if the request is allowed by the current user.
+ */
+ protected function isAllowed(ServerRequest $request): bool
+ {
+ $requiredRole = ($this->getRequiredRole)($request);
+
+ if ($requiredRole === null) {
+ return true;
+ }
+
+ if (is_bool($requiredRole)) {
+ return ($this->auth->user() !== null) === $requiredRole;
+ }
+
+ return Pipeline::with(is_array($requiredRole) ? $requiredRole : [$requiredRole])
+ ->hasAny(fn($role) => $this->auth->is($role));
+ }
+
+ /**
+ * Respond with forbidden (or unauthorized).
+ */
+ protected function forbidden(ServerRequest $request, ?Response $response = null): Response
+ {
+ $unauthorized = $this->auth->user() === null;
+
+ $forbiddenResponse = $this->createResponse($unauthorized ? 401 : 403, $response)
+ ->withProtocolVersion($request->getProtocolVersion());
+ $forbiddenResponse->getBody()->write('Access denied');
+
+ return $forbiddenResponse;
+ }
+
+ /**
+ * Create a response using the response factory.
+ *
+ * @param int $status Response status
+ * @param Response|null $originalResponse
+ * @return Response
+ */
+ protected function createResponse(int $status, ?Response $originalResponse = null): Response
+ {
+ if ($this->responseFactory !== null) {
+ return $this->responseFactory->createResponse($status);
+ } elseif ($originalResponse !== null) {
+ return $originalResponse->withStatus($status)->withBody(clone $originalResponse->getBody());
+ ;
+ } else {
+ throw new \LogicException('Response factory not set');
+ }
+ }
+}
diff --git a/src/Authz.php b/src/Authz.php
deleted file mode 100644
index 57de1c6..0000000
--- a/src/Authz.php
+++ /dev/null
@@ -1,32 +0,0 @@
-
- * if (!$auth->is('manager')) {
- * http_response_code(403); // Forbidden
- * echo "You are not allowed to view this page";
- * exit();
- * }
- *
- *
- * @param string $role
- * @return boolean
- */
- public function is($role);
-}
diff --git a/src/Authz/ByGroup.php b/src/Authz/ByGroup.php
deleted file mode 100644
index 7f8c203..0000000
--- a/src/Authz/ByGroup.php
+++ /dev/null
@@ -1,119 +0,0 @@
-
- * class Auth extends Jasny\Auth
- * {
- * use Jasny\Authz\ByGroup;
- *
- * protected function getGroupStructure()
- * {
- * return [
- * 'user' => [],
- * 'accountant' => ['user'],
- * 'moderator' => ['user'],
- * 'developer' => ['user'],
- * 'admin' => ['moderator', 'developer']
- * ];
- * }
- * }
- *
- */
-trait ByGroup
-{
- /**
- * Get the authenticated user
- *
- * @return User
- */
- abstract public function user();
-
- /**
- * Get the groups and the groups it supersedes.
- *
- * @return array
- */
- abstract protected function getGroupStructure();
-
-
- /**
- * Get group and all groups it supersedes (recursively).
- *
- * @param string|array $group Single group or array of groups
- * @return array
- */
- protected function expandGroup($group)
- {
- $groups = (array)$group;
- $structure = $this->getGroupStructure();
-
- $expanded = [];
-
- foreach ($groups as $group) {
- if (!isset($structure[$group])) {
- continue;
- }
-
- $expanded[] = $group;
- $expanded = array_merge($expanded, $this->expandGroup((array)$structure[$group]));
- }
-
- return array_unique($expanded);
- }
-
-
- /**
- * Get all auth roles
- *
- * @return array
- */
- public function getRoles()
- {
- $structure = $this->getGroupStructure();
-
- if (!is_array($structure)) {
- throw new \UnexpectedValueException("Group structure should be an array");
- }
-
- return array_keys($structure);
- }
-
- /**
- * Check if the current user is logged in and has specified role.
- *
- *
- * if (!$auth->is('manager')) {
- * http_response_code(403); // Forbidden
- * echo "You are not allowed to view this page";
- * exit();
- * }
- *
- *
- * @param string $group
- * @return boolean
- */
- public function is($group)
- {
- if (!in_array($group, $this->getRoles())) {
- trigger_error("Unknown role '$group'", E_USER_NOTICE);
- return false;
- }
-
- $user = $this->user();
-
- if (!isset($user)) {
- return false;
- }
-
- $userGroups = $this->expandGroup($user->getRole());
-
- return in_array($group, $userGroups);
- }
-}
diff --git a/src/Authz/ByLevel.php b/src/Authz/ByLevel.php
deleted file mode 100644
index 16f4687..0000000
--- a/src/Authz/ByLevel.php
+++ /dev/null
@@ -1,122 +0,0 @@
-
- * class Auth extends Jasny\Auth
- * {
- * use Jasny\Authz\ByLevel;
- *
- * protected function getAccessLevels()
- * {
- * return [
- * 'user' => 1,
- * 'moderator' => 100,
- * 'admin' => 1000
- * ];
- * }
- * }
- *
- */
-trait ByLevel
-{
- /**
- * Get the authenticated user
- *
- * @return User
- */
- abstract public function user();
-
- /**
- * Get all access levels.
- *
- * @return array
- */
- abstract protected function getAccessLevels();
-
- /**
- * Get access level by name.
- *
- * @param string|int $role
- * @return int
- * @throws DomainException for unknown level names
- */
- public function getLevel($role)
- {
- if (is_int($role) || (is_string($role) && ctype_digit($role))) {
- return (int)$role;
- }
-
- if (!is_string($role)) {
- $type = (is_object($role) ? get_class($role) . ' ' : '') . gettype($role);
- throw new \InvalidArgumentException("Expected role to be a string, not a $type");
- }
-
- $levels = $this->getAccessLevels();
-
- if (!isset($levels[$role])) {
- throw new \DomainException("Authorization level '$role' isn't defined.");
- }
-
- return $levels[$role];
- }
-
- /**
- * Get all authz roles
- *
- * @return array
- */
- public function getRoles()
- {
- $levels = $this->getAccessLevels();
-
- if (!is_array($levels)) {
- throw new \UnexpectedValueException("Access levels should be an array");
- }
-
- return array_keys($levels);
- }
-
- /**
- * Check if the current user is logged in and has specified role.
- *
- *
- * if (!$auth->is('manager')) {
- * http_response_code(403); // Forbidden
- * echo "You are not allowed to view this page";
- * exit();
- * }
- *
- *
- * @param string|int $role
- * @return boolean
- */
- public function is($role)
- {
- if (!in_array($role, $this->getRoles())) {
- trigger_error("Unknown role '$role'", E_USER_NOTICE);
- return false;
- }
-
- $user = $this->user();
-
- if (!isset($user)) {
- return false;
- }
-
- try {
- $userLevel = $this->getLevel($user->getRole());
- } catch (\DomainException $ex) {
- trigger_error("Unknown user role '" . $user->getRole() . "'", E_USER_NOTICE);
- return false;
- } catch (\Exception $ex) {
- trigger_error($ex->getMessage(), E_USER_WARNING);
- return false;
- }
-
- return $userLevel >= $this->getLevel($role);
- }
-}
diff --git a/src/Authz/Groups.php b/src/Authz/Groups.php
new file mode 100644
index 0000000..10a3a7b
--- /dev/null
+++ b/src/Authz/Groups.php
@@ -0,0 +1,170 @@
+
+ * $authz = new Authz\Groups([
+ * 'user' => [],
+ * 'accountant' => ['user'],
+ * 'moderator' => ['user'],
+ * 'developer' => ['user'],
+ * 'admin' => ['moderator', 'developer']
+ * ]);
+ *
+ * $auth = new Auth($authz);
+ *
+ */
+class Groups implements Authz
+{
+ use StateTrait;
+
+ /** @var array */
+ protected array $groups;
+
+ /**
+ * Current authenticated user
+ */
+ protected ?User $user = null;
+
+ /**
+ * The authorization context. This could be an organization, where a user has specific roles per organization
+ * rather than roles globally.
+ */
+ protected ?Context $context = null;
+
+ /**
+ * Cached user level. Service has an immutable state.
+ * @var string[]
+ */
+ protected array $userRoles = [];
+
+ /**
+ * AuthzByGroup constructor.
+ *
+ * @param array $groups
+ */
+ public function __construct(array $groups)
+ {
+ foreach ($groups as $group => &$roles) {
+ $roles = $this->expand($group, $groups);
+ }
+
+ $this->groups = $groups;
+ }
+
+ /**
+ * Get a copy of the service with a modified property and recalculated
+ * Returns $this if authz hasn't changed.
+ *
+ * @param string $property
+ * @param string $value
+ * @return static
+ */
+ protected function withProperty(string $property, $value): self
+ {
+ $clone = clone $this;
+ $clone->{$property} = $value;
+
+ $clone->calcUserRoles();
+
+ $isSame = $clone->{$property} === $this->{$property} && $clone->userRoles === $this->userRoles;
+
+ return $isSame ? $this : $clone;
+ }
+
+ /**
+ * Expand groups to include all roles they supersede.
+ *
+ * @param string $role
+ * @param array $groups
+ * @param string[] $expanded Accumulator
+ * @return string[]
+ */
+ protected function expand(string $role, array $groups, array &$expanded = []): array
+ {
+ $expanded[] = $role;
+
+ // Ignore duplicates.
+ $additionalRoles = array_diff($groups[$role], $expanded);
+
+ // Remove current role from groups to prevent issues from cross-references.
+ $groupsWithoutCurrent = array_diff_key($groups, [$role => null]);
+
+ // Recursively expand the superseded roles.
+ foreach ($additionalRoles as $additionalRole) {
+ $this->expand($additionalRole, $groupsWithoutCurrent, $expanded);
+ }
+
+ return $expanded;
+ }
+
+
+ /**
+ * Get all available authorization roles (for the current context).
+ *
+ * @return string[]
+ */
+ public function getAvailableRoles(): array
+ {
+ return array_keys($this->groups);
+ }
+
+ /**
+ * Check if the current user is logged in and has specified role.
+ */
+ public function is(string $role): bool
+ {
+ if (!isset($this->groups[$role])) {
+ trigger_error("Unknown authz role '$role'", E_USER_WARNING); // Catch typos
+ return false;
+ }
+
+ return in_array($role, $this->userRoles, true);
+ }
+
+ /**
+ * Get a copy, recalculating the authz level of the user.
+ * Returns $this if authz hasn't changed.
+ *
+ * @return static
+ */
+ public function recalc(): self
+ {
+ $clone = clone $this;
+ $clone->calcUserRoles();
+
+ return $clone->userRoles === $this->userRoles ? $this : $clone;
+ }
+
+ /**
+ * Calculate the (expanded) roles of the current user.
+ */
+ protected function calcUserRoles(): void
+ {
+ if ($this->user === null) {
+ $this->userRoles = [];
+ return;
+ }
+
+ $role = $this->user->getAuthRole($this->context);
+ $roles = is_array($role) ? $role : [$role];
+
+ $this->userRoles = Pipeline::with($roles)
+ ->map(fn($role) => $this->groups[$role] ?? [])
+ ->flatten()
+ ->unique()
+ ->toArray();
+ }
+}
diff --git a/src/Authz/Levels.php b/src/Authz/Levels.php
new file mode 100644
index 0000000..e4e8092
--- /dev/null
+++ b/src/Authz/Levels.php
@@ -0,0 +1,137 @@
+
+ * $authz = new Authz\Levels([
+ * 'user' => 1,
+ * 'moderator' => 100,
+ * 'admin' => 1000
+ * ]);
+ *
+ * $auth = new Auth($authz);
+ *
+ *
+ * Levels should be positive integers.
+ */
+class Levels implements AuthzInterface
+{
+ use StateTrait;
+
+ /** @var array */
+ protected array $levels;
+
+ /**
+ * Cached user level. Service has an immutable state.
+ */
+ protected int $userLevel = 0;
+
+ /**
+ * AuthzByLevel constructor.
+ *
+ * @param array $levels
+ */
+ public function __construct(array $levels)
+ {
+ $this->levels = $levels;
+ }
+
+
+ /**
+ * Get a copy of the service with a modified property and recalculated
+ * Returns $this if authz hasn't changed.
+ *
+ * @param string $property
+ * @param string $value
+ * @return static
+ */
+ private function withProperty(string $property, $value): self
+ {
+ $clone = clone $this;
+ $clone->{$property} = $value;
+
+ $clone->calcUserLevel();
+
+ $isSame = $clone->{$property} === $this->{$property} && $clone->userLevel === $this->userLevel;
+
+ return $isSame ? $this : $clone;
+ }
+
+ /**
+ * Get all available authorization roles (for the current context)
+ *
+ * @return string[]
+ */
+ public function getAvailableRoles(): array
+ {
+ return array_keys($this->levels);
+ }
+
+ /**
+ * Check if the current user is logged in and has specified role.
+ */
+ public function is(string $role): bool
+ {
+ if (!isset($this->levels[$role])) {
+ trigger_error("Unknown authz role '$role'", E_USER_WARNING); // Catch typos
+ return false;
+ }
+
+ return $this->userLevel >= $this->levels[$role];
+ }
+
+
+ /**
+ * Get a copy, recalculating the authz level of the user.
+ * Returns $this if authz hasn't changed.
+ *
+ * @return static
+ */
+ public function recalc(): self
+ {
+ $clone = clone $this;
+ $clone->calcUserLevel();
+
+ return $clone->userLevel === $this->userLevel ? $this : $clone;
+ }
+
+ /**
+ * Get access level of the current user.
+ *
+ * @throws \DomainException for unknown level names
+ */
+ private function calcUserLevel(): void
+ {
+ if ($this->user === null) {
+ $this->userLevel = 0;
+ return;
+ }
+
+ $uid = $this->user->getAuthId();
+
+ $role = i\type_check(
+ $this->user->getAuthRole($this->context),
+ ['int', 'string'],
+ new \UnexpectedValueException("For authz levels the role should be string|int, %s returned (uid:$uid)")
+ );
+
+ if (is_string($role) && !isset($this->levels[$role])) {
+ throw new \DomainException("Authorization level '$role' isn't defined (uid:$uid)");
+ }
+
+ $this->userLevel = is_string($role) ? $this->levels[$role] : (int)$role;
+ }
+}
diff --git a/src/Authz/StateTrait.php b/src/Authz/StateTrait.php
new file mode 100644
index 0000000..073550d
--- /dev/null
+++ b/src/Authz/StateTrait.php
@@ -0,0 +1,70 @@
+withProperty('user', $user);
+ }
+
+ /**
+ * Get a copy of the service for the given context.
+ * Returns $this if authz hasn't changed.
+ *
+ * @param Context|null $context
+ * @return static&Authz
+ */
+ public function inContextOf(?Context $context): Authz
+ {
+ return $this->withProperty('context', $context);
+ }
+
+ /**
+ * Get current authenticated user.
+ *
+ * @return User|null
+ */
+ final public function user(): ?User
+ {
+ return $this->user;
+ }
+
+ /**
+ * Get the current context.
+ */
+ final public function context(): ?Context
+ {
+ return $this->context;
+ }
+}
diff --git a/src/Authz/User.php b/src/Authz/User.php
deleted file mode 100644
index d80f123..0000000
--- a/src/Authz/User.php
+++ /dev/null
@@ -1,16 +0,0 @@
-secret = $secret;
+
+ $this->createHashids = $createHashids !== null
+ ? \Closure::fromCallable($createHashids)
+ : fn(string $salt) => new Hashids($salt);
+ }
+
+ /**
+ * Get copy with storage service.
+ *
+ * @param Storage $storage
+ * @return static
+ */
+ public function withStorage(Storage $storage): self
+ {
+ return $this->withProperty('storage', $storage);
+ }
+
+ /**
+ * Create a copy of this service with a specific subject.
+ *
+ * @param string $subject
+ * @return static
+ */
+ public function withSubject(string $subject): self
+ {
+ return $this->withProperty('subject', $subject);
+ }
+
+
+ /**
+ * Generate a confirmation token.
+ */
+ public function getToken(User $user, \DateTimeInterface $expire): string
+ {
+ $uidHex = $this->encodeUid($user->getAuthId());
+ $expireHex = CarbonImmutable::instance($expire)->utc()->format('YmdHis');
+ $checksum = $this->calcChecksum($user, $expire);
+
+ return $this->createHashids()->encodeHex($checksum . $expireHex . $uidHex);
+ }
+
+ /**
+ * Get user by confirmation token.
+ *
+ * @param string $token Confirmation token
+ * @return User
+ * @throws InvalidTokenException
+ */
+ public function from(string $token): User
+ {
+ $hex = $this->createHashids()->decodeHex($token);
+ $info = $this->extractHex($hex);
+
+ if ($info === null) {
+ throw new InvalidTokenException('Invalid confirmation token');
+ }
+
+ /* @var CarbonImmutable $expire */
+ ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid] = $info;
+
+ if ($expire->isPast()) {
+ throw new InvalidTokenException("Token is expired");
+ }
+
+ $user = $this->storage->fetchUserById($uid);
+ if ($user === null) {
+ throw new InvalidTokenException("User '$uid' doesn't exist");
+ }
+
+ if ($checksum !== $this->calcChecksum($user, $expire)) {
+ throw new InvalidTokenException("Checksum doesn't match");
+ }
+
+ return $user;
+ }
+
+ /**
+ * Extract uid, expire date and checksum from hex.
+ *
+ * @param string $hex
+ * @return null|array{checksum:string,expire:CarbonImmutable,uid:string|int}
+ */
+ protected function extractHex(string $hex): ?array
+ {
+ if (strlen($hex) <= 78) {
+ return null;
+ }
+
+ $checksum = substr($hex, 0, 64);
+ $expireHex = substr($hex, 64, 14);
+ $uidHex = substr($hex, 78);
+
+ try {
+ $uid = $this->decodeUid($uidHex);
+
+ /** @var CarbonImmutable $expire */
+ $expire = CarbonImmutable::createFromFormat('YmdHis', $expireHex, '+00:00');
+ } catch (\Exception $exception) {
+ return null;
+ }
+
+ return ['checksum' => $checksum, 'expire' => $expire, 'uid' => $uid];
+ }
+
+ /**
+ * Encode the uid to a hex value.
+ *
+ * @param int|string $uid
+ * @return string
+ */
+ protected function encodeUid($uid): string
+ {
+ return is_int($uid) ? '00' . dechex($uid) : '01' . (unpack('H*', $uid)[1]);
+ }
+
+ /**
+ * Decode the uid to a hex value.
+ *
+ * @param string $hex
+ * @return int|string
+ */
+ protected function decodeUid(string $hex)
+ {
+ $type = substr($hex, 0, 2);
+ $uidHex = substr($hex, 2);
+
+ if ($type !== '00' && $type !== '01') {
+ throw new \RuntimeException("Invalid uid");
+ }
+
+ return $type === '00' ? (int)hexdec($uidHex) : pack('H*', $uidHex);
+ }
+
+ /**
+ * Calculate confirmation checksum.
+ */
+ protected function calcChecksum(User $user, \DateTimeInterface $expire): string
+ {
+ $utc = CarbonImmutable::instance($expire)->utc();
+ $parts = [$utc->format('YmdHis'), $user->getAuthId(), $user->getAuthChecksum(), $this->secret];
+
+ return hash('sha256', join("\0", $parts));
+ }
+
+
+ /**
+ * Create a hashids service.
+ */
+ public function createHashids(): Hashids
+ {
+ return ($this->createHashids)(hash('sha256', $this->subject . $this->secret, true));
+ }
+}
diff --git a/src/Confirmation/InvalidTokenException.php b/src/Confirmation/InvalidTokenException.php
new file mode 100644
index 0000000..6bfb5f7
--- /dev/null
+++ b/src/Confirmation/InvalidTokenException.php
@@ -0,0 +1,12 @@
+user = $user;
+ }
+
+ /**
+ * Get the user.
+ */
+ final public function user(): User
+ {
+ return $this->user;
+ }
+}
diff --git a/src/Event/CancellableTrait.php b/src/Event/CancellableTrait.php
new file mode 100644
index 0000000..e989bf3
--- /dev/null
+++ b/src/Event/CancellableTrait.php
@@ -0,0 +1,45 @@
+cancelled = $reason;
+ }
+
+ /**
+ * Is login cancelled?
+ */
+ public function isCancelled(): bool
+ {
+ return $this->cancelled !== null;
+ }
+
+ /**
+ * Get reason why login was cancelled.
+ */
+ public function getCancellationReason(): string
+ {
+ return (string)$this->cancelled;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ final public function isPropagationStopped(): bool
+ {
+ return $this->isCancelled();
+ }
+}
diff --git a/src/Event/Login.php b/src/Event/Login.php
new file mode 100644
index 0000000..a3de685
--- /dev/null
+++ b/src/Event/Login.php
@@ -0,0 +1,15 @@
+|null */
+ protected ?\ArrayAccess $session;
+
+ /**
+ * PhpSession constructor.
+ *
+ * @param string $key
+ * @param \ArrayAccess|null $session Omit to use $_SESSION
+ */
+ public function __construct(string $key = 'auth', ?\ArrayAccess $session = null)
+ {
+ $this->key = $key;
+ $this->session = $session;
+ }
+
+ /**
+ * Get the auth info from session data.
+ *
+ * @return array
+ */
+ protected function getSessionData(): array
+ {
+ // @codeCoverageIgnoreStart
+ if ($this->session === null && session_status() !== \PHP_SESSION_ACTIVE) {
+ throw new \RuntimeException("Unable to get auth info from session: Session not started");
+ }// @codeCoverageIgnoreEnd
+
+ $session = $this->session ?? $_SESSION;
+
+ return $session[$this->key] ?? [];
+ }
+
+ /**
+ * Set the auth info to session data.
+ *
+ * @param array $info
+ */
+ protected function setSessionData(array $info): void
+ {
+ if ($this->session !== null) {
+ $this->session[$this->key] = $info;
+ } else {
+ $this->setGlobalSessionData($info); // @codeCoverageIgnore
+ }
+ }
+
+ /**
+ * Unset the auth info from session data.
+ */
+ protected function unsetSessionData(): void
+ {
+ if ($this->session !== null) {
+ unset($this->session[$this->key]);
+ } else {
+ $this->setGlobalSessionData(null); // @codeCoverageIgnore
+ }
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * @internal
+ *
+ * @param array|null $info
+ */
+ private function setGlobalSessionData(?array $info): void
+ {
+ if (session_status() !== \PHP_SESSION_ACTIVE) {
+ throw new \RuntimeException("Unable to persist auth info to session: Session not started");
+ }
+
+ if ($info !== null) {
+ $_SESSION[$this->key] = $info;
+ } else {
+ unset($_SESSION[$this->key]);
+ }
+ }
+
+
+ /**
+ * Get auth information from session.
+ *
+ * @return array{uid:string|int|null,context:mixed,checksum:string|null}
+ */
+ public function getInfo(): array
+ {
+ $data = $this->getSessionData();
+
+ return [
+ 'uid' => $data['uid'] ?? null,
+ 'context' => $data['context'] ?? null,
+ 'checksum' => $data['checksum'] ?? null,
+ ];
+ }
+
+ /**
+ * Persist auth information to session.
+ *
+ * @param string|int $uid
+ * @param mixed $context
+ * @param string|null $checksum
+ */
+ public function persist($uid, $context, ?string $checksum): void
+ {
+ $this->setSessionData(compact('uid', 'context', 'checksum'));
+ }
+
+ /**
+ * Remove auth information from session.
+ */
+ public function clear(): void
+ {
+ $this->unsetSessionData();
+ }
+}
diff --git a/src/Session/SessionInterface.php b/src/Session/SessionInterface.php
new file mode 100644
index 0000000..e41a246
--- /dev/null
+++ b/src/Session/SessionInterface.php
@@ -0,0 +1,32 @@
+auth = $this->getMockForTrait(Auth\Confirmation::class);
- }
-
- /**
- * @return string
- */
- public function testGetConfirmationToken()
- {
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getId')->willReturn(123);
-
- $token = $this->auth->getConfirmationToken($user, 'foo bar');
-
- $this->assertInternalType('string', $token);
- $this->assertNotEmpty($token);
-
- return $token;
- }
-
- /**
- * @depends testGetConfirmationToken
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationWithValidToken($token)
- {
- $user = $this->createMock(Auth\User::class);
-
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->once())->method('fetchUserById')->with(123)->willReturn($user);
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar');
-
- $this->assertSame($user, $result);
- }
-
- /**
- * @depends testGetConfirmationToken
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationWithDeletedUser($token)
- {
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->once())->method('fetchUserById')->with(123)->willReturn(null);
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar');
-
- $this->assertNull($result);
- }
-
- public function testFetchUserForConfirmationWithInvalidToken()
- {
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->never())->method('fetchUserById');
-
- $result = $this->auth->fetchUserForConfirmation('faKeToken', 'foo bar');
-
- $this->assertNull($result);
- }
-
- public function testFetchUserForConfirmationWithInvalidChecksum()
- {
- $user = $this->createMock(User::class);
-
- $salt = hash('sha256', 'very secret' . 'foo bar'); // hashids salt has been compromized
- $hashids = new Hashids($salt);
-
- $token = $hashids->encodeHex(str_repeat('0', 16) . '123'); // token with id and invalid checksum
-
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->once())->method('fetchUserById')->with(123)->willReturn($user);
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar');
-
- $this->assertNull($result);
- }
-
- /**
- * @depends testGetConfirmationToken
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationWithOtherSecret($token)
- {
- $this->auth->method('getConfirmationSecret')->willReturn('other secret');
- $this->auth->expects($this->never())->method('fetchUserById');
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar');
-
- $this->assertNull($result);
- }
-
- /**
- * @depends testGetConfirmationToken
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationWithOtherSubject($token)
- {
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->never())->method('fetchUserById');
-
- $result = $this->auth->fetchUserForConfirmation($token, 'other subject');
-
- $this->assertNull($result);
- }
-
-
- /**
- * @return string
- */
- public function testGetConfirmationTokenWithHexId()
- {
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getId')->willReturn('585c7f9a22a9037a1c8b4567');
-
- $token = $this->auth->getConfirmationToken($user, 'foo bar');
-
- $this->assertInternalType('string', $token);
- $this->assertNotEmpty($token);
-
- return $token;
- }
-
- /**
- * @depends testGetConfirmationTokenWithHexId
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationWithHexId($token)
- {
- $user = $this->createMock(Auth\User::class);
-
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->once())->method('fetchUserById')->with('585c7f9a22a9037a1c8b4567')
- ->willReturn($user);
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar');
-
- $this->assertSame($user, $result);
- }
-
- /**
- * @return string
- */
- public function testGetConfirmationTokenUsingPassword()
- {
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getId')->willReturn(123);
- $user->method('getHashedPassword')->willReturn('1234567890abcdef');
-
- $token = $this->auth->getConfirmationToken($user, 'foo bar', true);
-
- $this->assertInternalType('string', $token);
- $this->assertNotEmpty($token);
-
- return $token;
- }
-
- /**
- * @depends testGetConfirmationTokenUsingPassword
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationUsingPassword($token)
- {
- $user = $this->createMock(Auth\User::class);
- $user->expects($this->once())->method('getHashedPassword')->willReturn('1234567890abcdef');
-
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->once())->method('fetchUserById')->with(123)->willReturn($user);
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar', true);
-
- $this->assertSame($user, $result);
- }
-
- /**
- * @depends testGetConfirmationTokenUsingPassword
- *
- * @param string $token
- */
- public function testFetchUserForConfirmationAfterChangedPassword($token)
- {
- $user = $this->createMock(Auth\User::class);
- $user->expects($this->once())->method('getHashedPassword')->willReturn('0000000000000000');
-
- $this->auth->method('getConfirmationSecret')->willReturn('very secret');
- $this->auth->expects($this->once())->method('fetchUserById')->with(123)->willReturn($user);
-
- $result = $this->auth->fetchUserForConfirmation($token, 'foo bar', true);
-
- $this->assertNull($result);
- }
-}
diff --git a/tests/Auth/MiddlewareTest.php b/tests/Auth/MiddlewareTest.php
deleted file mode 100644
index 84da021..0000000
--- a/tests/Auth/MiddlewareTest.php
+++ /dev/null
@@ -1,213 +0,0 @@
-auth = $this->createMock(AuthAuthz::class);
-
- $this->middleware = new Middleware($this->auth, function(ServerRequestInterface $request) {
- return $request->getAttribute('auth');
- });
- }
-
- /**
- * @expectedException \InvalidArgumentException
- */
- public function testConstructInvalidArgument()
- {
- new Middleware($this->auth, 'foo bar zoo');
- }
-
-
- public function testInvokeWithoutRequiredUser()
- {
- $this->auth->expects($this->never())->method('user');
-
- $request = $this->createMock(ServerRequestInterface::class);
- $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(false);
-
- $finalResponse = $this->createMock(ResponseInterface::class);
-
- $response = $this->createMock(ResponseInterface::class);
- $response->expects($this->never())->method('withStatus');
-
- $next = $this->createCallbackMock(
- $this->once(),
- function(InvocationMocker $invoke) use ($request, $response, $finalResponse) {
- $invoke->with($this->identicalTo($request), $this->identicalTo($response))->willReturn($finalResponse);
- }
- );
-
- $result = call_user_func($this->middleware, $request, $response, $next);
-
- $this->assertSame($finalResponse, $result);
- }
-
- public function testInvokeWithoutRequiredRole()
- {
- $request = $this->createMock(ServerRequestInterface::class);
- $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null);
-
- $finalResponse = $this->createMock(ResponseInterface::class);
-
- $response = $this->createMock(ResponseInterface::class);
- $response->expects($this->never())->method('withStatus');
-
- $next = $this->createCallbackMock(
- $this->once(),
- function(InvocationMocker $invoke) use ($request, $response, $finalResponse) {
- $invoke->with($this->identicalTo($request), $this->identicalTo($response))->willReturn($finalResponse);
- }
- );
-
- $result = call_user_func($this->middleware, $request, $response, $next);
-
- $this->assertSame($finalResponse, $result);
- }
-
- public function testInvokeWithRequiredUser()
- {
- $user = $this->createMock(Authz\User::class);
- $this->auth->expects($this->once())->method('user')->willReturn($user);
-
- $request = $this->createMock(ServerRequestInterface::class);
- $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true);
-
- $finalResponse = $this->createMock(ResponseInterface::class);
-
- $response = $this->createMock(ResponseInterface::class);
- $response->expects($this->never())->method('withStatus');
-
- $next = $this->createCallbackMock(
- $this->once(),
- function(InvocationMocker $invoke) use ($request, $response, $finalResponse) {
- $invoke->with($this->identicalTo($request), $this->identicalTo($response))->willReturn($finalResponse);
- }
- );
-
- $result = call_user_func($this->middleware, $request, $response, $next);
-
- $this->assertSame($finalResponse, $result);
- }
-
- public function testInvokeWithRequiredRole()
- {
- $this->auth->expects($this->once())->method('is')->with('user')->willReturn(true);
-
- $request = $this->createMock(ServerRequestInterface::class);
- $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('user');
-
- $finalResponse = $this->createMock(ResponseInterface::class);
-
- $response = $this->createMock(ResponseInterface::class);
- $response->expects($this->never())->method('withStatus');
-
- $next = $this->createCallbackMock(
- $this->once(),
- function(InvocationMocker $invoke) use ($request, $response, $finalResponse) {
- $invoke->with($this->identicalTo($request), $this->identicalTo($response))->willReturn($finalResponse);
- }
- );
-
- $result = call_user_func($this->middleware, $request, $response, $next);
-
- $this->assertSame($finalResponse, $result);
- }
-
- public function testInvokeUnauthorized()
- {
- $this->auth->expects($this->once())->method('user')->willReturn(null);
- $this->auth->expects($this->once())->method('is')->with('user')->willReturn(false);
-
- $this->setPrivateProperty($this->middleware, 'auth', $this->auth);
-
- $request = $this->createMock(ServerRequestInterface::class);
- $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('user');
- $request->expects($this->once())->method('getProtocolVersion')->willReturn('1.1');
-
- $stream = $this->createMock(StreamInterface::class);
- $stream->expects($this->once())->method('write')->with('Access denied');
-
- $forbiddenResponse = $this->createMock(ResponseInterface::class);
- $forbiddenResponse->expects($this->once())->method('getBody')->willReturn($stream);
-
- $response = $this->createMock(ResponseInterface::class);
- $response->expects($this->once())->method('withProtocolVersion')->with('1.1')->willReturnSelf();
- $response->expects($this->once())->method('withStatus')->with(401)->willReturn($forbiddenResponse);
-
- $next = $this->createCallbackMock($this->never());
-
- $result = call_user_func($this->middleware, $request, $response, $next);
-
- $this->assertSame($forbiddenResponse, $result);
- }
-
- public function testInvokeForbidden()
- {
- $user = $this->createMock(Authz\User::class);
-
- $this->auth->expects($this->once())->method('user')->willReturn($user);
- $this->auth->expects($this->once())->method('is')->with('user')->willReturn(false);
-
- $request = $this->createMock(ServerRequestInterface::class);
- $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('user');
- $request->expects($this->once())->method('getProtocolVersion')->willReturn('1.1');
-
- $stream = $this->createMock(StreamInterface::class);
- $stream->expects($this->once())->method('write')->with('Access denied');
-
- $forbiddenResponse = $this->createMock(ResponseInterface::class);
- $forbiddenResponse->expects($this->once())->method('getBody')->willReturn($stream);
-
- $response = $this->createMock(ResponseInterface::class);
- $response->expects($this->once())->method('withProtocolVersion')->with('1.1')->willReturnSelf();
- $response->expects($this->once())->method('withStatus')->with(403)->willReturn($forbiddenResponse);
-
- $next = $this->createCallbackMock($this->never());
-
- $result = call_user_func($this->middleware, $request, $response, $next);
-
- $this->assertSame($forbiddenResponse, $result);
- }
-
- /**
- * @expectedException \InvalidArgumentException
- */
- public function testInvokeInvalidArgument()
- {
- $request = $this->createMock(ServerRequestInterface::class);
- $response = $this->createMock(ResponseInterface::class);
-
- $result = call_user_func($this->middleware, $request, $response, 'foo bar zoo');
- }
-}
\ No newline at end of file
diff --git a/tests/Auth/SessionsTest.php b/tests/Auth/SessionsTest.php
deleted file mode 100644
index 12ba621..0000000
--- a/tests/Auth/SessionsTest.php
+++ /dev/null
@@ -1,102 +0,0 @@
-sessionModule = session_module_name();
-
- $void = function () { return true; };
- $read = function () { return "a:0:{}"; };
- session_set_save_handler($void, $void, $read, $void, $void, $void);
-
- session_start();
- }
-
- protected function restoreSessionHandling()
- {
- session_abort();
- session_module_name($this->sessionModule);
- }
-
-
- public function setUp()
- {
- $this->auth = $this->getMockForTrait(Auth\Sessions::class);
- $this->mockSessionHandling();
- }
-
- public function tearDown()
- {
- $this->restoreSessionHandling();
- }
-
-
- public function testGetCurrentUserIdWithUser()
- {
- $_SESSION['auth_uid'] = 123;
-
- $id = $this->callPrivateMethod($this->auth, 'getCurrentUserId');
- $this->assertEquals(123, $id);
- }
-
- public function testGetCurrentUserIdWithoutUser()
- {
- $id = $this->callPrivateMethod($this->auth, 'getCurrentUserId');
- $this->assertNull($id);
- }
-
-
- public function testPersistCurrentUserWithUser()
- {
- $_SESSION['foo'] = 'bar';
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getId')->willReturn(123);
-
- $this->auth->expects($this->once())->method('user')->willReturn($user);
-
- $this->callPrivateMethod($this->auth, 'persistCurrentUser');
-
- $this->assertEquals(['foo' => 'bar', 'auth_uid' => 123], $_SESSION);
- }
-
- public function testPersistCurrentUserWithoutUser()
- {
- $_SESSION['auth_uid'] = 123;
- $_SESSION['foo'] = 'bar';
-
- $this->auth->expects($this->once())->method('user')->willReturn(null);
-
- $this->callPrivateMethod($this->auth, 'persistCurrentUser');
-
- $this->assertEquals(['foo' => 'bar'], $_SESSION);
- }
-}
diff --git a/tests/AuthMiddlewareDoublePassTest.php b/tests/AuthMiddlewareDoublePassTest.php
new file mode 100644
index 0000000..afc4a14
--- /dev/null
+++ b/tests/AuthMiddlewareDoublePassTest.php
@@ -0,0 +1,216 @@
+authz = $this->createMock(Authz::class);
+
+ $this->middleware = new AuthMiddleware(
+ $this->authz,
+ fn(ServerRequest $request) => $request->getAttribute('auth'),
+ );
+ }
+
+ public function testNoRequirements()
+ {
+ $this->authz->expects($this->never())->method('user');
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null);
+
+ $initialResp = $this->createMock(Response::class);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $next = $this->createCallbackMock($this->once(), function ($invoke) use ($request, $initialResp, $response) {
+ return $invoke
+ ->with($this->identicalTo($request), $this->identicalTo($initialResp))
+ ->willReturn($response);
+ });
+
+ $doublePass = $this->middleware->asDoublePass();
+ $result = $doublePass($request, $initialResp, $next);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testRequireUser()
+ {
+ $user = $this->createMock(User::class);
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true);
+
+ $initialResp = $this->createMock(Response::class);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $next = $this->createCallbackMock($this->once(), function ($invoke) use ($request, $initialResp, $response) {
+ return $invoke
+ ->with($this->identicalTo($request), $this->identicalTo($initialResp))
+ ->willReturn($response);
+ });
+
+ $doublePass = $this->middleware->asDoublePass();
+ $result = $doublePass($request, $initialResp, $next);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testRequireNoUser()
+ {
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(false);
+
+ $initialResp = $this->createMock(Response::class);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $next = $this->createCallbackMock($this->once(), function ($invoke) use ($request, $initialResp, $response) {
+ return $invoke
+ ->with($this->identicalTo($request), $this->identicalTo($initialResp))
+ ->willReturn($response);
+ });
+
+ $doublePass = $this->middleware->asDoublePass();
+ $result = $doublePass($request, $initialResp, $next);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testLoginRequired()
+ {
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true);
+ $request->expects($this->once())->method('getProtocolVersion')->willReturn('1.1');
+
+ $body = $this->createMock(Stream::class);
+ $body->expects($this->once())->method('write')->with('Access denied');
+
+ $forbidden = $this->createMock(Response::class);
+ $forbidden->expects($this->once())->method('withProtocolVersion')->with('1.1')->willReturnSelf();
+ $forbidden->expects($this->once())->method('getBody')->willReturn($body);
+
+ $initialResp = $this->createMock(Response::class);
+ $initialResp->expects($this->once())->method('withStatus')->with(401)->willReturnSelf();
+ $initialResp->expects($this->once())->method('getBody')->willReturn($body);
+ $initialResp->expects($this->once())->method('withBody')->willReturn($forbidden);
+
+ $next = $this->createCallbackMock($this->never());
+
+ $doublePass = $this->middleware->asDoublePass();
+ $result = $doublePass($request, $initialResp, $next);
+
+ $this->assertSame($forbidden, $result);
+ }
+
+ public function testAccessGranted()
+ {
+ $this->authz->expects($this->once())->method('is')->with('foo')->willReturn(true);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('foo');
+
+ $initialResp = $this->createMock(Response::class);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $next = $this->createCallbackMock($this->once(), function ($invoke) use ($request, $initialResp, $response) {
+ return $invoke
+ ->with($this->identicalTo($request), $this->identicalTo($initialResp))
+ ->willReturn($response);
+ });
+
+ $doublePass = $this->middleware->asDoublePass();
+ $result = $doublePass($request, $initialResp, $next);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testAccessDenied()
+ {
+ $user = $this->createMock(User::class);
+
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user);
+ $this->authz->expects($this->once())->method('is')->with('foo')->willReturn(false);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('foo');
+ $request->expects($this->once())->method('getProtocolVersion')->willReturn('1.1');
+
+ $body = $this->createMock(Stream::class);
+ $body->expects($this->once())->method('write')->with('Access denied');
+
+ $forbidden = $this->createMock(Response::class);
+ $forbidden->expects($this->once())->method('withProtocolVersion')->with('1.1')->willReturnSelf();
+ $forbidden->expects($this->once())->method('getBody')->willReturn($body);
+
+ $initialResp = $this->createMock(Response::class);
+ $initialResp->expects($this->once())->method('withStatus')->with(403)->willReturnSelf();
+ $initialResp->expects($this->once())->method('getBody')->willReturn($body);
+ $initialResp->expects($this->once())->method('withBody')->willReturn($forbidden);
+
+ $next = $this->createCallbackMock($this->never());
+
+ $doublePass = $this->middleware->asDoublePass();
+ $result = $doublePass($request, $initialResp, $next);
+
+ $this->assertSame($forbidden, $result);
+ }
+
+
+ public function testInitialize()
+ {
+ $auth = $this->createMock(Auth::class);
+ $auth->expects($this->once())->method('isInitialized')->willReturn(false);
+ $auth->expects($this->once())->method('initialize');
+
+ $response = $this->createMock(Response::class);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null);
+
+ $middleware = new AuthMiddleware(
+ $auth,
+ fn(ServerRequest $request) => $request->getAttribute('auth'),
+ );
+
+ $next = $this->createCallbackMock($this->once(), [], $response);
+
+ $doublePass = $middleware->asDoublePass();
+ $doublePass($request, $response, $next);
+ }
+}
\ No newline at end of file
diff --git a/tests/AuthMiddlewareTest.php b/tests/AuthMiddlewareTest.php
new file mode 100644
index 0000000..32e6732
--- /dev/null
+++ b/tests/AuthMiddlewareTest.php
@@ -0,0 +1,256 @@
+authz = $this->createMock(Authz::class);
+ $this->responseFactory = $this->createMock(ResponseFactory::class);
+
+ $this->middleware = new AuthMiddleware(
+ $this->authz,
+ fn(ServerRequest $request) => $request->getAttribute('auth'),
+ $this->responseFactory,
+ );
+ }
+
+ public function testNoRequirements()
+ {
+ $this->authz->expects($this->never())->method('user');
+
+ $this->responseFactory->expects($this->never())->method('createResponse');
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->once())->method('handle')
+ ->with($this->identicalTo($request))
+ ->willReturn($response);
+
+ $result = $this->middleware->process($request, $handler);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testRequireUser()
+ {
+ $user = $this->createMock(User::class);
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user);
+
+ $this->responseFactory->expects($this->never())->method('createResponse');
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->once())->method('handle')
+ ->with($this->identicalTo($request))
+ ->willReturn($response);
+
+ $result = $this->middleware->process($request, $handler);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testRequireNoUser()
+ {
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null);
+
+ $this->responseFactory->expects($this->never())->method('createResponse');
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(false);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->once())->method('handle')
+ ->with($this->identicalTo($request))
+ ->willReturn($response);
+
+ $result = $this->middleware->process($request, $handler);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testLoginRequired()
+ {
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true);
+ $request->expects($this->once())->method('getProtocolVersion')->willReturn('1.1');
+
+ $body = $this->createMock(Stream::class);
+ $body->expects($this->once())->method('write')->with('Access denied');
+
+ $forbidden = $this->createMock(Response::class);
+ $forbidden->expects($this->once())->method('withProtocolVersion')->with('1.1')->willReturnSelf();
+ $forbidden->expects($this->once())->method('getBody')->willReturn($body);
+
+ $this->responseFactory->expects($this->once())->method('createResponse')
+ ->with(401)
+ ->willReturn($forbidden);
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->never())->method('handle');
+
+ $result = $this->middleware->process($request, $handler);
+
+ $this->assertSame($forbidden, $result);
+ }
+
+ public function testAccessGranted()
+ {
+ $this->authz->expects($this->once())->method('is')->with('foo')->willReturn(true);
+
+ $this->responseFactory->expects($this->never())->method('createResponse');
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('foo');
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->never())->method($this->anything());
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->once())->method('handle')
+ ->with($this->identicalTo($request))
+ ->willReturn($response);
+
+ $result = $this->middleware->process($request, $handler);
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testAccessDenied()
+ {
+ $user = $this->createMock(User::class);
+
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user);
+ $this->authz->expects($this->once())->method('is')->with('foo')->willReturn(false);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn('foo');
+ $request->expects($this->once())->method('getProtocolVersion')->willReturn('1.1');
+
+ $body = $this->createMock(Stream::class);
+ $body->expects($this->once())->method('write')->with('Access denied');
+
+ $forbidden = $this->createMock(Response::class);
+ $forbidden->expects($this->once())->method('withProtocolVersion')->with('1.1')->willReturnSelf();
+ $forbidden->expects($this->once())->method('getBody')->willReturn($body);
+
+ $this->responseFactory->expects($this->once())->method('createResponse')
+ ->with(403)
+ ->willReturn($forbidden);
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->never())->method('handle');
+
+ $result = $this->middleware->process($request, $handler);
+
+ $this->assertSame($forbidden, $result);
+ }
+
+
+ public function testMissingResponseFactory()
+ {
+ $middleware = new AuthMiddleware(
+ $this->authz,
+ fn(ServerRequest $request) => $request->getAttribute('auth'),
+ );
+
+ $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true);
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->never())->method('handle');
+
+ $this->expectException(\LogicException::class);
+
+ $middleware->process($request, $handler);
+ }
+
+
+ public function testInitialize()
+ {
+ $auth = $this->createMock(Auth::class);
+ $auth->expects($this->once())->method('isInitialized')->willReturn(false);
+ $auth->expects($this->once())->method('initialize');
+
+ $response = $this->createMock(Response::class);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null);
+
+ $middleware = new AuthMiddleware(
+ $auth,
+ fn(ServerRequest $request) => $request->getAttribute('auth'),
+ );
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->once())->method('handle')
+ ->with($this->identicalTo($request))
+ ->willReturn($response);
+
+ $middleware->process($request, $handler);
+ }
+
+ public function testInitializeTwice()
+ {
+ $auth = $this->createMock(Auth::class);
+ $auth->expects($this->once())->method('isInitialized')->willReturn(true);
+ $auth->expects($this->never())->method('initialize');
+
+ $response = $this->createMock(Response::class);
+
+ $request = $this->createMock(ServerRequest::class);
+ $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null);
+
+ $middleware = new AuthMiddleware(
+ $auth,
+ fn(ServerRequest $request) => $request->getAttribute('auth'),
+ );
+
+ $handler = $this->createMock(RequestHandler::class);
+ $handler->expects($this->once())->method('handle')
+ ->with($this->identicalTo($request))
+ ->willReturn($response);
+
+ $middleware->process($request, $handler);
+ }
+}
\ No newline at end of file
diff --git a/tests/AuthTest.php b/tests/AuthTest.php
index be3f837..dd74aab 100644
--- a/tests/AuthTest.php
+++ b/tests/AuthTest.php
@@ -1,194 +1,610 @@
authz = $this->createMock(Authz::class);
+ $this->session = $this->createMock(Session::class);
+ $this->storage = $this->createMock(Storage::class);
+ $this->confirmation = $this->createMock(Confirmation::class);
+ $this->dispatcher = $this->createMock(EventDispatcher::class);
+
+ $this->service = (new Auth($this->authz, $this->storage, $this->confirmation))
+ ->withSession($this->session)
+ ->withEventDispatcher($this->dispatcher);
+ }
+
+
+ protected function expectInitAuthz(?User $user, ?Context $context)
+ {
+ $newAuthz = $this->createMock(Authz::class);
+
+ $this->authz->expects($this->once())->method('forUser')
+ ->with($this->identicalTo($user))
+ ->willReturnSelf();
+ $this->authz->expects($this->once())->method('inContextOf')
+ ->with($this->identicalTo($context))
+ ->willReturn($newAuthz);
+
+ return $newAuthz;
+ }
+
+ public function testInitializeWithoutSession()
+ {
+ //
+ $this->session->expects($this->once())
+ ->method('getInfo')
+ ->willReturn(['uid' => null, 'context' => null, 'checksum' => null]);
+
+ $this->storage->expects($this->never())->method($this->anything());
+ //
+
+ $newAuthz = $this->expectInitAuthz(null, null);
+
+ $this->assertFalse($this->service->isInitialized());
+
+ $this->service->initialize();
+
+ $this->assertTrue($this->service->isInitialized());
+ $this->assertSame($newAuthz, $this->service->authz());
+
+ return $this->service;
+ }
+
/**
- * @var Auth|MockObject
+ * @depends testInitializeWithoutSession
*/
- protected $auth;
-
- public function setUp()
+ public function testInitializeTwice(Auth $service)
+ {
+ $this->expectException(\LogicException::class);
+ $service->initialize();
+ }
+
+ public function testInitializeWithUser()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+
+ //
+ $this->session->expects($this->once())
+ ->method('getInfo')
+ ->willReturn(['uid' => 42, 'context' => null, 'checksum' => 'abc']);
+
+ $this->storage->expects($this->once())->method('fetchUserById')
+ ->with(42)
+ ->willReturn($user);
+ $this->storage->expects($this->never())->method('fetchContext');
+ //
+
+ $newAuthz = $this->expectInitAuthz($user, null);
+
+ $this->service->initialize();
+
+ $this->assertSame($newAuthz, $this->service->authz());
+ }
+
+ public function testInitializeWithUserAndContext()
{
- $this->auth = $this->getMockForAbstractClass(Auth::class);
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+ $context = $this->createConfiguredMock(Context::class, ['getAuthId' => 'foo']);
+
+ //
+ $this->session->expects($this->once())
+ ->method('getInfo')
+ ->willReturn(['uid' => 42, 'context' => 'foo', 'checksum' => 'abc']);
+
+ $this->storage->expects($this->once())->method('fetchUserById')
+ ->with(42)
+ ->willReturn($user);
+ $this->storage->expects($this->once())->method('fetchContext')
+ ->with('foo')
+ ->willReturn($context);
+ //
+
+ $newAuthz = $this->expectInitAuthz($user, $context);
+
+ $this->service->initialize();
+
+ $this->assertSame($newAuthz, $this->service->authz());
+
+ return $this->service;
}
-
-
- public function testHashPassword()
+
+ public function testInitializeWithInvalidAuthChecksum()
{
- $hash = $this->auth->hashPassword('abc');
- $this->assertTrue(password_verify('abc', $hash));
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'xyz']);
+
+ //
+ $this->session->expects($this->once())
+ ->method('getInfo')
+ ->willReturn(['uid' => 42, 'context' => null, 'checksum' => 'abc']);
+
+ $this->storage->expects($this->once())->method('fetchUserById')
+ ->with(42)
+ ->willReturn($user);
+ $this->storage->expects($this->never())->method('fetchContext');
+ //
+
+ $newAuthz = $this->expectInitAuthz(null, null);
+
+ $this->service->initialize();
+
+ $this->assertSame($newAuthz, $this->service->authz());
}
-
- public function invalidPasswordProvider()
+
+ public function initalizedMethodProvider()
{
return [
- [''],
- [array()],
- [123]
+ 'is(...)' => ['is', 'foo'],
+ 'user()' => ['user'],
+ 'context()' => ['context'],
];
}
-
+
+ /**
+ * @dataProvider initalizedMethodProvider
+ */
+ public function testAssertInitialized(string $method, ...$args)
+ {
+ $this->expectException(\LogicException::class);
+ $this->service->{$method}(...$args);
+ }
+
+
+ public function testGetAvailableRoles()
+ {
+ $this->authz->expects($this->once())->method('getAvailableRoles')
+ ->willReturn(['user', 'manager', 'admin']);
+
+ $this->assertEquals(['user', 'manager', 'admin'], $this->service->getAvailableRoles());
+ }
+
+ public function testIs()
+ {
+ $this->authz->expects($this->once())->method('is')
+ ->with('foo')
+ ->willReturn(true);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+
+ $this->assertTrue($this->service->is('foo'));
+ }
+
+ public function testUser()
+ {
+ $user = $this->createMock(User::class);
+ $this->authz->expects($this->once())->method('user')->willReturn($user);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+
+ $this->assertSame($user, $this->service->user());
+ }
+
+ public function testContext()
+ {
+ $context = $this->createMock(Context::class);
+ $this->authz->expects($this->once())->method('context')->willReturn($context);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+
+ $this->assertSame($context, $this->service->context());
+ }
+
+
/**
- * @dataProvider invalidPasswordProvider
- * @expectedException \InvalidArgumentException
+ * @return Authz&MockObject
*/
- public function testHashPasswordWithInvalidArgument($password)
- {
- $this->auth->hashPassword($password);
- }
-
-
- public function testVerifyCredentials()
- {
- $hash = password_hash('abc', PASSWORD_BCRYPT);
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getHashedPassword')->willReturn($hash);
-
- $this->assertTrue($this->auth->verifyCredentials($user, 'abc'));
-
- $this->assertFalse($this->auth->verifyCredentials($user, 'god'));
- $this->assertFalse($this->auth->verifyCredentials($user, ''));
- }
-
-
- public function testUserWithoutSessionUser()
- {
- $user = $this->createMock(Auth\User::class);
- $user->expects($this->never())->method('onLogin');
-
- $this->auth->expects($this->once())->method('getCurrentUserId')->willReturn(123);
- $this->auth->expects($this->once())->method('fetchUserById')->with(123)->willReturn($user);
-
- $this->assertSame($user, $this->auth->user());
- }
-
- public function testUserWithSessionUser()
- {
- $this->assertNull($this->auth->user());
- }
-
-
+ protected function expectSetAuthzUser(?User $user, ?Context $context = null)
+ {
+ $newAuthz = $this->createMock(Authz::class);
+
+ $this->authz->expects($this->once())->method('forUser')
+ ->with($this->identicalTo($user))
+ ->willReturn($newAuthz);
+
+ $newAuthz->expects($this->any())->method('user')
+ ->willReturn($user);
+ $newAuthz->expects($this->any())->method('context')
+ ->willReturn($context);
+
+ return $newAuthz;
+ }
+
/**
- * @return Auth|MockObject
+ * @return Authz&MockObject
*/
- public function testSetUser()
- {
- $user = $this->createMock(Auth\User::class);
-
- $this->auth->expects($this->once())->method('persistCurrentUser');
-
- $result = $this->auth->setUser($user);
-
- $this->assertSame($user, $result);
- $this->assertSame($user, $this->auth->user());
-
- return $this->auth;
- }
-
- public function testSetUserWithExistingUser()
- {
- $this->auth->expects($this->exactly(2))->method('persistCurrentUser');
-
- $this->auth->setUser($this->createMock(Auth\User::class));
-
- $user = $this->createMock(Auth\User::class);
- $user->expects($this->once())->method('onLogin');
-
- $result = $this->auth->setUser($user);
-
- $this->assertSame($user, $result);
- $this->assertSame($user, $this->auth->user());
- }
-
- public function testSetUserWithOnLoginFail()
- {
- $this->auth->expects($this->once())->method('persistCurrentUser');
-
- $oldUser = $this->createMock(Auth\User::class);
- $this->auth->setUser($oldUser);
-
- $user = $this->createMock(Auth\User::class);
- $user->expects($this->once())->method('onLogin')->willReturn(false);
-
- $this->auth->expects($this->never())->method('persistCurrentUser');
-
- $result = $this->auth->setUser($user);
-
- $this->assertNull($result);
- $this->assertSame($oldUser, $this->auth->user());
- }
-
-
+ protected function expectSetAuthzContext(?User $user, ?Context $context)
+ {
+ $newAuthz = $this->createMock(Authz::class);
+
+ $this->authz->expects($this->once())->method('inContextOf')
+ ->with($this->identicalTo($context))
+ ->willReturn($newAuthz);
+
+ $newAuthz->expects($this->any())->method('user')
+ ->willReturn($user);
+ $newAuthz->expects($this->any())->method('context')
+ ->willReturn($context);
+
+ return $newAuthz;
+ }
+
+ public function testLoginAs()
+ {
+ //
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+
+ $this->dispatcher->expects($this->once())->method('dispatch')
+ ->with($this->callback(function ($event) use ($user) {
+ $this->assertInstanceOf(Event\Login::class, $event);
+
+ /** @var Event\Login $event */
+ $this->assertSame($user, $event->user());
+ return true;
+ }))
+ ->willReturnArgument(0);
+
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->expectSetAuthzUser($user);
+
+ $this->session->expects($this->once())->method('persist')
+ ->with(42, null, 'abc');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->loginAs($user);
+ }
+
+ public function testCancelLogin()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+
+ $this->dispatcher->expects($this->once())->method('dispatch')
+ ->with($this->callback(function (Event\Login $event) {
+ $event->cancel('no good');
+ return true;
+ }))
+ ->willReturnArgument(0);
+
+ //
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->authz->expects($this->never())->method('forUser');
+ $this->authz->expects($this->never())->method('inContextOf');
+ $this->session->expects($this->never())->method('persist');
+
+ $this->expectException(LoginException::class);
+ $this->expectExceptionMessage('no good');
+ $this->expectExceptionCode(LoginException::CANCELLED);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->loginAs($user);
+ }
+
+ public function testLoginAsTwice()
+ {
+ $user = $this->createMock(User::class);
+
+ //
+ $this->authz->expects($this->any())->method('user')->willReturn($user);
+
+ $this->expectException(\LogicException::class);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->loginAs($user);
+ }
+
public function testLogin()
{
- $hash = password_hash('abc', PASSWORD_BCRYPT);
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getHashedPassword')->willReturn($hash);
- $user->expects($this->once())->method('onLogin');
-
- $this->auth->expects($this->once())->method('fetchUserByUsername')->with('john')->willReturn($user);
- $this->auth->expects($this->once())->method('persistCurrentUser');
-
- $result = $this->auth->login('john', 'abc');
-
- $this->assertSame($user, $result);
- $this->assertSame($user, $this->auth->user());
- }
-
- public function testLoginWithIncorrectPassword()
- {
- $hash = password_hash('abc', PASSWORD_BCRYPT);
-
- $user = $this->createMock(Auth\User::class);
- $user->method('getHashedPassword')->willReturn($hash);
- $user->expects($this->never())->method('onLogin');
-
- $this->auth->expects($this->once())->method('fetchUserByUsername')->with('john')->willReturn($user);
- $this->auth->expects($this->never())->method('persistCurrentUser');
-
- $result = $this->auth->login('john', 'god');
-
- $this->assertNull($result);
- $this->assertNull($this->auth->user());
- }
-
+ $user = $this->createMock(User::class);
+ $user->expects($this->once())->method('verifyPassword')
+ ->with('pwd')
+ ->willReturn(true);
+
+ $this->storage->expects($this->once())->method('fetchUserByUsername')
+ ->with('john')
+ ->willReturn($user);
+
+ //
+ $user->expects($this->any())->method('getAuthId')->willReturn(42);
+ $user->expects($this->any())->method('getAuthChecksum')->willReturn('xyz');
+
+ $this->dispatcher->expects($this->once())->method('dispatch')
+ ->with($this->callback(function ($event) use ($user) {
+ $this->assertInstanceOf(Event\Login::class, $event);
+
+ /** @var Event\Login $event */
+ $this->assertSame($user, $event->user());
+ return true;
+ }))
+ ->willReturnArgument(0);
+
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->expectSetAuthzUser($user);
+
+ $this->session->expects($this->once())->method('persist')
+ ->with(42, null, 'xyz');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->login('john', 'pwd');
+ }
+
+ public function testLoginWithIncorrectUsername()
+ {
+ $this->storage->expects($this->once())->method('fetchUserByUsername')
+ ->with('john')
+ ->willReturn(null);
+
+ //
+ $this->dispatcher->expects($this->never())->method('dispatch');
+
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->authz->expects($this->never())->method('forUser');
+ $this->authz->expects($this->never())->method('inContextOf');
+ $this->session->expects($this->never())->method('persist');
+
+ $this->expectException(LoginException::class);
+ $this->expectExceptionMessage('Invalid credentials');
+ $this->expectExceptionCode(LoginException::INVALID_CREDENTIALS);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->login('john', 'pwd');
+ }
+
+ public function testLoginWithInvalidPassword()
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->once())->method('verifyPassword')
+ ->with('pwd')
+ ->willReturn(false);
+
+ //
+ $user->expects($this->any())->method('getAuthId')->willReturn(42);
+ $user->expects($this->any())->method('getAuthChecksum')->willReturn('abc');
+
+ $this->storage->expects($this->once())->method('fetchUserByUsername')
+ ->with('john')
+ ->willReturn($user);
+
+ $this->dispatcher->expects($this->never())->method('dispatch');
+
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->authz->expects($this->never())->method('forUser');
+ $this->authz->expects($this->never())->method('inContextOf');
+ $this->session->expects($this->never())->method('persist');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->expectException(LoginException::class);
+ $this->expectExceptionMessage('Invalid credentials');
+ $this->expectExceptionCode(LoginException::INVALID_CREDENTIALS);
+
+ $this->service->login('john', 'pwd');
+ }
+
+ public function testLoginTwice()
+ {
+ //
+ $user = $this->createMock(User::class);
+ $this->authz->expects($this->any())->method('user')->willReturn($user);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->expectException(\LogicException::class);
+
+ $this->service->login('john', 'pwd');
+ }
+
public function testLogout()
{
- $user = $this->createMock(Auth\User::class);
- $user->expects($this->once())->method('onLogout');
-
- $this->auth->setUser($user);
-
- $this->auth->logout();
-
- $this->assertNull($this->auth->user());
-
- // Logout again shouldn't really do anything
- $this->auth->logout();
- }
-
-
- public function testAsMiddleware()
- {
- $callback = $this->createCallbackMock($this->never());
- $middleware = $this->auth->asMiddleware($callback);
-
- $this->assertInstanceOf(Auth\Middleware::class, $middleware);
- $this->assertAttributeEquals($this->auth, 'auth', $middleware);
- $this->assertAttributeEquals($callback, 'getRequiredRole', $middleware);
+ //
+ $user = $this->createMock(User::class);
+
+ $this->dispatcher->expects($this->once())->method('dispatch')
+ ->with($this->callback(function ($event) use ($user) {
+ $this->assertInstanceOf(Event\Logout::class, $event);
+
+ /** @var Event\Login $event */
+ $this->assertSame($user, $event->user());
+ return true;
+ }))
+ ->willReturnArgument(0);
+
+ $this->authz->expects($this->any())->method('user')->willReturn($user);
+ $this->session->expects($this->once())->method('clear');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $newAuthz = $this->expectInitAuthz(null, null);
+
+ $this->service->logout();
+
+ $this->assertSame($newAuthz, $this->service->authz());
+ }
+
+ public function testLogoutTwice()
+ {
+ $this->setPrivateProperty($this->service, 'initialized', true);
+
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->authz->expects($this->never())->method('forUser');
+ $this->authz->expects($this->never())->method('inContextOf');
+ $this->session->expects($this->never())->method('persist');
+
+ $this->service->logout();
+ }
+
+ public function testSetContext()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+ $context = $this->createConfiguredMock(Context::class, ['getAuthId' => 'foo']);
+
+ //
+ $this->authz->expects($this->any())->method('user')->willReturn($user);
+
+ $this->session->expects($this->once())->method('persist')
+ ->with(42, 'foo', 'abc');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $newAuthz = $this->expectSetAuthzContext($user, $context);
+
+ $this->service->setContext($context);
+
+ $this->assertSame($newAuthz, $this->service->authz());
+ }
+
+ public function testClearContext()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+
+ //
+ $this->authz->expects($this->any())->method('user')->willReturn($user);
+
+ $this->session->expects($this->once())->method('persist')
+ ->with(42, null, 'abc');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $newAuthz = $this->expectSetAuthzContext($user, null);
+
+ $this->service->setContext(null);
+
+ $this->assertSame($newAuthz, $this->service->authz());
+ }
+
+ public function testRecalc()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']);
+ $context = $this->createConfiguredMock(Context::class, ['getAuthId' => 'foo']);
+
+ $newAuthz = $this->createMock(Authz::class);
+ $this->authz->expects($this->once())->method('recalc')->willReturn($newAuthz);
+
+ //
+ $this->authz->expects($this->never())->method('user');
+ $this->authz->expects($this->never())->method('context');
+
+ $newAuthz->expects($this->any())->method('user')->willReturn($user);
+ $newAuthz->expects($this->any())->method('context')->willReturn($context);
+
+ $this->session->expects($this->never())->method('clear');
+ $this->session->expects($this->once())->method('persist')
+ ->with(42, 'foo', 'abc');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->recalc();
+ }
+
+ public function testRecalcWithoutUser()
+ {
+ //
+ $this->authz->expects($this->once())->method('recalc')->willReturnSelf();
+ $this->authz->expects($this->any())->method('user')->willReturn(null);
+ $this->authz->expects($this->any())->method('context')->willReturn(null);
+
+ $this->session->expects($this->once())->method('clear');
+ $this->session->expects($this->never())->method('persist');
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+ //
+
+ $this->service->recalc();
+ }
+
+
+ public function testForUser()
+ {
+ $user = $this->createMock(User::class);
+ $newAuthz = $this->createMock(Authz::class);
+
+ $this->authz->expects($this->once())->method('forUser')
+ ->with($user)
+ ->willReturn($newAuthz);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+
+ $this->assertSame($newAuthz, $this->service->forUser($user));
+ $this->assertSame($this->authz, $this->service->authz()); // Not modified
+ }
+
+ public function testForContext()
+ {
+ $context = $this->createMock(Context::class);
+ $newAuthz = $this->createMock(Authz::class);
+
+ $this->authz->expects($this->once())->method('inContextOf')
+ ->with($context)
+ ->willReturn($newAuthz);
+
+ $this->setPrivateProperty($this->service, 'initialized', true);
+
+ $this->assertSame($newAuthz, $this->service->inContextOf($context));
+ $this->assertSame($this->authz, $this->service->authz()); // Not modified
+ }
+
+
+ public function testConfirm()
+ {
+ $newConfirmation = $this->createMock(Confirmation::class);
+
+ $this->confirmation->expects($this->once())->method('withStorage')
+ ->with($this->identicalTo($this->storage))
+ ->willReturnSelf();
+
+ $this->confirmation->expects($this->once())->method('withSubject')
+ ->with('foo bar')
+ ->willReturn($newConfirmation);
+
+ $this->assertSame($newConfirmation, $this->service->confirm('foo bar'));
}
}
diff --git a/tests/Authz/ByGroupTest.php b/tests/Authz/ByGroupTest.php
deleted file mode 100644
index 5aee83e..0000000
--- a/tests/Authz/ByGroupTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
-auth = $this->getMockForTrait(Authz\ByGroup::class);
-
- $this->auth->method('getGroupStructure')->willReturn([
- 'user' => [],
- 'client' => ['user'],
- 'mod' => ['user'],
- 'dev' => ['user'],
- 'admin' => ['mod', 'dev']
- ]);
- }
-
- public function testGetRoles()
- {
- $this->assertEquals(['user', 'client', 'mod', 'dev', 'admin'], $this->auth->getRoles());
- }
-
- /**
- * @expectedException \UnexpectedValueException
- */
- public function testGetRolesWithInvalidStructure()
- {
- $this->auth = $this->getMockForTrait(Authz\ByGroup::class);
- $this->auth->method('getGroupStructure')->willReturn('foo bar');
-
- $this->auth->getRoles();
- }
-
-
- public function testIsWithoutUser()
- {
- $this->assertFalse($this->auth->is('user'));
- }
-
- public function roleProvider()
- {
- return [
- ['user', ['user' => true, 'client' => false, 'mod' => false, 'dev' => false, 'admin' => false]],
- ['client', ['user' => true, 'client' => true, 'mod' => false, 'dev' => false, 'admin' => false]],
- ['admin', ['user' => true, 'client' => false, 'mod' => true, 'dev' => true, 'admin' => true]],
- [['mod', 'client'], ['user' => true, 'client' => true, 'mod' => true, 'dev' => false, 'admin' => false]],
- [['user', 'foo'], ['user' => true, 'client' => false, 'mod' => false, 'dev' => false, 'admin' => false]],
- ];
- }
-
- /**
- * @dataProvider roleProvider
- *
- * @param string|array $role
- * @param array $expect
- */
- public function testIsWithUser($role, array $expect)
- {
- $user = $this->createMock(User::class);
- $user->method('getRole')->willReturn($role);
-
- $this->auth->method('user')->willReturn($user);
-
- $this->assertSame($expect['user'], $this->auth->is('user'));
- $this->assertSame($expect['client'], $this->auth->is('client'));
- $this->assertSame($expect['mod'], $this->auth->is('mod'));
- $this->assertSame($expect['dev'], $this->auth->is('dev'));
- $this->assertSame($expect['admin'], $this->auth->is('admin'));
- }
-
- public function testIsWithUnknownRole()
- {
- $this->assertFalse(@$this->auth->is('foo'));
- $this->assertLastError(E_USER_NOTICE, "Unknown role 'foo'");
- }
-}
diff --git a/tests/Authz/ByLevelTest.php b/tests/Authz/ByLevelTest.php
deleted file mode 100644
index c85464e..0000000
--- a/tests/Authz/ByLevelTest.php
+++ /dev/null
@@ -1,142 +0,0 @@
-auth = $this->getMockForTrait(Authz\ByLevel::class);
-
- $this->auth->method('getAccessLevels')->willReturn([
- 'user' => 1,
- 'mod' => 10,
- 'admin' => 100
- ]);
- }
-
- public function testGetLevelWithRole()
- {
- $this->assertSame(1, $this->auth->getLevel('user'));
- $this->assertSame(10, $this->auth->getLevel('mod'));
- $this->assertSame(100, $this->auth->getLevel('admin'));
- }
-
- /**
- * @expectedException \DomainException
- */
- public function testGetLevelWithUnknownRole()
- {
- $this->assertSame(1, $this->auth->getLevel('foo'));
- }
-
- /**
- * @expectedException \InvalidArgumentException
- */
- public function testGetLevelWithInvalidValue()
- {
- $this->assertSame(1, $this->auth->getLevel(['foo']));
- }
-
- public function testGetLevelWithLevel()
- {
- $this->assertSame(100, $this->auth->getLevel(100));
- $this->assertSame(100, $this->auth->getLevel('100'));
- }
-
-
- public function testGetRoles()
- {
- $this->assertEquals(['user', 'mod', 'admin'], $this->auth->getRoles());
- }
-
- /**
- * @expectedException \UnexpectedValueException
- */
- public function testGetRolesWithInvalidStructure()
- {
- $this->auth = $this->getMockForTrait(Authz\ByLevel::class);
- $this->auth->method('getAccessLevels')->willReturn('foo bar');
-
- $this->auth->getRoles();
- }
-
-
- public function testIsWithoutUser()
- {
- $this->assertFalse($this->auth->is('user'));
- }
-
- public function roleProvider()
- {
- return [
- ['user', ['user' => true, 'mod' => false, 'admin' => false]],
- ['mod', ['user' => true, 'mod' => true, 'admin' => false]],
- ['admin', ['user' => true, 'mod' => true, 'admin' => true]],
- [50, ['user' => true, 'mod' => true, 'admin' => false]],
- [500, ['user' => true, 'mod' => true, 'admin' => true]]
- ];
- }
-
- /**
- * @dataProvider roleProvider
- *
- * @param string|array $role
- * @param array $expect
- */
- public function testIsWithUser($role, array $expect)
- {
- $user = $this->createMock(User::class);
- $user->method('getRole')->willReturn($role);
-
- $this->auth->method('user')->willReturn($user);
-
- $this->assertSame($expect['user'], $this->auth->is('user'));
- $this->assertSame($expect['mod'], $this->auth->is('mod'));
- $this->assertSame($expect['admin'], $this->auth->is('admin'));
- }
-
- public function testIsWithUnknownRole()
- {
- $this->assertFalse(@$this->auth->is('foo'));
- $this->assertLastError(E_USER_NOTICE, "Unknown role 'foo'");
- }
-
- public function testIsWithUnknownUserRole()
- {
- $user = $this->createMock(User::class);
- $user->method('getRole')->willReturn('foo');
-
- $this->auth->method('user')->willReturn($user);
-
- $this->assertFalse(@$this->auth->is('user'));
- $this->assertLastError(E_USER_NOTICE, "Unknown user role 'foo'");
- }
-
- public function testIsWithInvalidRole()
- {
- $user = $this->createMock(User::class);
- $user->method('getRole')->willReturn(['user']);
-
- $this->auth->method('user')->willReturn($user);
-
- $this->assertFalse(@$this->auth->is('user'));
- $this->assertLastError(E_USER_WARNING, "Expected role to be a string, not a array");
- }
-}
diff --git a/tests/Authz/GroupsTest.php b/tests/Authz/GroupsTest.php
new file mode 100644
index 0000000..ff3f88d
--- /dev/null
+++ b/tests/Authz/GroupsTest.php
@@ -0,0 +1,179 @@
+authz = new Groups([
+ 'user' => [],
+ 'client' => ['user'],
+ 'mod' => ['user'],
+ 'dev' => ['user'],
+ 'admin' => ['mod', 'dev']
+ ]);
+ }
+
+ public function testAvailableGetRoles()
+ {
+ $this->assertEquals(
+ ['user', 'client', 'mod', 'dev', 'admin'],
+ $this->authz->getAvailableRoles()
+ );
+ }
+
+ public function roleProvider()
+ {
+ return [
+ 'user' => [
+ 'user',
+ ['user' => true, 'client' => false, 'mod' => false, 'dev' => false, 'admin' => false],
+ ],
+ 'client' => [
+ 'client',
+ ['user' => true, 'client' => true, 'mod' => false, 'dev' => false, 'admin' => false],
+ ],
+ 'admin' => [
+ 'admin',
+ ['user' => true, 'client' => false, 'mod' => true, 'dev' => true, 'admin' => true],
+ ],
+ 'mod+client' => [
+ ['mod', 'client'],
+ ['user' => true, 'client' => true, 'mod' => true, 'dev' => false, 'admin' => false],
+ ],
+ 'user+foo' => [
+ ['user', 'foo'],
+ ['user' => true, 'client' => false, 'mod' => false, 'dev' => false, 'admin' => false],
+ ],
+ ];
+ }
+
+ public function testIsWithoutUser()
+ {
+ $this->assertFalse($this->authz->is('user'));
+ }
+
+ /**
+ * @dataProvider roleProvider
+ *
+ * @param string|array $role
+ * @param array $expect
+ */
+ public function testIsWithUser($role, array $expect)
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->any())->method('getAuthRole')->willReturn($role);
+
+ $this->authz = $this->authz->forUser($user);
+
+ $this->assertEquals($expect['user'], $this->authz->is('user'));
+ $this->assertEquals($expect['client'], $this->authz->is('client'));
+ $this->assertEquals($expect['mod'], $this->authz->is('mod'));
+ $this->assertEquals($expect['dev'], $this->authz->is('dev'));
+ $this->assertEquals($expect['admin'], $this->authz->is('admin'));
+ }
+
+
+ public function testIsWithUnknownRole()
+ {
+ $this->expectWarningMessage("Unknown authz role 'foo'");
+ $this->assertFalse($this->authz->is('foo'));
+ }
+
+ public function testIsWithUnknownUserRole()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthRole' => 'foo', 'getAuthId' => 42]);
+ $this->authz = $this->authz->forUser($user);
+
+ $this->assertFalse($this->authz->is('user'));
+ }
+
+
+ public function testRecalc()
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->exactly(2))->method('getAuthRole')
+ ->willReturnOnConsecutiveCalls('client', 'admin');
+
+ $this->authz = $this->authz->forUser($user);
+
+ $this->assertTrue($this->authz->is('client'));
+ $this->assertFalse($this->authz->is('dev'));
+
+ // $user->role = 'admin';
+ $updatedAuthz = $this->authz->recalc();
+
+ $this->assertFalse($this->authz->is('dev'));
+ $this->assertTrue($updatedAuthz->is('dev')); // admin supersedes dev
+ }
+
+ public function testRecalcWithoutAnyChange()
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->exactly(2))->method('getAuthRole')
+ ->willReturnOnConsecutiveCalls('client', 'client');
+
+ $this->authz = $this->authz->forUser($user);
+ $updatedAuthz = $this->authz->recalc();
+
+ $this->assertSame($this->authz, $updatedAuthz);
+ }
+
+ public function testRecalcWithoutUser()
+ {
+ $this->authz = $this->authz->forUser(null);
+ $updatedAuthz = $this->authz->recalc();
+
+ $this->assertSame($this->authz, $updatedAuthz);
+ }
+
+
+ public function crossReferenceProvider()
+ {
+ return [
+ 'client' => ['client'],
+ 'customer' => ['customer'],
+ 'king' => ['king'],
+ ];
+ }
+
+ /**
+ * @dataProvider crossReferenceProvider
+ */
+ public function testCrossReference(string $role)
+ {
+ $this->authz = new Groups([
+ 'user' => [],
+ 'client' => ['user', 'customer'],
+ 'customer' => ['client', 'king'],
+ 'king' => ['customer'],
+ ]);
+
+ $user = $this->createConfiguredMock(User::class, ['getAuthRole' => $role]);
+
+ $this->assertTrue($this->authz->forUser($user)->is('user'));
+ $this->assertTrue($this->authz->forUser($user)->is('client'));
+ $this->assertTrue($this->authz->forUser($user)->is('customer'));
+ $this->assertTrue($this->authz->forUser($user)->is('king'));
+ }
+}
diff --git a/tests/Authz/LevelsTest.php b/tests/Authz/LevelsTest.php
new file mode 100644
index 0000000..9d2c02d
--- /dev/null
+++ b/tests/Authz/LevelsTest.php
@@ -0,0 +1,166 @@
+authz = new Levels([
+ 'user' => 1,
+ 'mod' => 10,
+ 'admin' => 100
+ ]);
+ }
+
+ public function testGetAvailableRoles()
+ {
+ $this->assertEquals(['user', 'mod', 'admin'], $this->authz->getAvailableRoles());
+ }
+
+
+ public function testUser()
+ {
+ $this->assertNull($this->authz->user());
+
+ $user = $this->createConfiguredMock(User::class, ['getAuthRole' => 'user']);
+ $userAuthz = $this->authz->forUser($user);
+
+ $this->assertNotSame($this->authz, $userAuthz);
+ $this->assertNull($this->authz->user());
+ $this->assertSame($user, $userAuthz->user());
+ }
+
+ public function testWithUnknownUserRole()
+ {
+ $user = $this->createConfiguredMock(User::class, ['getAuthRole' => 'foo', 'getAuthId' => 42]);
+
+ $this->expectException(\DomainException::class);
+ $this->expectExceptionMessage("Authorization level 'foo' isn't defined (uid:42)");
+
+ $this->authz = $this->authz->forUser($user);
+ }
+
+ public function testWithInvalidUserRole()
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->any())->method('getAuthRole')->willReturn(['user', 'mod']);
+
+ $this->expectException(\UnexpectedValueException::class);
+
+ $this->authz = $this->authz->forUser($user);
+ }
+
+ public function testContext()
+ {
+ $this->assertNull($this->authz->context());
+
+ $context = $this->createMock(Context::class);
+ $contextAuthz = $this->authz->inContextOf($context);
+
+ $this->assertNotSame($this->authz, $contextAuthz);
+ $this->assertNull($this->authz->context());
+ $this->assertSame($context, $contextAuthz->context());
+ }
+
+
+ public function testIsWithoutUser()
+ {
+ $this->assertFalse($this->authz->is('user'));
+ }
+
+ public function roleProvider()
+ {
+ return [
+ ['user', ['user' => true, 'mod' => false, 'admin' => false]],
+ ['mod', ['user' => true, 'mod' => true, 'admin' => false]],
+ ['admin', ['user' => true, 'mod' => true, 'admin' => true]],
+
+ [1, ['user' => true, 'mod' => false, 'admin' => false]],
+ [10, ['user' => true, 'mod' => true, 'admin' => false]],
+ [50, ['user' => true, 'mod' => true, 'admin' => false]],
+ [500, ['user' => true, 'mod' => true, 'admin' => true]]
+ ];
+ }
+
+ /**
+ * @dataProvider roleProvider
+ *
+ * @param string|array $role
+ * @param array $expect
+ */
+ public function testIsWithUser($role, array $expect)
+ {
+ $user = $this->createMock(User::class);
+ $user->method('getAuthRole')->willReturn($role);
+
+ $this->authz = $this->authz->forUser($user);
+
+ $this->assertSame($expect['user'], $this->authz->is('user'));
+ $this->assertSame($expect['mod'], $this->authz->is('mod'));
+ $this->assertSame($expect['admin'], $this->authz->is('admin'));
+ }
+
+ public function testIsWithUnknownRole()
+ {
+ $this->expectWarningMessage("Unknown authz role 'foo'");
+ $this->assertFalse($this->authz->is('foo'));
+ }
+
+
+ public function testRecalc()
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->exactly(2))->method('getAuthRole')
+ ->willReturnOnConsecutiveCalls('user', 'admin');
+
+ $this->authz = $this->authz->forUser($user);
+
+ $this->assertTrue($this->authz->is('user'));
+ $this->assertFalse($this->authz->is('mod'));
+
+ // $user->role = 'admin';
+ $updatedAuthz = $this->authz->recalc();
+
+ $this->assertFalse($this->authz->is('mod'));
+ $this->assertTrue($updatedAuthz->is('mod')); // admin supersedes dev
+ }
+
+ public function testRecalcWithoutAnyChange()
+ {
+ $user = $this->createMock(User::class);
+ $user->expects($this->exactly(2))->method('getAuthRole')
+ ->willReturnOnConsecutiveCalls('user', 'user');
+
+ $this->authz = $this->authz->forUser($user);
+ $updatedAuthz = $this->authz->recalc();
+
+ $this->assertSame($this->authz, $updatedAuthz);
+ }
+
+ public function testRecalcWithoutUser()
+ {
+ $this->authz = $this->authz->forUser(null);
+ $updatedAuthz = $this->authz->recalc();
+
+ $this->assertSame($this->authz, $updatedAuthz);
+ }
+}
diff --git a/tests/Confirmation/HashidsConfirmationTest.php b/tests/Confirmation/HashidsConfirmationTest.php
new file mode 100644
index 0000000..85fc754
--- /dev/null
+++ b/tests/Confirmation/HashidsConfirmationTest.php
@@ -0,0 +1,259 @@
+user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'xyz']);
+ }
+
+ public function tearDown(): void
+ {
+ CarbonImmutable::setTestNow(null);
+ }
+
+ public function testGetToken()
+ {
+ $storage = $this->createMock(Storage::class);
+ $storage->expects($this->never())->method($this->anything());
+
+ /** @var Hashids&MockObject $hashids */
+ $hashids = $this->createMock(Hashids::class);
+ $hashids->expects($this->once())->method('encodeHex')
+ ->with(self::STD_HEX)
+ ->willReturn('the_token');
+
+ $confirm = (new HashidsConfirmation('secret', fn() => $hashids))
+ ->withStorage($storage)
+ ->withSubject('test');
+
+ $token = $confirm->getToken($this->user, new \DateTime('2020-01-01T12:00:00+00:00'));
+
+ $this->assertEquals('the_token', $token);
+ }
+
+
+ protected function createService(string $hex, ?User $user = null): HashidsConfirmation
+ {
+ $storage = $this->createMock(Storage::class);
+
+ if (func_num_args() > 1) {
+ $storage->expects($this->once())->method('fetchUserById')
+ ->with($user !== null ? $user->getAuthId() : 42)
+ ->willReturn($user);
+ } else {
+ $storage->expects($this->never())->method('fetchUserById');
+ }
+
+ /** @var Hashids&MockObject $hashids */
+ $hashids = $this->createMock(Hashids::class);
+ $hashids->expects($this->never())->method('encodeHex');
+ $hashids->expects($this->once())->method('decodeHex')
+ ->with('the_token')
+ ->willReturn($hex);
+
+ return (new HashidsConfirmation('secret', fn() => $hashids))
+ ->withStorage($storage)
+ ->withSubject('test');
+ }
+
+ public function testFrom()
+ {
+ $confirm = $this->createService(self::STD_HEX, $this->user);
+
+ $this->assertSame($this->user, $confirm->from('the_token'));
+ }
+
+
+ public function testFromUserWithStringId()
+ {
+ $hex = '8bbf6e9d7540db35392c348f3effa2ca5687afc9daff2d68747941c077ac2c4120200101120000017a3031';
+ $user = $this->createConfiguredMock(User::class, ['getAuthId' => 'z01', 'getAuthChecksum' => 'xyz']);
+
+ $confirm = $this->createService($hex, $user);
+
+ $this->assertSame($user, $confirm->from('the_token'));
+ }
+
+ public function testFromDeletedUser()
+ {
+ $confirm = $this->createService(self::STD_HEX, null);
+
+ $this->expectExceptionObject(new InvalidTokenException("User '42' doesn't exist"));
+ $confirm->from('the_token');
+ }
+
+ public function testFromInvalidChecksum()
+ {
+ $hex = hash('sha256', '') . '20200101000000' . '002a';
+
+ $confirm = $this->createService($hex, $this->user);
+
+ $this->expectExceptionObject(new InvalidTokenException("Checksum doesn't match"));
+ $confirm->from('the_token');
+ }
+
+ public function testFromInvalidToken()
+ {
+ $hex = 'nop';
+
+ $confirm = $this->createService($hex);
+
+ $this->expectExceptionObject(new InvalidTokenException("Invalid confirmation token"));
+ $confirm->from('the_token');
+ }
+
+ public function testFromInvalidUid()
+ {
+ $hex = hash('sha256', '') . '20200101000000' . '992a';
+
+ $confirm = $this->createService($hex);
+
+ $this->expectExceptionObject(new InvalidTokenException("Invalid confirmation token"));
+ $confirm->from('the_token');
+ }
+
+ public function testFromTokenWithInvalidExpireDate()
+ {
+ $hex = hash('sha256', '') . '99999999000000' . '002a';
+
+ $confirm = $this->createService($hex);
+
+ $this->expectExceptionObject(new InvalidTokenException("Invalid confirmation token"));
+ $confirm->from('the_token');
+ }
+
+ public function testFromExpiredToken()
+ {
+ $hex = 'b087edc903ba55d052e51aa2f8a01bc8e68c9503778eedc941e9932b36dd8d09' . '20191101120000' . '002a';
+
+ $confirm = $this->createService($hex);
+
+ $this->expectExceptionObject(new InvalidTokenException("Token is expired"));
+ $confirm->from('the_token');
+ }
+
+ public function testCreateHashIdsWithCallback()
+ {
+ $storage = $this->createMock(Storage::class);
+ $storage->expects($this->never())->method('fetchUserById');
+
+ /** @var Hashids&MockObject $hashids */
+ $hashids = $this->createMock(Hashids::class);
+
+ $salt = hash('sha256', 'testsecret', true);
+ $callback = $this->createCallbackMock($this->once(), [$salt], $hashids);
+
+ $service = (new HashidsConfirmation('secret', $callback))
+ ->withStorage($storage)
+ ->withSubject('test');
+
+ $result = $service->createHashids();
+
+ $this->assertSame($result, $hashids);
+ }
+
+
+ /**
+ * @group hashids
+ */
+ public function testCreateHashIds()
+ {
+ $storage = $this->createMock(Storage::class);
+ $storage->expects($this->never())->method('fetchUserById');
+
+ $service = (new HashidsConfirmation('secret'))
+ ->withStorage($storage)
+ ->withSubject('test');
+
+ $hashids = $service->createHashids();
+ $this->assertInstanceOf(Hashids::class, $hashids);
+
+ $token = $hashids->encodeHex(self::STD_HEX);
+
+ $expectedToken = '6VoyPg4NxJs9VjqQeKRKCV1VyDvYQ7U2bMMygYVxHJge7wVKoGs0JNe6jNwMS6WMA4AmA';
+ $this->assertEquals($expectedToken, $token);
+ }
+
+
+ /**
+ * @group hashids
+ * @coversNothing
+ */
+ public function testGetTokenWithRealHashids()
+ {
+ $storage = $this->createMock(Storage::class);
+ $storage->expects($this->never())->method($this->anything());
+
+ $confirm = (new HashidsConfirmation('secret'))
+ ->withStorage($storage)
+ ->withSubject('test');
+
+ $token = $confirm->getToken($this->user, new \DateTime('2020-01-01T12:00:00+00:00'));
+
+ $expectedToken = '6VoyPg4NxJs9VjqQeKRKCV1VyDvYQ7U2bMMygYVxHJge7wVKoGs0JNe6jNwMS6WMA4AmA';
+ $this->assertEquals($expectedToken, $token);
+ }
+
+ /**
+ * @group hashids
+ * @coversNothing
+ */
+ public function testFromWithRealHashids()
+ {
+ $storage = $this->createMock(Storage::class);
+ $storage->expects($this->once())->method('fetchUserById')
+ ->with(42)
+ ->willReturn($this->user);
+
+ $confirm = (new HashidsConfirmation('secret'))
+ ->withStorage($storage)
+ ->withSubject('test');
+
+ $user = $confirm->from('6VoyPg4NxJs9VjqQeKRKCV1VyDvYQ7U2bMMygYVxHJge7wVKoGs0JNe6jNwMS6WMA4AmA');
+
+ $this->assertSame($this->user, $user);
+ }
+
+ /**
+ * @group hashids
+ * @coversNothing
+ */
+ public function testFromOtherSubjectWithRealHashids()
+ {
+ $storage = $this->createMock(Storage::class);
+ $storage->expects($this->never())->method('fetchUserById');
+
+ $confirm = (new HashidsConfirmation('secret'))
+ ->withStorage($storage)
+ ->withSubject('foo-bar');
+
+ $this->expectException(InvalidTokenException::class);
+
+ $confirm->from('6VoyPg4NxJs9VjqQeKRKCV1VyDvYQ7U2bMMygYVxHJge7wVKoGs0JNe6jNwMS6WMA4AmA');
+ }
+}
diff --git a/tests/Confirmation/NoConfirmationTest.php b/tests/Confirmation/NoConfirmationTest.php
new file mode 100644
index 0000000..3dab1d3
--- /dev/null
+++ b/tests/Confirmation/NoConfirmationTest.php
@@ -0,0 +1,52 @@
+service = new NoConfirmation();
+ }
+
+ public function testWithSubject()
+ {
+ $this->assertSame($this->service, $this->service->withSubject('test'));
+ }
+
+ public function testWithStorage()
+ {
+ /** @var StorageInterface $storage */
+ $storage = $this->createMock(StorageInterface::class);
+
+ $this->assertSame($this->service, $this->service->withStorage($storage));
+ }
+
+ public function testGetToken()
+ {
+ /** @var User&MockObject $user */
+ $user = $this->createMock(User::class);
+
+ $this->expectException(\LogicException::class);
+ $this->service->getToken($user, new \DateTimeImmutable());
+ }
+
+ public function testFrom()
+ {
+ $this->expectException(\LogicException::class);
+ $this->service->from('abc');
+ }
+}
diff --git a/tests/Event/LoginTest.php b/tests/Event/LoginTest.php
new file mode 100644
index 0000000..0f809d7
--- /dev/null
+++ b/tests/Event/LoginTest.php
@@ -0,0 +1,46 @@
+createMock(Auth::class);
+ $user = $this->createMock(User::class);
+
+ $login = new Login($auth, $user);
+
+ $this->assertSame($user, $login->user());
+ }
+
+ public function testCancel()
+ {
+ $auth = $this->createMock(Auth::class);
+ $user = $this->createMock(User::class);
+
+ $login = new Login($auth, $user);
+
+ $this->assertFalse($login->isCancelled());
+ $this->assertFalse($login->isPropagationStopped());
+ $this->assertEquals('', $login->getCancellationReason());
+
+ $login->cancel('not ok');
+
+ $this->assertTrue($login->isCancelled());
+ $this->assertTrue($login->isPropagationStopped());
+ $this->assertEquals('not ok', $login->getCancellationReason());
+ }
+}
diff --git a/tests/Session/PhpSessionsTest.php b/tests/Session/PhpSessionsTest.php
new file mode 100644
index 0000000..3c552b8
--- /dev/null
+++ b/tests/Session/PhpSessionsTest.php
@@ -0,0 +1,52 @@
+session = new \ArrayObject();
+ $this->service = new PhpSession('auth', $this->session);
+ }
+
+ public function testGetInfo()
+ {
+ $data = ['uid' => 'abc', 'context' => 99, 'checksum' => 'xyz'];
+ $this->session['auth'] = $data + ['other' => 'q'];
+
+ $info = $this->service->getInfo();
+ $this->assertEquals($data, $info);
+ }
+
+ public function testGetInfoDefaults()
+ {
+ $info = $this->service->getInfo();
+ $this->assertEquals(['uid' => null, 'context' => null, 'checksum' => null], $info);
+ }
+
+ public function testPersist()
+ {
+ $this->service->persist('abc', 99, 'xyz');
+
+ $this->assertArrayHasKey('auth', $this->session->getArrayCopy());
+ $this->assertEquals($this->session['auth'], ['uid' => 'abc', 'context' => 99, 'checksum' => 'xyz']);
+ }
+
+ public function testClear()
+ {
+ $this->session['auth'] = ['uid' => 'abc', 'context' => 99, 'checksum' => 'xyz'];
+
+ $this->service->clear();
+ $this->assertArrayNotHasKey('auth', $this->session->getArrayCopy());
+ }
+}
diff --git a/tests/support/TestAuth.php b/tests/support/TestAuth.php
deleted file mode 100644
index eadfacc..0000000
--- a/tests/support/TestAuth.php
+++ /dev/null
@@ -1,10 +0,0 @@
-