diff --git a/docs/README.md b/docs/README.md index 8dc0ec0..8b09c0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,32 +3,851 @@ Authentication and authorization ## Content + - [Setup](#setup) -- [Password encoders](#password-encoders) - - [Sodium](#sodium-encoder) - - [Bcrypt](#bcrypt-encoder) - - [Unsafe MD5](#unsafe-md5-encoder) - - [Backward compatibility](#backward-compatibility---upgrading-encoder) - - [Extending](#extending) - [Authentication](#authentication) - - [Setup](#authentication-setup) - - [Usage](#authentication-usage) + - [Firewall](#firewall) + - [Identity](#identity) + - [Log-in](#log-in) + - [Log-in expiration](#log-in-expiration) + - [Identity refreshing](#identity-refreshing) + - [Log-out](#log-out) + - [Expired logins](#expired-logins) + - [Login storage](#login-storage) - [Authorization](#authorization) - - [Policies](#policies) + - [Setup](#authorization-setup) + - [Roles](#roles) + - [Privileges](#privileges) + - [Privilege hierarchy](#privilege-hierarchy) + - [Policies - customized authorization](#policies---customized-authorization) + - [Policy context](#policy-context) + - [Policy with optional log-in check](#policy-with-optional-log-in-check) + - [Policy with optional requirements](#policy-with-optional-requirements) + - [Policy with no requirements](#policy-with-no-requirements) + - [Policy with default-like privilege check](#policy-with-default-like-privilege-check) + - [Root - bypass all checks](#root---bypass-all-checks) + - [Check authorization of not current user](#check-authorization-of-not-current-user) + - [Decision reason](#decision-reason) +- [Passwords](#passwords) + - [Sodium](#sodium-encoder) + - [Bcrypt](#bcrypt-encoder) + - [Unsafe MD5](#unsafe-md5-encoder) + - [Backward compatibility](#backward-compatibility---upgrading-encoder) + +## Setup + +Install with [Composer](https://getcomposer.org) + +```sh +composer require orisai/auth +``` + +## Authentication + +TODO + +- setup + - firewall + storage + refresher + authorizer + - odkaz na setup authorizeru + - odkaz na setup refresheru + - odkaz na setup storage + - Extend BaseFirewall x use SimpleFirewall + +### Firewall + +TODO + +```php +use Orisai\Auth\Authentication\BaseFirewall; +use Orisai\Auth\Authentication\Exception\NotLoggedIn; + +final class UserAwareFirewall extends BaseFirewall +{ + + public function getUser(): User + { + $identity = $this->fetchIdentity(); + + // Method can't be used for logged-out user + if ($identity === null) { + throw NotLoggedIn::create(static::class, __FUNCTION__); + } + + return $this->userRepository->getByIdChecked($identity->getId()); + } + +} +``` + +### Identity + +Identity is a representation of user through their unique ID and storage for authorization-related data. [Roles](#roles) +and user-specific [privileges](#privileges). + +It is required for [logging into firewall](#log-in) and authorization via [authorizer](#authorization). + +For numeric ID: + +```php +use Orisai\Auth\Authentication\IntIdentity; + +$identity = new IntIdentity(123, ['list', 'of', 'roles']); +``` + +For string ID (UID): + +```php +use Orisai\Auth\Authentication\StringIdentity; + +$identity = new StringIdentity('1fdc5f77-4254-4888-99b2-bce81bb4fa39', ['list', 'of', 'roles']); +``` + +### Log-in + +Log-in user: + +```php +$firewall->login($identity); +``` + +Firewall itself does *no credentials checks*, you are expected to log-in user with an identity you already verified user +has access to. + +After log-in, several methods become accessible: + +```php +$firewall->isLoggedIn() // true +if ($firewall->isLoggedIn()) { + $firewall->getIdentity(); // Identity + + $firewall->getAuthenticationTime(); // Instant + $firewall->getExpirationTime(); // Instant + $firewall->setExpirationTime($instant); // void + + $firewall->refreshIdentity($newIdentity); // void +} +``` + +### Log-in expiration + +Set login to expire after certain amount of time. Expiration is sliding, each request in which firewall is used, +expiration is extended. + +```php +use Brick\DateTime\Instant; + +$firewall->setExpiration(Instant::now()->plusDays(7)); +$firewall->removeExpiration(); +``` + +Firewall uses a `Brick\DateTime\Clock` instance for getting time, you may set custom instance through constructor for +testing expiration with fixed time. + +### Identity refreshing + +Identity is refreshed on each request through `IdentityRefresher` to keep roles and privileges up-to-date. It allows you +to change user permissions immediately. + +```php +use Example\Core\User\UserRepository; +use Orisai\Auth\Authentication\DecisionReason; +use Orisai\Auth\Authentication\Exception\IdentityExpired; +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authentication\IdentityRefresher; +use Orisai\Auth\Authentication\IntIdentity; + +/** + * @phpstan-implements IdentityRefresher + */ +final class AdminIdentityRefresher implements IdentityRefresher +{ + + private UserRepository $userRepository; + + public function __construct(UserRepository $userRepository) + { + $this->userRepository = $userRepository; + } + + public function refresh(Identity $identity): Identity + { + $user = $this->userRepository->getById($identity->getId()); + + // User no longer exists, log them out + if ($user === null) { + throw IdentityExpired::create(); + } + + return new IntIdentity($user->id, $user->roles); + } + +} +``` + +`IdentityExpired` exception accepts parameter with reason why user was logged out. Together with logout code is +accessible through [expired login](#expired-logins): + +```php +use Orisai\Auth\Authentication\DecisionReason; +use Orisai\Auth\Authentication\Exception\IdentityExpired; + +throw IdentityExpired::create(DecisionReason::create('decision reason')); +// or +throw IdentityExpired::create(DecisionReason::createTranslatable( + 'logout.reason.key', + [/* message parameters */], +)); +``` + +Identity can be refreshed also manually on current request. Unlike `$firewall->login()` it keeps the previous +authentication and expiration times. + +```php +use Orisai\Auth\Authentication\IntIdentity; + +$identity = new IntIdentity($user->getId(), $user->getRoles()); +$firewall->refreshIdentity($identity); +``` + +### Log-out + +Manual log-out: + +```php +$firewall->logout(); +``` + +User is automatically logged-out in case their [login expired](#log-in-expiration) +or [identity refresher](#identity-refreshing) invalidated identity. + +Several methods are accessible only for logged-in users and should be preceded by `isLoggedIn()` check: + +```php +$firewall->isLoggedIn() // false +if (!$firewall->isLoggedIn()) { + $firewall->getIdentity(); // exception + + $firewall->getAuthenticationTime(); // exception + $firewall->getExpirationTime(); // exception + $firewall->setExpirationTime($instant); // exception + + $firewall->refreshIdentity($newIdentity); // exception +} +``` + +### Expired logins + +After user is logged out you may still access all data about this login. This way you may e.g. offer user to log back +into their account. + +```php +$expiredLogin = $firewall->getLastExpiredLogin(); + +if ($expiredLogin !== null) { + $identity = $expiredLogin->getIdentity(); // Identity + + $authenticationTime = $expiredLogin->getAuthenticationTime(); // Instant + $expiration = $expiredLogin->getExpiration(); + $expirationTime = $expiration !== null ? $expiration->getTime() : null; // Instant|null + + $logoutCode = $expiredLogin->getLogoutCode(); // Firewall::LOGOUT_* + $logoutReason = $expiredLogin->getLogoutReason(); // DecisionReason|null + + if ($logoutReason !== null) { + $message = $logoutReason->isTranslatable() + ? $translator->translate($logoutReason->getMessage(), $logoutReason->getParameters()) + : $logoutReason->getMessage(); + } +} +``` + +Access all expired logins, ordered from oldest to newest: + +```php +foreach ($firewall->getExpiredLogins() as $identityId => $expiredLogin) { + // ... +} +``` + +Remove all expired logins: + +```php +$firewall->removeExpiredLogins(); +``` + +Remove expired login by ID from `Identity` - for one ID is always stored only the newest: + +```php +$firewall->removeExpiredLogin($identityId); +``` + +Only 3 expired identities are stored by default. These out of limit are removed from the oldest. To change the limit, +call: + +```php +$firewall->setExpiredIdentitiesLimit(0); +``` + +### Login storage + +Information about current login and expired logins has to be stored somewhere. For this purpose you may use two types of +storages - for single request and across requests. + +Single request storage is useful for APIs where user authorizes with each request. For this purpose use: + +- `Orisai\Auth\Authentication\ArrayLoginStorage` + +For standard across requests authentication: + +- `OriNette\Auth\SessionLoginStorage` (from [orisai/nette-auth](https://github.com/orisai/nette-auth) package, uses + session mechanism from [nette/http](https://github.com/nette/http)) + +## Authorization + +TODO + +- Prologue: Check user permissions via privilege system +- Access - default deny, allow by privilege, customize by privilege +- setup - builder, authorizer, data caching +- role privileges, identity privileges +- firewall check, authorizer check - isAllowed(), hasPrivilege() +- identity creator? id, roles, identity privileges and identity class should be same in both login and refresher + +### Authorization setup + +TODO - create authorizer, create authorization data + +Step 1: + +- Create empty authorization data + +```php +use Orisai\Auth\Authorization\AuthorizationData; +use Orisai\Auth\Authorization\AuthorizationDataBuilder; +use Orisai\Auth\Authorization\PrivilegeAuthorizer; +use Orisai\Auth\Authorization\SimplePolicyManager; + +$data = AuthorizationData::createEmpty(); +$policyManager = new SimplePolicyManager(); +$authorizer = new PrivilegeAuthorizer($policyManager, $data); +``` + +Step 2 (optional): + +- Create data builder +- Add privileges and roles +- Assign privileges to roles +- Build the data + +```php +use Orisai\Auth\Authorization\AuthorizationData; +use Orisai\Auth\Authorization\AuthorizationDataBuilder; +use Orisai\Auth\Authorization\Authorizer; + +// Create data builder +$builder = new AuthorizationDataBuilder(); + +// Add privileges +$builder->addPrivilege('article.delete'); +$builder->addPrivilege('article.edit.all'); +$builder->addPrivilege('article.edit.owned'); +$builder->addPrivilege('article.publish'); + +// Add roles +$builder->addRole('editor'); +$builder->addRole('chief-editor'); +$builder->addRole('supervisor'); + +// Allow role to work with specified privileges +$builder->allow('supervisor', Authorizer::ROOT_PRIVILEGE); // Everything +$builder->allow('chief-editor', 'article.edit'); // Edit both owned and all articles +$builder->allow('chief-editor', 'article.publish'); // Publish article +$builder->allow('chief-editor', 'article.delete'); // Delete articles +$builder->allow('editor', 'article.edit.owned'); // Edit owned articles + +// Create data object +$data = $builder->build(); +``` + +Step 3 (optional): + +- Abstract data creation with an object + +```php +use Orisai\Auth\Authorization\AuthorizationData; +use Orisai\Auth\Authorization\AuthorizationDataBuilder; +use Orisai\Auth\Authorization\Authorizer; + +final class AuthorizationDataCreator +{ + + public function create(): AuthorizationData + { + $builder = new AuthorizationDataBuilder(); + + foreach ($this->getPrivileges() as $privilege) { + // $builder->addPrivilege('article.publish'); + $builder->addPrivilege($privilege); + } + + $rolePrivileges = $this->getRolePrivileges(); + + foreach ($rolePrivileges as $role => $privileges) { + // $builder->addRole('chief-editor'); + $builder->addRole($role); + + foreach ($privileges as $privilege) { + // $builder->allow('chief-editor', 'article.publish'); + $builder->allow($role, $privilege); + } + } + + return $builder->build(); + } + + /** + * @return array + */ + private function getPrivileges(): array + { + return [ + 'article.delete', + 'article.edit.all', + 'article.edit.owned', + 'article.publish', + ]; + } + + /** + * @return array> + */ + private function getRolePrivileges(): array + { + return [ + 'supervisor' => [ + Authorizer::ROOT_PRIVILEGE, + ], + 'editor' => [ + 'article.edit.owned', + ], + 'chief-editor' => [ + 'article.delete', + 'article.edit', + 'article.publish', + ], + ]; + } + +} +``` + +Step 4 (optional): + +- Move privileges to an external source (config, editable by programmer) +- Move roles and their privileges to an external source (database, editable by system supervisor) +- Cache created data - instead of building data on each request, serialize them in cache and invalidate on change + +```php +namespace Example\Core\Auth; + +use Example\Core\Role\RoleRepository; +use ExampleLib\Caching\Cache; +use Orisai\Auth\Authorization\AuthorizationData; +use Orisai\Auth\Authorization\AuthorizationDataBuilder; + +final class AuthorizationDataCreator +{ + + private const CACHE_KEY = 'Example.Core.Auth.Data'; + + /** @var array */ + private array $privileges; + + private RoleRepository $roleRepository; + + private Cache $cache; + + /** + * @param array $privileges + */ + public function __construct(array $privileges, RoleRepository $roleRepository, Cache $cache) + { + $this->privileges = $privileges; + $this->roleRepository = $roleRepository; + $this->cache = $cache; + + $this->roleRepository->onFlush[] = fn () => $this->rebuild(); + } + + public function create(): AuthorizationData + { + $data = $this->cache->load(self::CACHE_KEY); + if ($data instanceof AuthorizationData) { + return $data; + } + + $data = $this->buildData(); + + $this->cache->save(self::CACHE_KEY, $data); + + return $data; + } + + private function rebuild(): void + { + $data = $this->buildData(); + $this->cache->save(self::CACHE_KEY, $data); + } + + private function buildData(): AuthorizationData + { + $dataBuilder = new AuthorizationDataBuilder(); + + foreach ($this->privileges as $privilege) { + $dataBuilder->addPrivilege($privilege); + } + + $roles = $this->roleRepository->findAll(); + + foreach ($roles as $role) { + $dataBuilder->addRole($role->name); + + foreach ($role->privileges as $privilege) { + $dataBuilder->allow($role->name, $privilege); + } + } + + return $dataBuilder->build(); + } + +} +``` + +### Roles + +User roles like developer, admin and editor are the most basic form of authorization. User can have multiple roles +assigned through their identity. + +```php +$firewall->hasRole('admin'); // bool +$identity->hasRole('admin'); // bool +``` + +Although it's easy to set up roles-based authorization, it may backfire as the app gets more complicated. Usually in a +company not just single role has access to single action and relying on roles may lead to conditions +like `$firewall->hasRole('supervisor') || $firewall->hasRole('admin') || $firewall->hasRole('editor') || ...`. Instead, +we use [privilege-based authorization](#privileges). + +### Privileges + +TODO + +- what are privileges + +#### Privilege hierarchy + +TODO + +- hierarchy + - assigning privileges + - checking privileges + - checks all sub-privileges + - combines privilege from all roles and identity +- default check does a full privilege match (article.* for article), but policies of sub-privileges (article.*) are not + called + +```text +✓ article -> article + ✓ publish -> article.publish + ✓ edit -> article.edit + ✓ all -> article.edit.all + ✓ owned -> article.edit.owned + ✓ delete -> article.delete +``` + +### Policies - customized authorization + +TODO + +- basic description + example +- setup + +Requirements can be made [optional](#policy-with-optional-requirements) or even [none](#policy-with-no-requirements) at +all. + +Policy is called only when user is logged-in. For logged-out +users, [make Identity optional](#policy-with-optional-log-in-check). + +For privileges with registered policy, privilege itself **is not checked**. Policy has +to [do the check itself](#policy-with-default-like-privilege-check). + +Each policy has to be registered by `PolicyManager`: + +```php +use Orisai\Auth\Authorization\SimplePolicyManager; + +$policyManager = new SimplePolicyManager(); +$policyManager->add(new ArticleEditPolicy()); +$policyManager->add(new ArticleEditOwnedPolicy()); +``` + +You should prefer an implementation with lazy loading support - like the one for [nette/di](https://github.com/nette/di) +in [orisai/nette-auth](https://github.com/orisai/nette-auth). + +#### Policy context + +Policy provides a context to make authorizer calls to subsequent policies, access current users expired logins, ...: + +```php +use Orisai\Auth\Authentication\DecisionReason; +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authorization\CurrentUserPolicyContext; +use Orisai\Auth\Authorization\Policy; +use Orisai\Auth\Authorization\PolicyContext; + +final class ContextAwarePolicy implements Policy +{ + + // ... + + public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): bool + { + if ($context instanceof CurrentUserPolicyContext) { + foreach ($context->getExpiredLogins() as $expiredLogin) { + // ... + } + } + + $authorizer = $context->getAuthorizer(); + + return $authorizer->isAllowed('contextAware.subprivilege1') + && $authorizer->isAllowed('contextAware.subprivilege2'); + } + +} +``` + +#### Policy with optional log-in check + +Only logged-in users are checked via policy, logged-out users are not allowed to do anything. If you want to authorize +also logged-out users, implement the `OptionalIdentityPolicy`. + +```php +$firewall->isAllowed(OnlyLoggedOutUserPolicy::getPrivilege(), new stdClass()); +$authorizer->isAllowed($identity, OnlyLoggedOutUserPolicy::getPrivilege(), new stdClass()); +$authorizer->isAllowed(null, OnlyLoggedOutUserPolicy::getPrivilege(), new stdClass()); +``` + +```php +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authorization\NoRequirements; +use Orisai\Auth\Authorization\OptionalRequirementsPolicy; +use Orisai\Auth\Authorization\PolicyContext; +use stdClass; + +final class OnlyLoggedOutUserPolicy implements OptionalIdentityPolicy +{ + + // ... + + public static function getRequirementsClass(): string + { + return stdClass::class; + } + + public function isAllowed(?Identity $identity, object $requirements, PolicyContext $context): bool + { + // Only logged-out user is allowed + + return $identity === null; + } + +} +``` -## Setup +#### Policy with optional requirements -Install with [Composer](https://getcomposer.org) +Requirements may be marked optional by implementing `OptionalRequirementsPolicy`. It allows requirements to be null: -```sh -composer require orisai/auth +```php +$firewall->isAllowed(OptionalRequirementsPolicy::getPrivilege()); +$firewall->isAllowed(OptionalRequirementsPolicy::getPrivilege(), new stdClass()); +$authorizer->isAllowed($identity, OptionalRequirementsPolicy::getPrivilege()); +$authorizer->isAllowed($identity, OptionalRequirementsPolicy::getPrivilege(), new stdClass()); +``` + +```php +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authorization\NoRequirements; +use Orisai\Auth\Authorization\OptionalRequirementsPolicy; +use Orisai\Auth\Authorization\PolicyContext; +use stdClass; + +final class OptionalRequirementsPolicy implements OptionalRequirementsPolicy +{ + + // ... + + public static function getRequirementsClass(): string + { + return stdClass::class; + } + + public function isAllowed(Identity $identity, ?object $requirements, PolicyContext $context): bool + { + if ($requirements === null) { + // ... + } else { + // ... + } + } + +} +``` + +#### Policy with no requirements + +Policy which does not have any requirements may use `NoRequirements`. Authorizer will create this object for you so you +don't have to pass it via `isAllowed()`: + +```php +$firewall->isAllowed(NoRequirementsPolicy::getPrivilege()); +$authorizer->isAllowed($identity, NoRequirementsPolicy::getPrivilege()); +``` + +```php +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authorization\NoRequirements; +use Orisai\Auth\Authorization\Policy; +use Orisai\Auth\Authorization\PolicyContext; + +final class NoRequirementsPolicy implements Policy +{ + + // ... + + public static function getRequirementsClass(): string + { + return NoRequirements::class; + } + + public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): bool + { + assert($requirements instanceof NoRequirements); + + return 2b || !2b; + } + +} +``` + +#### Policy with default-like privilege check + +Setting a policy makes the privilege itself **optional and therefore not checked**. To fall back to default behavior, +check privilege via authorizer yourself: + +```php +$firewall->isAllowed(DefaultCheckPolicy::getPrivilege()); +$authorizer->isAllowed($identity, DefaultCheckPolicy::getPrivilege()); +``` + +```php +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authorization\NoRequirements; +use Orisai\Auth\Authorization\Policy; +use Orisai\Auth\Authorization\PolicyContext; + +final class DefaultCheckPolicy implements Policy +{ + + // ... + + public static function getRequirementsClass(): string + { + return NoRequirements::class; + } + + public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): bool + { + $authorizer = $context->getAuthorizer(); + + return $authorizer->hasPrivilege($identity, self::getPrivilege()); + } + +} +``` + +### Root - bypass all checks + +Root privilege is a special privilege which bypasses both privilege and policy checks - neither of them is called, +everything is accessible by root. + +```php +$builder->allow('groot', $authorizer::ROOT_PRIVILEGE); +// ... +$firewall->login(new IntIdentity(123, ['groot'])); +$firewall->isAllowed('anything'); // always true +``` + +### Check authorization of not current user + +User does not have to be logged into firewall in order to check their permissions. Just create an identity for the user +and use authorizer instead of firewall: + +```php +$authorizer->isAllowed($identity, 'privilege.name'); +``` + +### Decision reason + +Reason why the user has or does not have permission can be described by a policy: + +```php +use Orisai\Auth\Authentication\DecisionReason; +use Orisai\Auth\Authentication\Identity; +use Orisai\Auth\Authorization\Policy; +use Orisai\Auth\Authorization\PolicyContext; + +final class WillTellYouWhyPolicy implements Policy +{ + + // ... + + public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): bool + { + if (/* user likes cats */) { + return true; + } + + $context->setDecisionReason(DecisionReason::create('You just don\'t understand their personality.')); + + return false; + } + +} ``` -## Password encoders +Both authorizer and firewall return reason via reference: + +```php +$firewall->isAllowed(DefaultCheckPolicy::getPrivilege(), $requirements, $reason); +$authorizer->isAllowed($identity, DefaultCheckPolicy::getPrivilege(), $requirements, $reason); + +if ($reason !== null) { + $message = $reason->isTranslatable() + ? $translator->translate($reason->getMessage(), $reason->getParameters()) + : $reason->getMessage(); +} +``` + +## Passwords Encode (hash) and verify passwords. ```php +use Example\Core\User\User; +use Example\Front\Auth\FrontFirewall; +use Orisai\Auth\Authentication\IntIdentity; use Orisai\Auth\Passwords\PasswordEncoder; final class UserLogin @@ -36,23 +855,27 @@ final class UserLogin private PasswordEncoder $passwordEncoder; - public function __construct(PasswordEncoder $passwordEncoder) + private FrontFirewall $frontFirewall; + + public function __construct(PasswordEncoder $passwordEncoder, FrontFirewall $frontFirewall) { $this->passwordEncoder = $passwordEncoder; + $this->frontFirewall = $frontFirewall; } - public function signIn(string $password): void + public function login(string $email, string $password): void { - $user; // Query user from database + $user; // Query user from database by $email if ($this->passwordEncoder->isValid($password, $user->encodedPassword)) { $this->updateEncodedPassword($user, $password); // Login user + $this->frontFirewall->login(new IntIdentity($user->id, $user->roles)); } } - public function signUp(string $password): void + public function register(string $password): void { $encodedPassword = $this->passwordEncoder->encode($password); @@ -66,13 +889,14 @@ final class UserLogin } $user->encodedPassword = $this->passwordEncoder->encode($password); + // Persist user to database } } ``` -Make sure your passwords storage allows at least 255 characters. -Each algorithm produces encoded strings of different length and even different settings of one algorithms may vary in results. +Make sure your password storage allows at least 255 characters. Each algorithm produces encoded strings of different +length and even different settings of an algorithm may vary in results. ### Sodium encoder @@ -85,21 +909,24 @@ $encoder = new SodiumPasswordEncoder(); ``` Options: + - `SodiumPasswordEncoder(?int $timeCost, ?int $memoryCost)` - - `$timeCost` - - Maximum number of computations to perform - - By default is set to higher one of `4` and `SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE` - - `$memoryCost` - - Maximum number of memory consumed - - Defined in bytes - - By default is set to higher one of `~67 MB` and `SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE` + - `$timeCost` + - Maximum number of computations to perform + - Default: higher one of `4` and `SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE` + - `$memoryCost` + - Maximum number of memory consumed + - Defined in bytes + - Default: higher one of `~67 MB` and `SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE` ### Bcrypt encoder -Hash passwords with **bcrypt** algorithm. Unless sodium php extension is not available on your setup then always prefer [sodium encoder](#sodium-encoder). +Hash passwords with **bcrypt** algorithm. Unless sodium php extension is not available on your setup then always +prefer [sodium encoder](#sodium-encoder). -*Note:* bcrypt algorithm trims password before hashing to 72 characters. You should not worry about it because it does not have any usage impact, -but it may cause issues if you are migrating from a bcrypt-encoder which modified password to be 72 characters or less before hashing, so please ensure produced hashes are same. +*Note:* bcrypt algorithm trims password before hashing to 72 characters. You should not worry about it because it does +not have any usage impact, but it may cause issues if you are migrating from a bcrypt-encoder which modified password to +be 72 characters or fewer before hashing, so please ensure produced hashes are considered valid by password encoder. ```php use Orisai\Auth\Passwords\BcryptPasswordEncoder; @@ -108,18 +935,19 @@ $encoder = new BcryptPasswordEncoder(); ``` Options: + - `BcryptPasswordEncoder(int $cost)` - - `$cost` - - Cost of the algorithm - - Must be in range `4-31` - - By default is set to `10` + - `$cost` + - Cost of the algorithm + - Must be in range `4-31` + - Default: `10` ### Unsafe MD5 encoder **Use only for testing** -Encoding passwords with sodium is safe option, but also time and resource intensive. -For automated tests purposes it may be helpful to choose faster MD5 algorithm which would be **unsafe** in production environment. +Encoding passwords with sodium is safe option, but also time and resource intensive. For automated tests purposes it may +be helpful to choose faster MD5 algorithm which would be **unsafe** in production environment. ```php use Orisai\Auth\Passwords\UnsafeMD5PasswordEncoder; @@ -129,12 +957,14 @@ $encoder = new UnsafeMD5PasswordEncoder(); ### Backward compatibility - upgrading encoder -If you are migrating to new algorithm, use `UpgradingPasswordEncoder`. It requires a preferred encoder and optionally accepts fallback encoders. +If you are migrating to new algorithm, use `UpgradingPasswordEncoder`. It requires a preferred encoder and optionally +accepts fallback encoders. -If you migrate from a `password_verify()`-compatible password validation method then you don't need any fallback encoders -as it is done automatically for you. These passwords should always start with string like `$2a$`, `$2x$`, `$argon2id$` etc. +If you migrate from a `password_verify()`-compatible password validation method then you don't need any fallback +encoders as it is done automatically for you. These passwords should always start with string like `$2a$`, `$2x$` +, `$argon2id$` etc. -If you need fallback to a *custom encoder*, then check [how to implement your own](#extending). +If you need fallback to a *custom encoder*, implement an `Orisai\Auth\Passwords\PasswordEncoder`. ```php use Orisai\Auth\Passwords\SodiumPasswordEncoder; @@ -154,43 +984,11 @@ $encoder = new UpgradingPasswordEncoder( ); ``` -### Extending - -While it's not recommended to do so, unless you require it for backward compatibility or deeply understand secure hashing and encryption algorithms, -you can implement own encoder. Simply implement `PasswordEncoder` interface. `BcryptPasswordEncoder` is simple example of a working implementation. - -```php -use Orisai\Auth\Passwords\PasswordEncoder; - -final class CustomEncoder implements PasswordEncoder -{ - - public function encode(string $raw): string - { - // An implementation - } - - public function needsReEncode(string $encoded): bool - { - // An implementation - } - - public function isValid(string $raw, string $encoded): bool - { - // An implementation - } - -} -``` - -## Authentication - -Log in user into application via a firewall. +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ -### Authentication setup +### Authentication setup - old Create firewall -- Should extend `BaseFirewall` ```php - */ -final class AdminIdentityRenewer implements IdentityRefresher -{ - - private UserRepository $userRepository; - - public function __construct(UserRepository $userRepository) - { - $this->userRepository = $userRepository; - } - - public function renewIdentity(Identity $identity): Identity - { - $user = $this->userRepository->getById($identity->getId()); - - if ($user === null) { - throw IdentityExpired::create(); - //throw IdentityExpired::create('logout reason description'); - } - - return new IntIdentity($user->getId(), $user->getRoles()); - } - -} -``` - Create firewall instance ```php @@ -256,142 +1016,30 @@ use Orisai\Auth\Authorization\PrivilegeAuthorizer; use Orisai\Auth\Authorization\SimplePolicyManager; use Orisai\Auth\Bridge\NetteHttp\SessionLoginStorage; -$identityRenewer = new AdminIdentityRenewer($userRepository); +$identityRefresher = new AdminIdentityRefresher($userRepository); $loginStorage = new SessionLoginStorage($session); $authorizer = new PrivilegeAuthorizer(); $policyManager = new SimplePolicyManager(); $firewall = new AdminFirewall($loginStorage, $identityRenewer, $authorizer, $policyManager); ``` -### Authentication usage - -#### Log in user +#### Log in user - old ```php use Orisai\Auth\Authentication\IntIdentity; -$identity = new IntIdentity($user->getId(), $user->getRoles()); -$firewall->login($identity); -$firewall->isLoggedIn(); // true -$firewall->getIdentity(); // $identity -$firewall->getAuthenticationTime(); // Instant -$firewall->getExpirationTime(); // Instant|null $firewall->hasRole($role); // bool $firewall->isAllowed($privilege); // bool ``` -#### Set or remove login expiration - -- Expiration is sliding, each request when firewall is used is expiration extended -- After expiration is user logged out (`ExpiredLogin->getLogoutReason()` returns `$firewall::REASON_INACTIVITY`) - -```php -use Brick\DateTime\Instant; - -$firewall->setExpiration(Instant::now()->plusDays(7)); -$firewall->removeExpiration(); -``` - -#### Renew `Identity` - -- use in case you need to change `Identity` on current request (on next request is called `IdentityRenewer`, if set) -- `$firewall->login()` would reset authentication time, don't use it for `Identity` update - -```php -use Orisai\Auth\Authentication\IntIdentity; - -$identity = new IntIdentity($user->getId(), $user->getRoles()); -$firewall->renewIdentity($identity); -``` - -##### Log out user - -- After manual logout `ExpiredLogin->getLogoutReason()` returns `$firewall::REASON_MANUAL` -- `$firewall->getIdentity()` raises an exception, check with `$firewall->isLoggedIn()` or use `$firewall->getExpiredLogins()` instead - -```php -$firewall->logout(); -$firewall->isLoggedIn(); // false -$firewall->getIdentity(); // exception - -$firewall->removeExpiredLogins(); -$firewall->setExpiredIdentitiesLimit($count); // Maximum number of expired logins to store, defaults to 3 - -$firewall->getLastExpiredLogin(); // ExpiredLogin|null -$firewall->getExpiredLogins(); // array -foreach ($firewall->getExpiredLogins() as $identityId => $expiredLogin) { - $firewall->removeExpiredLogin($identityId); - - $expiredLogin->getIdentity(); // Identity - $expiredLogin->getAuthenticationTime(); // Instant - $expiredLogin->getLogoutReason(); // $firewall::REASON_* - REASON_MANUAL | REASON_INACTIVITY | REASON_INVALID_IDENTITY - $expiredLogin->getLogoutReasonDescription(); // string|null - $expiredLogin->getExpiration(); // Expiration|null -} -``` - -# Authorization - -Represent your app permissions with privilege hierarchy - -``` -✓ article - ✓ view - ✓ publish - ✓ edit - ✓ all - ✓ owned - ✓ delete -``` - -```php -use Orisai\Auth\Authorization\AuthorizationDataBuilder; -use Orisai\Auth\Authorization\PrivilegeAuthorizer; -use Orisai\Auth\Authorization\SimplePolicyManager; - -// Create data builder -$builder = new AuthorizationDataBuilder(); - -// Add roles -$builder->addRole('editor'); - -// Add privileges -// - they support hierarchy via dot (e.g article.view is part of article) -$builder->addPrivilege('article.view'); -$builder->addPrivilege('article.publish'); -$builder->addPrivilege('article.delete'); -$builder->addPrivilege('article.edit.owned'); -$builder->addPrivilege('article.edit.all'); - -// Allow role to work with specified privileges -$builder->allow('editor', $authorizer::ALL_PRIVILEGES); // Everything -$builder->allow('editor', 'article.edit'); // Everything from article.edit -$builder->allow('editor', 'article'); // Everything from article - -// Deny role to work with privileges (you shouldn't need to do this explicitly, everything is disallowed by default) -$builder->removeAllow('editor', 'article'); - -// Create data object -$data = $builder->build(); - -// Create authorizer -$policyManager = new SimplePolicyManager(); -$authorizer = new PrivilegeAuthorizer($policyManager, $data); - -// Check if user has privilege -$authorizer->isAllowed($identity, 'article'); // bool, required to have all article sub-privileges -$firewall->isAllowed('article'); // shortcut to $authorizer->isAllowed(), but also checks whether user is logged in - -// Check privilege is registered -$data->privilegeExists('article'); // bool -``` +# Authorization - old -## Policies +## Policies - old -To check whether user has privilege to edit an article, you have to call `$firewall->isAllowed('article.edit')`. -Firewall then (via authorizer) performs checks if user has that privilege and, if any are defined, -also all child privileges like `article.edit.owned` and `article.edit.all`. -This approach is safe but may impractical. To customize that behavior, define a policy: +To check whether user is allowed to edit an article, you have to call `$firewall->isAllowed('article.edit')`. +Firewall then (via authorizer) performs checks if user has that privilege and, if any are defined, also all child +privileges like `article.edit.owned` and `article.edit.all`. This approach is safe but may impractical. To customize +that behavior, define a policy: ```php use Orisai\Auth\Authentication\Identity; @@ -405,8 +1053,6 @@ use Orisai\Auth\Authorization\PolicyContext; final class ArticleEditPolicy implements Policy { - public const EDIT_ALL = 'article.edit.all'; - public static function getPrivilege(): string { return 'article.edit'; @@ -425,16 +1071,8 @@ final class ArticleEditPolicy implements Policy $authorizer = $context->getAuthorizer(); // User is allowed to edit an article, if is allowed to edit all of them or is the article author - return $authorizer->isAllowed($identity, self::EDIT_ALL) - || $authorizer->isAllowed($identity, ...ArticleEditOwnedPolicy::get($requirements)); - } - - /** - * @return array{string, object} - */ - public static function get(Article $article): array - { - return [self::getPrivilege(), $article]; + return $authorizer->isAllowed($identity, 'article.edit.all') + || $authorizer->isAllowed($identity, ArticleEditOwnedPolicy::getPrivilege(), $requirements); } } @@ -473,41 +1111,5 @@ final class ArticleEditOwnedPolicy implements Policy && $identity->getId() === $requirements->getAuthor()->getId(); } - /** - * @return array{string, object} - */ - public static function get(Article $article): array - { - return [self::getPrivilege(), $article]; - } - } ``` - -Now you have to register these policies in policy manager: - -- registration example is for `SimplePolicyManager`, other implementations may require different approach - -```php -$policyManager->add(new ArticleEditPolicy()); -$policyManager->add(new ArticleEditOwnedPolicy()); -``` - -And check if user is allowed by that policy to perform actions: - -```php -$firewall->isAllowed(...ArticleEditPolicy::get($article)); -``` - -Be aware that in case of policy firewall itself don't perform any checks except the logged-in check, so you have to do -all the required privilege checks yourself in the policy. It is possible to fallback to default behavior with -`$authorizer->hasPrivilege($identity, self::getPrivilege())` - -Once the policy is registered, firewall will require you to pass policy requirements. -You may choose to make requirements nullable and change `object $requirements` to `?object $requirements`. -Other possibility is to not have any requirements at all - in that case use requirement `Orisai\Auth\Authorization\NoRequirement` -and firewall will auto-create it for you. - -Always check against the most specific permissions you need. If user is allowed to do everything, `article` privilege -check would be successful, but `ArticleEditOwnedPolicy` check (`article.edit.owned` privilege) may return false in case -user is not author of that article.