Authentication and authorization
- Setup
- Authentication
- Authorization
- Passwords
- Integrations and extensions
Install with Composer
composer require orisai/auth
Check authentication, authorization and passwords for their individual setup.
Log-in, log-out, access expired log-ins and check current user permissions to perform actions via Firewall interface.
Create a firewall, with following dependencies:
- Namespace - a unique identifier, used to separate logins of each firewall in login storage
- Login storage - choose one of available or implement your own
- Identity refresher - implement your own, it is required to keep user login up-to-date
- Authorizer - authorizer can be left not configured for authentication, it is used only for privilege and policy-based authorization
use Orisai\Auth\Authentication\ArrayLoginStorage;
use Orisai\Auth\Authentication\SimpleFirewall;
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
use Orisai\Auth\Authorization\AuthorizationData;
use Orisai\Auth\Authorization\PrivilegeAuthorizer;
use Orisai\Auth\Authorization\SimplePolicyManager;
$loginStorage = new ArrayLoginStorage();
$identityRefresher = new ExampleIdentityRefresher();
$authorizer = new PrivilegeAuthorizer(
new SimplePolicyManager(),
(new AuthorizationDataBuilder())->build(),
);
$firewall = new SimpleFirewall(
'namespace',
$loginStorage,
$identityRefresher,
$authorizer,
);
Identity is a storage for user unique ID and authorization-related data - roles and user-specific privileges.
It is required for logging into firewall and authorization via authorizer.
For numeric ID:
use Orisai\Auth\Authentication\IntIdentity;
$identity = new IntIdentity(123, ['list', 'of', 'roles']);
For string ID (e.g. UUID/ULID):
use Orisai\Auth\Authentication\StringIdentity;
$identity = new StringIdentity('1fdc5f77-4254-4888-99b2-bce81bb4fa39', ['list', 'of', 'roles']);
You can also extend Orisai\Auth\Authentication\BaseIdentity
or implement Orisai\Auth\Authentication\Identity
to
store additional data into identity. But usually it's more convenient
to get user data from database.
Log-in user:
$firewall->login($identity);
Firewall itself does no credentials checks, you have to log-in user with an identity you already verified user has access to.
After log-in, several methods become accessible:
$firewall->isLoggedIn() // true
if ($firewall->isLoggedIn()) {
$firewall->getIdentity(); // Identity
$firewall->getAuthenticationTime(); // DateTimeImmutable
$firewall->getExpirationTime(); // DateTimeImmutable
$firewall->setExpirationTime($datetime); // void
$firewall->refreshIdentity($newIdentity); // void
}
You can listen to log-in via callback:
$firewall->addLoginCallback(function() use($firewall): void {
// After log-in
});
Set login to expire after certain amount of time. Expiration is sliding, each request in which firewall is used, expiration is extended.
use DateTimeImmutable;
$firewall->setExpiration(new DateTimeImmutable('7 days'));
$firewall->removeExpiration();
Firewall uses a Psr\Clock\ClockInterface
instance for getting time, you may set custom instance through constructor
for testing expiration with fixed time. Check orisai/clock for available
implementations.
Identity is refreshed on each request through an IdentityRefresher
to keep roles and privileges of active logins
up-to-date.
use Example\Core\User\UserRepository;
use Orisai\Auth\Authentication\Exception\IdentityExpired;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authentication\IdentityRefresher;
use Orisai\Auth\Authentication\IntIdentity;
/**
* @implements IdentityRefresher<IntIdentity>
*/
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:
use Orisai\Auth\Authentication\Exception\IdentityExpired;
use Orisai\TranslationContracts\TranslatableMessage;
throw IdentityExpired::create('decision reason');
// or
throw IdentityExpired::create(new TranslatableMessage('decision.reason.key'));
Identity can be refreshed also manually on current request. Unlike $firewall->login()
it keeps the previous
authentication and expiration times.
use Orisai\Auth\Authentication\IntIdentity;
$identity = new IntIdentity($user->getId(), $user->getRoles());
$firewall->refreshIdentity($identity);
Manual log-out:
$firewall->logout();
User is automatically logged-out in case their login expired or identity refresher invalidated identity.
Several methods are accessible only for logged-in users and should be preceded by isLoggedIn()
check:
$firewall->isLoggedIn() // false
if (!$firewall->isLoggedIn()) {
$firewall->getIdentity(); // exception
$firewall->getAuthenticationTime(); // exception
$firewall->getExpirationTime(); // exception
$firewall->setExpirationTime($datetime); // exception
$firewall->refreshIdentity($newIdentity); // exception
}
You can listen to any if the log-out methods via callback:
$firewall->addLogoutCallback(function() use($firewall): void {
// After log-out
});
Get user entity directly from firewall
use Example\Core\User\UserRepository;
use Orisai\Auth\Authentication\BaseFirewall;
use Orisai\Auth\Authentication\Exception\NotLoggedIn;
use Orisai\Auth\Authentication\IdentityRefresher;
use Orisai\Auth\Authentication\LoginStorage;
use Orisai\Auth\Authorization\Authorizer;
use Psr\Clock\ClockInterface;
final class UserAwareFirewall extends BaseFirewall
{
private UserRepository $userRepository;
public function __construct(
UserRepository $userRepository,
LoginStorage $storage,
IdentityRefresher $refresher,
Authorizer $authorizer,
?ClockInterface $clock = null
) {
parent::__construct($storage, $refresher, $authorizer, $clock);
$this->userRepository = $userRepository;
}
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());
}
}
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.
use Orisai\TranslationContracts\TranslatableMessage;
use Orisai\TranslationContracts\Translator;
$expiredLogin = $firewall->getLastExpiredLogin();
if ($expiredLogin !== null) {
$identity = $expiredLogin->getIdentity(); // Identity
$authenticationTime = $expiredLogin->getAuthenticationTime(); // DateTimeImmutable
$expiration = $expiredLogin->getExpiration();
$expirationTime = $expiration !== null ? $expiration->getTime() : null; // DateTimeImmutable|null
$logoutCode = $expiredLogin->getLogoutCode(); // LogoutCode
$logoutReason = $expiredLogin->getLogoutReason(); // string|TranslatableMessage|null
if ($logoutReason !== null) {
$message = $logoutReason->getMessage();
if ($message instanceof TranslatableMessage) {
assert($translator instanceof Translator); // Create translator or get message id and parameters from TranslatableMessage
$message = $translator->translateMessage($message);
}
}
}
Access all expired logins, ordered from oldest to newest:
foreach ($firewall->getExpiredLogins() as $identityId => $expiredLogin) {
// ...
}
Remove all expired logins:
$firewall->removeExpiredLogins();
Remove expired login by ID from Identity
- for one ID is always stored only the newest:
$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:
$firewall->setExpiredIdentitiesLimit(0);
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 package, uses session mechanism from nette/http)
Each section of application, like administration, frontend and API can have fully separate login. For each section you just need to create firewall instance, with a unique namespace.
Namespace of a firewall can be accessed via $firewall->getNamespace();
.
SimpleFirewall
accepts namespace in constructor, yet it may be more convenient to extend BaseFirewall
and
differentiate each firewall by class name.
<?php
use Orisai\Auth\Authentication\BaseFirewall;
/**
* @extends BaseFirewall<IntIdentity>
*/
final class AdminFirewall extends BaseFirewall
{
public function getNamespace(): string
{
return 'admin';
}
}
Check any user permissions to perform actions via privilege-based system.
$firewall->isAllowed('privilege');
$authorizer->isAllowed($identity, 'privilege');
User has no access to anything, unless explicitly allowed by privilege or by policy.
As a first step, create an authorizer, a policy manager and empty authorization data:
use Orisai\Auth\Authorization\AuthorizationData;
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
use Orisai\Auth\Authorization\AuthorizationDataCreator;
use Orisai\Auth\Authorization\PrivilegeAuthorizer;
use Orisai\Auth\Authorization\SimpleAuthorizationDataCreator;
use Orisai\Auth\Authorization\SimplePolicyManager;
$dataBuilder = new AuthorizationDataBuilder();
$dataCreator = new SimpleAuthorizationDataCreator($dataBuilder);
$policyManager = new SimplePolicyManager();
$authorizer = new PrivilegeAuthorizer($policyManager, $dataCreator);
Step 2 (optional):
- Create data builder
- Add privileges and roles
- Assign privileges to roles
- Build the data
use Orisai\Auth\Authorization\AuthorizationData;
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
use Orisai\Auth\Authorization\Authorizer;
// Create data builder
$dataBuilder = new AuthorizationDataBuilder();
// Add privileges
$dataBuilder->addPrivilege('article.delete');
$dataBuilder->addPrivilege('article.edit.all');
$dataBuilder->addPrivilege('article.edit.owned');
$dataBuilder->addPrivilege('article.publish');
// Add roles
$dataBuilder->addRole('editor');
$dataBuilder->addRole('chief-editor');
$dataBuilder->addRole('supervisor');
// Allow role to work with specified privileges
$dataBuilder->allow('chief-editor', 'article.edit'); // Edit both owned and all articles
$dataBuilder->allow('chief-editor', 'article.publish'); // Publish article
$dataBuilder->allow('chief-editor', 'article.delete'); // Delete articles
$dataBuilder->allow('editor', 'article.edit.owned'); // Edit owned articles
// Give role a root access
$dataBuilder->addRoot('supervisor');
// Create data object
$data = $dataBuilder->build();
Step 3 (optional):
- Abstract data creation with an object
use Orisai\Auth\Authorization\PrivilegeAuthorizer;
use Orisai\Auth\Authorization\SimplePolicyManager;
$dataCreator = new AuthorizationDataCreatorImpl();
$policyManager = new SimplePolicyManager();
$authorizer = new PrivilegeAuthorizer($policyManager, $dataCreator);
use Orisai\Auth\Authorization\AuthorizationData;
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
use Orisai\Auth\Authorization\AuthorizationDataCreator;
use Orisai\Auth\Authorization\Authorizer;
final class AuthorizationDataCreatorImpl implements AuthorizationDataCreator
{
public function create(): AuthorizationData
{
$dataBuilder = new AuthorizationDataBuilder();
foreach ($this->getPrivileges() as $privilege) {
// $dataBuilder->addPrivilege('article.publish');
$dataBuilder->addPrivilege($privilege);
}
foreach ($this->getRolePrivileges() as $role => $privileges) {
// $dataBuilder->addRole('chief-editor');
$dataBuilder->addRole($role);
if ($privileges === true) {
$dataBuilder->addRoot($role);
} else {
foreach ($privileges as $privilege) {
// $dataBuilder->allow('chief-editor', 'article.publish');
$dataBuilder->allow($role, $privilege);
}
}
}
return $dataBuilder->build();
}
/**
* @return array<string>
*/
private function getPrivileges(): array
{
return [
'article.delete',
'article.edit.all',
'article.edit.owned',
'article.publish',
];
}
/**
* @return array<string, true|array<string>>
*/
private function getRolePrivileges(): array
{
return [
'supervisor' => true,
'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
namespace Example\Core\Auth;
use Example\Core\Role\RoleRepository;
use ExampleLib\Caching\Cache;
use Orisai\Auth\Authorization\AuthorizationData;
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
use Orisai\Auth\Authorization\AuthorizationDataCreator;
final class AuthorizationDataCreator implements AuthorizationDataCreator
{
private const CacheKey = 'Example.Core.Auth.Data';
/** @var array<string> */
private array $privileges;
private RoleRepository $roleRepository;
private Cache $cache;
/**
* @param array<string> $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::CacheKey);
if ($data instanceof AuthorizationData) {
return $data;
}
$data = $this->buildData();
$this->cache->save(self::CacheKey, $data);
return $data;
}
private function rebuild(): void
{
$data = $this->buildData();
$this->cache->save(self::CacheKey, $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);
if ($role->root) {
$dataBuilder->addRoot($role->name);
}
foreach ($role->privileges as $privilege) {
$dataBuilder->allow($role->name, $privilege);
}
}
return $dataBuilder->build();
}
}
When an unknown privilege is assigned to role or identity, an exception is thrown. This behavior is correct, but it also means you have to migrate assigned privileges when you remove or rename one.
If it is too complicated, you may just turn it off and re-assign renamed privileges to user:
This is just a workaround, preferably never use this option
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
$dataBuilder = new AuthorizationDataBuilder();
$dataBuilder->throwOnUnknownPrivilege = false;
// ...
$data = $dataBuilder->build();
User roles like developer, admin and editor are the most basic form of authorization. User can have multiple roles assigned through their identity.
$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.
Privilege is a right to commit an action.
Privileges are checked via $firewall->isAllowed()
and $authorizer->isAllowed()
methods.
There is also $authorizer->hasPrivilege()
method, but it should not be used outside of policies because its purpose
is to bypass policy checks to prevent infinite loops (like ArticleEditPolicy
calling isAllowed('article.edit')
).
User privileges have two sources, combined into one during check:
- role privileges, assigned during authorization setup
- identity privileges, assigned to identity directly
Privileges are composed in a hierarchical structure, in which individual sub-privileges are separated by a dot.
- Adding privilege
article.edit.all
via$builder->addPrivilege()
adds also privilegesarticle.edit
andarticle
. - Assigning privilege
article
to user gives them also all the sub-privileges - all these whose name starts witharticle.
. - Checking whether user has privilege
article
checks also all sub-privileges - user has to have all which start witharticle.
.- Policy is checked only for exact privilege, not for sub-privileges nor parent privileges. That means
when
article.edit
has a policy andisAllowed('article')
is called, thearticle.edit
policy is not checked. - To check whether user has a privilege, all roles and identity privileges are combined. Having each sub-privilege at least from one role or identity is enough to have the whole privilege.
- Policy is checked only for exact privilege, not for sub-privileges nor parent privileges. That means
when
Each user can have their privileges assigned directly, without any roles.
use Orisai\Auth\Authorization\AuthorizationDataBuilder;
use Orisai\Auth\Authorization\IdentityAuthorizationDataBuilder;
use Orisai\Auth\Authentication\IntIdentity;
$dataBuilder = new AuthorizationDataBuilder();
// ...
$data = $dataBuilder->build();
$identity = new IntIdentity($user->id, $user->roles);
$identityDataBuilder = new IdentityAuthorizationDataBuilder($data);
if ($user->root) {
$identityDataBuilder->addRoot($identity);
}
foreach ($user->privileges as $privilege) {
$identityDataBuilder->allow($identity, $privilege);
}
$identity->setAuthorizationData($identityDataBuilder->build($identity));
Policy is a class used for custom implementation of privilege check, completely replacing default full privilege match.
It may request an object from isAllowed()
call and services via constructor to perform any checks needed.
hasPrivilege()
checks only privilege, without calling policy
$policyManager->add(new ArticleEditPolicy());
// ...
$authorizer->isAllowed($identity, 'article.edit', $article); // bool
$firewall->isAllowed('article.edit', $article); // bool
Policy has to yield at least one AccessEntry
. Only if all entries contain AccessEntryResult::allowed()
, policy
passes.
use Generator;
use Orisai\Auth\Authorization\Authorizer;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
use Orisai\Auth\Authorization\Policy;
use Orisai\Auth\Authorization\PolicyContext;
/**
* @implements Policy<Article>
*/
final class ArticleEditPolicy implements Policy
{
public static function getPrivilege(): string
{
return 'article.edit';
}
public static function getRequirementsClass(): string
{
return Article::class;
}
/**
* @param Article $requirements
*/
public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): Generator
{
$authorizer = $context->getAuthorizer();
$privilege = self::getPrivilege();
yield new AccessEntry(
// true -> AccessEntryResult::allowed()
// false -> AccessEntryResult::forbidden()
AccessEntryResult::fromBool($authorizer->hasPrivilege($identity, $privilege)),
"Has privilege $privilege",
);
yield new AccessEntry(
AccessEntryResult::fromBool($identity->getId() === $requirements->getAuthor()->getId()),
'Is author of the article',
);
}
}
Each policy has to be registered by PolicyManager
:
use Orisai\Auth\Authorization\SimplePolicyManager;
$policyManager = new SimplePolicyManager();
$policyManager->add(new ArticleEditPolicy());
$policyManager->add(new ArticleEditOwnedPolicy());
Check following chapters to learn more about policies:
- Requirements can be made optional or even none at all.
- Policy is called only when user is logged-in. For logged-out users, make Identity optional.
- Privilege is not checked, when policy is used. Policy has to do the check itself.
- Create || and && conditions via conditional entries
- Compose policies from other policies
- Check whether user previously logged in
- Policy is always skipped by root.
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
.
$firewall->isAllowed(OnlyLoggedOutUserPolicy::getPrivilege(), new stdClass());
$authorizer->isAllowed($identity, OnlyLoggedOutUserPolicy::getPrivilege(), new stdClass());
$authorizer->isAllowed(null, OnlyLoggedOutUserPolicy::getPrivilege(), new stdClass());
use Generator;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
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): Generator
{
yield new AccessEntry(
// Only logged-out user is allowed
AccessEntryResult::fromBool($identity === null),
'Not logged in',
);
}
}
Requirements may be marked optional by implementing OptionalRequirementsPolicy
. It allows requirements to be null:
$firewall->isAllowed(OptionalRequirementsPolicy::getPrivilege());
$firewall->isAllowed(OptionalRequirementsPolicy::getPrivilege(), new stdClass());
$authorizer->isAllowed($identity, OptionalRequirementsPolicy::getPrivilege());
$authorizer->isAllowed($identity, OptionalRequirementsPolicy::getPrivilege(), new stdClass());
use Generator;
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): Generator
{
if ($requirements === null) {
// yield ...
} else {
// yield ...
}
}
}
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()
:
$firewall->isAllowed(NoRequirementsPolicy::getPrivilege());
$authorizer->isAllowed($identity, NoRequirementsPolicy::getPrivilege());
use Generator;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
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;
}
/**
* @param NoRequirements $requirements
*/
public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): Generator
{
yield new AccessEntry(
AccessEntryResult::allowed(),
'No requirements',
);
}
}
Setting a policy makes the privilege itself optional and therefore not checked. To fall back to default behavior, check privilege via authorizer yourself:
$firewall->isAllowed(DefaultCheckPolicy::getPrivilege());
$authorizer->isAllowed($identity, DefaultCheckPolicy::getPrivilege());
use Generator;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
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): Generator
{
$authorizer = $context->getAuthorizer();
$privilege = self::getPrivilege();
yield AccessEntry::forRequiredPrivilege(
AccessEntryResult::fromBool($authorizer->hasPrivilege($identity, $privilege)),
$privilege,
);
}
}
Entries yielded by policy are combined with an && operator by default.
You can also combine them with || operator:
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
yield AccessEntry::matchAny([
// first || second
new AccessEntry(AccessEntryResult::allowed(), /* ... */),
new AccessEntry(AccessEntryResult::forbidden(), /* ... */),
]);
Or explicitly use (the default) && operator:
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
yield AccessEntry::matchAll([
// first && second
new AccessEntry(AccessEntryResult::allowed(), /* ... */),
new AccessEntry(AccessEntryResult::allowed(), /* ... */),
]);
Why not just regular || or && operator? With matchAll()
and matchAny()
you
can show the required checks to user.
Policies can call other policies internally and combine their results
use Generator;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
use Orisai\Auth\Authorization\Policy;
use Orisai\Auth\Authorization\PolicyContext;
final class ComposedPolicy implements Policy
{
// ...
public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): Generator
{
$authorizer = $context->getAuthorizer();
// We don't care about returned value, because it is determined from entries
// We use entries directly instead
$authorizer->isAllowed($identity, 'composed.subprivilege1', null, $entries);
yield from $entries;
$authorizer->isAllowed($identity, 'composed.subprivilege2', null, $entries);
yield from $entries;
}
}
Policy can access information about current login to e.g. make an action available only if user already has an expired login
use Generator;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;
use Orisai\Auth\Authorization\Policy;
use Orisai\Auth\Authorization\PolicyContext;
final class LoginAwarePolicy implements Policy
{
// ...
public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): Generator
{
$context->isCurrentUser(); // bool
// true = Firewall->isAllowed() - user is the current user
// false = Authorizer->isAllowed() - user may not be current user and no expired logins will be available
$context->getLastExpiredLogin(); // ExpiredLogin|null
foreach ($context->getExpiredLogins() as $expiredLogin) {
// ...
}
}
}
Root privilege is a special privilege which bypasses both privilege and policy checks - neither of them is called, everything is accessible by root.
$builder->addRoot('groot');
// ...
$firewall->login(new IntIdentity(123, ['groot']));
$firewall->isAllowed('anything'); // true
$firewall->isRoot(); // true
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:
$authorizer->isAllowed($identity, 'privilege.name');
We may also access authorizer used in firewall. This is useful for verifying user permissions before logging in:
$firewall = $this->getFirewall();
if (!$firewall->getAuthorizer()->isAllowed($identity, 'administration.entry')) {
// Not an admin
return;
}
$firewall->login($identity);
AccessEntry|MatchAllOfEntries|MatchAnyOfEntries
yielded by policies are not
used just to allow or forbid policy-protected privilege. You can also use them to show user why exactly they were (not)
given access.
Entries are propagated to you via isAllowed()
parameter entries
reference:
use Orisai\Auth\Authorization\MatchAllOfEntries;
use Orisai\Auth\Authorization\MatchAnyOfEntries;
use Orisai\TranslationContracts\Translatable;
use Orisai\TranslationContracts\Translator;
assert($translator instanceof Translator); // Create translator or get message id and parameters from Translatable
$firewall->isAllowed($privilege, $requirements, $entries); // $entries === list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>
$authorizer->isAllowed($identity, $privilege, $requirements, $entries); // $entries === list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>
printEntries($entries);
function printEntries(array $entries): void
{
foreach ($entries as $entry) {
if ($entry instanceof MatchAllOfEntries) {
echo "\n";
echo "All of:\n";
printEntries($entry->getEntries());
continue;
}
if ($entry instanceof MatchAnyOfEntries) {
echo "\n";
echo "Any of:\n";
printEntries($entry->getEntries());
continue;
}
$result = $entry->getResult(); // AccessEntryResult
$message = $entry->getMessage(); // string|Translatable
if ($message instanceof Translatable) {
$message = $translator->translateMessage($message);
}
echo "$result->value: $message\n"; // e.g. allowed: Author of the article
}
}
Authorizer has a predefined message for a required privilege. You can either:
- translate it yourself (see
AccessEntry::forRequiredPrivilege()
) - or load translations from
src/Locale/<locale>.json
and format it via an ICU MessageFormat formatter
Access authorization data from authorizer
$data = $authorizer->getData(); // AuthorizationData
$data->getRoles(); // array<int, string>
$data->getPrivileges(); // array<int, string>
$data->getRootRoles(); // array<int, string>
$data->getAllowedPrivilegesForRole('role'); // array<int, string>
$data->privilegeExists('privilege.name'); // bool
Hash and verify passwords.
use Example\Core\User\User;
use Example\Front\Auth\FrontFirewall;
use Orisai\Auth\Authentication\IntIdentity;
use Orisai\Auth\Passwords\PasswordHasher;
final class UserLogin
{
private PasswordHasher $passwordHasher;
private FrontFirewall $frontFirewall;
public function __construct(PasswordHasher $passwordHasher, FrontFirewall $frontFirewall)
{
$this->passwordHasher = $passwordHasher;
$this->frontFirewall = $frontFirewall;
}
public function login(string $email, string $password): void
{
$user; // Query user from database by $email
if ($this->passwordHasher->isValid($password, $user->password)) {
$this->updateHashedPassword($user, $password);
// Login user
$this->frontFirewall->login(new IntIdentity($user->id, $user->roles));
}
}
public function register(string $password): void
{
$hashedPassword = $this->passwordHasher->hash($password);
// Register user
}
private function updateHashedPassword(User $user, string $password): void
{
if (!$this->passwordHasher->needsRehash($user->password)) {
return;
}
$user->password = $this->passwordHasher->hash($password);
// Persist user to database
}
}
Make sure your password storage allows at least 255 characters. Each algorithm produces hashed strings of different length and even different settings of an algorithm may vary in results.
All hashes produced by this library follow PHC string format
Hash passwords with argon2id algorithm. This hasher is recommended.
use Orisai\Auth\Passwords\Argon2PasswordHasher;
$hasher = new Argon2PasswordHasher();
Options:
Don't set any options on lower than default unless it's configuration for tests. Lower values may make algorithm usage not secure enough.
Argon2PasswordHasher(?int $timeCost, ?int $memoryCost, ?int $threads)
$timeCost
- Maximum amount of time it may take to compute the hash
- Increase to make computing of hash harder (more secure, but longer and more CPU intensive)
- Default:
16
$memoryCost
- Maximum memory that may be used to compute the hash
- Increase to make hash computing consume more memory (be aware using more memory increases computation time)
- Defined in KiB (kibibytes)
- Default:
65_535
$threads
- Number of threads to use for computing the hash
- Increase to make computing of hash faster without making it less secure
- default:
4
Hash passwords with bcrypt algorithm. Unless sodium php extension is not available on your setup then always prefer argon2 hasher.
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-hasher which modified password to be 72 characters or fewer before hashing, so please ensure produced hashes are considered valid by password hasher.
use Orisai\Auth\Passwords\BcryptPasswordHasher;
$hasher = new BcryptPasswordHasher();
Options:
Don't set any options on lower than default unless it's configuration for tests. Lower values may make algorithm usage not secure enough.
BcryptPasswordHasher(int $cost)
$cost
- Cost of the algorithm
- Must be in range
4-31
- Default:
13
Following approach is suitable only if we are migrating from secure settings of a secure algorithm. For upgrade from an unsecure algorithm, check migrating from an unsafe algorithm.
If you are migrating to new algorithm, use UpgradingPasswordHasher
. It requires a preferred hasher and optionally
accepts fallback hashers.
If you migrate from a password_verify()
-compatible password validation method then you don't need any fallback
hashers 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 hasher, implement an Orisai\Auth\Passwords\PasswordHasher
.
use Orisai\Auth\Passwords\Argon2PasswordHasher;
use Orisai\Auth\Passwords\UpgradingPasswordHasher;
// With only preferred hasher
$hasher = new UpgradingPasswordHasher(
new Argon2PasswordHasher()
);
// With outdated fallback hashers
$hasher = new UpgradingPasswordHasher(
new Argon2PasswordHasher(),
[
new ExamplePasswordHasher(),
]
);
When we have an unsafe hashing algorithm like md5, sha-* or even safer one but with low settings, we should not wait with rehash on user logging in.
Instead, use the existing password hashes as inputs for a more secure algorithm. For example, if the application
originally stored passwords as md5($password)
, this could be easily upgraded to bcrypt(md5($password))
. Layering the
hashes avoids the need to know the original password; however, it can make the hashes easier to crack. These hashes
should be replaced with direct hashes of the users' passwords next time the user logs in.
- Nette integration - orisai/nette-auth