Security
Authentication
Authenticators
Application modules can provide multiple authenticators which can be used to authenticate the user.
Authenticators are executed during the request event in
the Amarant\Framework\EventSubscriber\Security\AuthenticationEventSubscriber event subscriber
which is registered with a priority of Amarant\Framework\Enum\EventPriorityEnum::AUTHENTICATION.
Authenticators must implement the following interface:
<?php
declare(strict_types=1);
namespace Amarant\Framework\Contract\Security\Authenticator;
use Amarant\Framework\Contract\Application\Request\RequestContextInterface;
interface AuthenticatorInterface
{
public function supports(RequestContextInterface $requestContext): bool;
public function authenticate(RequestContextInterface $requestContext): AuthenticationResultInterface;
}
If an authenticator can specify a URL which the user should be redirected to, for example to a login page, it must implement the following interface:
<?php
declare(strict_types=1);
namespace Amarant\Framework\Contract\Security\Authenticator;
use Amarant\Framework\Contract\Application\Request\RequestContextInterface;
interface StartAuthenticationUrlAwareAuthenticatorInterface extends AuthenticatorInterface
{
public function getAuthenticationUrl(RequestContextInterface $requestContext): ?string;
}
If a string value is returned, a redirect response will be returned using the value.
Dependency injection tags
Amarant\Framework\Enum\DiEnum::SECURITY_AUTHENTICATOR
Supports
This method should respond with true if it can immediately authenticate the user.
This means if an authenticator is using a login form, it should only respond with true when the user
has submitted data for authentication on a particular URL.
For example:
<?php
#[Override] public function supports(RequestContextInterface $requestContext): bool
{
$request = $requestContext->getRequest();
return $requestContext->getContext()->getArea() === AreaInterface::AREA_BACKEND &&
$request->isMethod(Request::METHOD_POST) &&
$request->getPathInfo() === '/user/account/login';
}
This authenticator will only be executed if the current application area is backend (back office), the request method is POST and
the path of the request is /user/account/login.
On the other hand, if an API request is made, we should support every request using an authorization header.
For example:
<?php
#[Override] public function supports(RequestContextInterface $requestContext): bool
{
return $requestContext->getContext()->getArea() === AreaInterface::AREA_BACKEND &&
$requestContext->isApi() &&
\str_starts_with(
$requestContext->getRequest()->headers->get('Authorization', ''),
'Bearer '
);
}
Authenticate
If an authenticator supports authentication, it must return a result object implementing the following interface:
<?php
declare(strict_types=1);
namespace Amarant\Framework\Contract\Security\Authenticator;
use Amarant\Framework\Contract\Security\Identity\IdentityInterface;
use Amarant\Framework\Exception\BaseException;
use Symfony\Component\HttpFoundation\Response;
interface AuthenticationResultInterface
{
public function getIdentity(): ?IdentityInterface;
public function getException(): ?BaseException;
public function getResponse(): ?Response;
}
The authentication event subscriber executes all supporting authenticators and checks this object.
If getIdentity does not return null, the authentication is considered to be successful. The identity is saved
to security storage. If getResponse does not return null, it sets the response to the request event and stops
further event subscribers. Finally, it returns (exits the event subscriber handling method).
The event subscriber then checks if the authenticator implements StartAuthenticationUrlAwareAuthenticatorInterface.
If it does, and the start authentication URL hasn't been set yet, it calls the getAuthenticationUrl method and
sets the start authentication URL, if not null.
If getException does not return null, the exception is added to a list of exceptions to be used later on.
Important
It only matters if the authenticator implements StartAuthenticationUrlAwareAuthenticatorInterface.
The getAuthenticationUrl can return null if its logic decides so, depending on the request context.
If any of the authenticator results had an exception set, the request event is stopped. If the request is an
API request, Amarant\Framework\Exception\SecurityException::becauseAuthenticationFailed is thrown, otherwise
exception details are added to session error messages.
Finally, an existing identity is retrieved from supporting identity providers and if found, it's saved to security storage.
Note
Security storage is a simple object that stores the current user identity.
It implements Amarant\Framework\Contract\Security\SecurityStorageInterface.
Identities
Identities are objects that implement the following interface:
Amarant\Framework\Contract\Security\Identity\IdentityInterface
They're used to represent users, in a storage-agnostic way, and can provide access to real user objects, for example a data model.
An identity can have a storage attached to it. The storage in this case is temporary, like a session.
It can be used to store settings, like the user's locale, or flash messages like notices and errors.
Such an identity must implement the following interface:
Amarant\Framework\Contract\Security\Identity\StorageAwareIdentityInterface
The following identities already exist in the framework module:
Amarant\Framework\Security\Identity\Identity
By default, not used by any of the core modules, but can be freely used by application modules.
Amarant\Framework\Security\Identity\StorageAwareIdentity
Same as the above, but with a storage attached to it.
Amarant\Framework\Security\Identity\UserAccountIdentity
This identity is used for backend (admin) user accounts. The entire user account authentication stack — UserAccountFacade, UserAccountIdentityProvider, ApiKeyAuthenticator — is backend-only and is not active in the frontend area.
Guest identity
If no other identity provider provides an identity, the Amarant\Framework\Security\Identity\Provider\GuestIdentityProvider
will provide a guest identity.
This identity will be an instance of Amarant\Framework\Security\Identity\StorageAwareIdentity.
Its type will be guest with a global guest access scope and a random id.
Important
The guest provider has a very low tag priority to allow other providers to run before it. Also, this provider is supported only in the frontend application area.
Frontend identities
Out of the box, the framework only provides a guest identity for the frontend area. Any non-guest frontend identity — such as a logged-in customer — requires a dedicated module to be installed. For example, the Sales module provides the customer identity, its authenticators, identity providers, and all surrounding login/logout logic. The framework defines the contracts; the module supplies the implementation.
No PHP sessions
The application never uses PHP sessions, regardless of the backing storage. Identity persistence is handled exclusively through HTTP-only cookies:
- Authenticated users receive a signed JWT cookie. On each subsequent request an identity provider reads and verifies the token and restores the identity object from it.
- Guest users receive a cookie containing a randomly generated unique ID. This ID ties the request to their guest identity.
If an identity implements StorageAwareIdentityInterface, it can carry per-user data (locale,
flash messages, cart references, etc.) that behaves like a session — but the backing store is Redis,
not a PHP session. The identity ID (JWT subject or guest unique ID) is used as the Redis key.
This means there is no session_start(), no $_SESSION, and no session files or session table anywhere
in the application.
Identity providers
Identity providers are used to retrieve an identity that was previously authenticated. For example, a provider might use a http-only cookie to store the user's (JWT) token. On each subsequent request it would verify the token and if valid, it would return the user's identity object.
Identity providers must implement the following interface:
<?php
declare(strict_types=1);
namespace Amarant\Framework\Contract\Security\Identity;
interface IdentityProviderInterface
{
public function supports(): bool;
public function getIdentity(): ?IdentityInterface;
}
Providers can also store an existing identity. This usually means that a provider creates a http-only cookie and stores the user's (JWT) token in it.
Such a provider must implement the following interface:
<?php
declare(strict_types=1);
namespace Amarant\Framework\Contract\Security\Identity;
interface PersistentIdentityProviderInterface extends IdentityProviderInterface
{
public function canStore(IdentityInterface $identity): bool;
public function store(IdentityInterface $identity): void;
public function canDiscard(IdentityInterface $identity): bool;
public function discard(IdentityInterface $identity): void;
}
The store and discard methods will only be called if the corresponding methods canStore and canDiscard return true.
Dependency injection tags
Amarant\Framework\Enum\DiEnum::SECURITY_IDENTITY_PROVIDER
Firewall
The http application is secured using firewall rules. Each application module can specify their own rules.
Rules are evaluated during the request event in
the Amarant\Framework\EventSubscriber\Security\FirewallEventSubscriber event subscriber
which is registered with a priority of Amarant\Framework\Enum\EventPriorityEnum::FIREWALL.
Create firewall rules
Check the annotations to know more.
<?php
declare(strict_types=1);
use Amarant\Framework\Contract\Application\ContextInterface;
use Amarant\Framework\Contract\Security\Firewall\FirewallRuleCollectionInterface;
use Amarant\Framework\Contract\Security\Identity\IdentityInterface;
use Amarant\Framework\Security\Firewall\FirewallRule;
use Amarant\Framework\Security\SecurityConfiguratorInterface;
return new class () implements SecurityConfiguratorInterface {
public function configure(
ContextInterface $context,
FirewallRuleCollectionInterface $firewallRuleCollection
): void {
$firewallRuleCollection
->addRule(
FirewallRule::create(
pathExpression: '/^\/some-path/', // (1)
accessScopes: ['super_scope'], // (2)
types: ['some_identity_type'], // (3)
states: [IdentityInterface::STATE_FULLY_AUTHENTICATED], // (4)
fallthrough: false, // (5)
priority: 0 // (6)
)
);
}
};
- Regex to match with the request path.
- Access scopes a user should have to be able to access the path.
- Identity types the user should have to be able to access the path.
- Identity states the user must be in. When
typesis specified without an explicitstates, the firewall implicitly requiresIdentityInterface::STATE_FULLY_AUTHENTICATED. Available states are constants onIdentityInterface:STATE_ANONYMOUS,STATE_PARTIALLY_AUTHENTICATED,STATE_FULLY_AUTHENTICATED. - If set to
false, no other rules will be evaluated after this rule is matched. - The priority of the rule. Rules of higher priority are evaluated first.
Important
The pathExpression value is used as-is to call preg_match. Format and/or quote this value properly so
it can be used with preg_match directly.
Note
To add rules for a particular application area, place this file in frontend or backend subdirectory instead:
Vendor/ModuleName/etc/frontend/security.php
Vendor/ModuleName/etc/backend/security.php
Route-level access control
In addition to path-based firewall rules, access can be restricted at the route level. Routes can declare required access scopes and data scopes directly in their definition. These are checked by Amarant\Framework\EventSubscriber\Security\RouteAccessEventSubscriber on the EventEnum::APP_REQUEST_ROUTE event at priority EventPriorityEnum::FIREWALL.
If the current identity does not satisfy the route's required scopes, the subscriber replaces the matched route with the RouteEnum::ACCESS_DENIED route and stops event propagation, rendering the access-denied page instead.
Note
Route-level access control does not apply to API requests. The firewall rules and authenticator exceptions handle access denial for API modes.
Events and execution order
Security is enforced entirely through APP_REQUEST and APP_REQUEST_ROUTE event subscribers. All priorities are defined as cases on Amarant\Framework\Enum\EventPriorityEnum.
EventPriorityEnum — full list
| Case | Value | Purpose |
|---|---|---|
CONTENT_NEGOTIATION |
700 | Content type negotiation |
DECOUPLED_MODE |
610 | Decoupled/headless mode setup |
FULL_MAINTENANCE |
600 | Full maintenance mode check |
AUTHENTICATION |
500 | Identity resolution (see below) |
IDENTITY_SUBJECT_UPDATE |
490 | Update last-seen on identity subject |
MAINTENANCE |
480 | Partial maintenance mode check |
FIREWALL |
450 | Path-based firewall + route-level access check (see below) |
PRIVATE_MODE |
440 | Private mode enforcement |
RESPONSE_CACHE |
400 | Response cache lookup |
Higher values run first. The security-relevant subscribers, in execution order:
1. Authentication — EventPriorityEnum::AUTHENTICATION (500)
Subscriber: Amarant\Framework\EventSubscriber\Security\AuthenticationEventSubscriber
Event: EventEnum::APP_REQUEST
Runs all tagged authenticators in priority order. For each one whose supports() returns true:
- If the result carries an identity, stores it via
SecurityStorageInterfaceand returns immediately. - If the result carries a response, sets it on the event, stops propagation, and returns.
- If the result carries an exception, queues it.
If any authenticator provided a start-authentication URL (StartAuthenticationUrlAwareAuthenticatorInterface), it is stored on the request context under RequestContextInterface::META_AUTHENTICATION_URL.
If queued exceptions exist and no identity was established, the event is stopped. For API and XHR requests a SecurityException is thrown; for regular requests the exception details are added to session error messages.
If no authenticator ran (or none succeeded), existing identity providers are tried in order. The first provider whose supports() and getIdentity() both succeed supplies the identity for the rest of the request.
2. Identity subject update — EventPriorityEnum::IDENTITY_SUBJECT_UPDATE (490)
Subscriber: Amarant\Framework\EventSubscriber\Security\UpdateIdentitySubjectEventSubscriber
Event: EventEnum::APP_REQUEST
If the resolved identity carries a subject that implements LastSeenInterface, updates its last_seen timestamp — but at most once every 2 minutes to avoid excessive writes.
3. Firewall — EventPriorityEnum::FIREWALL (450)
Subscriber: Amarant\Framework\EventSubscriber\Security\FirewallEventSubscriber
Event: EventEnum::APP_REQUEST
Iterates firewall rules (sorted by priority, highest first). For each rule whose path regex matches the request URI:
- Accumulates required access scopes, identity types, and identity states from the rule.
- If
fallthroughisfalseon a matching rule, stops iterating further rules.
After all matching rules are processed, the identity is checked against the accumulated constraints. On failure:
- If
META_AUTHENTICATION_URLwas set during authentication, issues a redirect response to that URL. - If the identity is a fully-authenticated non-guest that still fails the check, throws
SecurityException::becauseOfUnauthorizedAccess. - Otherwise, throws
SecurityException::becauseAccessDenied.
4. Route access — EventPriorityEnum::FIREWALL (450)
Subscriber: Amarant\Framework\EventSubscriber\Security\RouteAccessEventSubscriber
Event: EventEnum::APP_REQUEST_ROUTE
After the router resolves a route, checks the route's declared requiredAccessScopes and requiredDataScopes against the current identity. On failure, replaces the route with RouteEnum::ACCESS_DENIED and stops the event. Does not run for API requests.
Login and logout events
Amarant\Framework\Security\UserAccountFacade dispatches two events outside of the HTTP request flow. Note that UserAccountFacade is backend-only — these events fire only for admin user account authentication, not for frontend identities:
| Event constant | Value | When |
|---|---|---|
UserAccountFacade::EVENT_LOGIN |
user.account.login |
After a successful login() call. Payload: ['identity' => UserAccountIdentity]. |
UserAccountFacade::EVENT_LOGOUT |
user.account.logout |
After logout() discards all identities. Payload: ['identities' => UserAccountIdentity[]]. |
Subscribe to these to react to authentication state changes — for example to clear application caches, record audit logs, or invalidate sessions on logout.
Security manager
Security manager is used to get the current user identity and to check if the user has appropriate rights to do something or to access some resources.
To use the security manager, inject the following:
Amarant\Framework\Contract\Security\SecurityManagerInterface
Checking for access scopes
Calling the has method checks if the current user has all the access scopes
given in the method's accessScopes parameter.
<?php
if ($securityManager->has(['some_scope_a', 'some_scope_b'])) {
// user has both 'scope_a' and 'scope_b' access scopes
}
Checking for identity type
Calling the isType method checks if the current user's identity type is the same type as given in the method's type parameter.
Checking for data scope access
Calling the canAccessDataScope and canAccessDataScopes checks if the user has access to all the data scopes given in
the method's scopeValue or scopeValues parameters.
<?php
if ($securityManager->canAccessDataScope('some_data_scope_a')) {
// user has access to 'some_data_scope_a' data scope
}
if ($securityManager->canAccessDataScopes(['some_data_scope_a', 'some_data_scope_b'])) {
// user has access to both 'some_data_scope_a' and 'some_data_scope_b' data scopes
}
Checking if user's identity type is any of types
Calling the isAnyOfTypes method checks if the current user's identity type is any of the types given in the method's types parameter.
<?php
if ($securityManager->isAnyOfTypes(['some_type_a', 'some_type_b'])) {
// user's identity type is 'some_type_a' or 'some_type_b'
}
Checking if access is granted
Calling the isGranted method checks if the current user is allowed to perform actions on a subject.
<?php
if ($securityManager->isGranted($valueOrObject, ['attribute_a', 'attribute_b'])) {
// user is allowed to perform on the subject $valueOrObject
}
These checks are evaluated by security guards. The only and default strategy at the moment is absolute.
This means all guards that support the subject and attributes, must grant access for the isGranted to
return true.
Guards
Guards are used to grant access using a given subject and a list of attributes.
A security guard must implement the following interface:
<?php
declare(strict_types=1);
namespace Amarant\Framework\Contract\Security;
interface SecurityGuardInterface
{
/**
* @param string[] $attributes
*/
public function supports(mixed $subject, array $attributes, SecurityManagerInterface $securityManager): bool;
/**
* @param string[] $attributes
*/
public function isGranted(mixed $subject, array $attributes, SecurityManagerInterface $securityManager): bool;
}
Examples
Amarant\Sales\Security\Guard\CustomerCartGuard
Amarant\Sales\Security\Guard\ActiveCartGuard
Dependency injection tags
Amarant\Framework\Enum\DiEnum::SECURITY_GUARD
CSRF storage
To work with CSRF storage, use the:
Amarant\Framework\Contract\Security\CsrfStorageInterface
From a presentation layer template, use the function csrf_token to get or create a token by name.
Password generation
To generate passwords, use the:
Amarant\Framework\Security\PasswordGenerator
Cryptography
JWT
To work with JWT tokens, use the:
Amarant\Framework\Contract\Security\Jwt\TokenManagerInterface
Creating tokens
Use the create method and specify a payload and headers.
Example
Amarant\Framework\Security\UserAccountFacade::createToken
Validating tokens
Use the load method and specify a token as a string.
Result will be an object of type Amarant\Framework\Security\Jwt\Jwt that can be used to check if the token
is valid and to access its data.
Example
Amarant\Framework\Security\UserAccountFacade::extractToken
Encryption
To encrypt and decrypt data, use the:
Amarant\Framework\Contract\Security\Cryptography\EncryptorInterface
Under the hood, the default encryptor implementation uses sodium_crypto_secretbox and sodium_crypto_secretbox_open.
Encrypted values are prefixed using: Amarant\Framework\Contract\Security\Cryptography\EncryptorInterface::PREFIX.