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 @@ -