Skip to content

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.

Vendor/ModuleName/etc/security.php
<?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)
                )
            );
    }
};
  1. Regex to match with the request path.
  2. Access scopes a user should have to be able to access the path.
  3. Identity types the user should have to be able to access the path.
  4. Identity states the user must be in. When types is specified without an explicit states, the firewall implicitly requires IdentityInterface::STATE_FULLY_AUTHENTICATED. Available states are constants on IdentityInterface: STATE_ANONYMOUS, STATE_PARTIALLY_AUTHENTICATED, STATE_FULLY_AUTHENTICATED.
  5. If set to false, no other rules will be evaluated after this rule is matched.
  6. 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 SecurityStorageInterface and 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 fallthrough is false on 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_URL was 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.

<?php

if ($securityManager->isType('some_type')) {
    // user's identity type is 'some_type'
}

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.