Decoupling your user model from Symfony Security

Cover image for Decoupling your user model from Symfony Security

A Symfony security approach that separates application users from framework-specific security models.

Hexagonal architecture keeps business rules separate from framework code. Symfony's security interfaces can blur that boundary when the domain User also becomes the framework's authenticated user.

Symfony's documentation describes the usual setup:

permissions are always linked to a user object. If you need to secure (parts of) your application, you need to create a user class. This is a class that implements UserInterface. Often, this is a Doctrine entity [4].

This approach is direct, but it makes the domain model depend on Symfony.

Several Symfony developers have documented another approach: create a class used only by the security system [1, 2, 3].

Symfony's security system has changed since some of those articles appeared. This post applies the same separation to the newer APIs.

Separate the two user models

  1. Create a security user class in SecurityUser.php.

  2. Create its provider in SecurityUserProvider.php.

  3. Register both in config/packages/security.yaml.

Create SecurityUser

This class contains only the data Symfony needs for authentication and authorization. It implements UserInterface but remains separate from the domain User.

PHP
<?php

declare(strict_types=1);

namespace Infrastructure\Framework\Symfony\Security;

use Domain\Model\User\Entity\User;
use Symfony\Component\Security\Core\User\{
    UserInterface,
    PasswordAuthenticatedUserInterface
};
use Symfony\Component\Uid\Uuid;

final readonly class SecurityUser implements
    UserInterface,
    PasswordAuthenticatedUserInterface
{
    private function __construct(
        private Uuid $id,
        private string $email,
        private string $password,
        private array $roles
    ) {
    }

    public static function create(User $user): self
    {
        return new self(
            $user->getId(),
            $user->getEmail(),
            $user->getPassword(),
            $user->getRoles()
        );
    }

    #[\Override]
    public function getPassword(): ?string
    {
        return $this->password;
    }

    #[\Override]
    public function getRoles(): array
    {
        return $this->roles;
    }

    #[\Override]
    public function getUserIdentifier(): string
    {
        return $this->email;
    }

     #[\Override]
    public function eraseCredentials(): void
    {
    }
}

Create SecurityUserProvider

The user provider loads a SecurityUser from an identifier such as an email address. It can query a database or another source through the application's domain model.

PHP
<?php

declare(strict_types=1);

namespace Infrastructure\Framework\Symfony\Security;

use Domain\Model\User\Repository\UserRepository;
use Symfony\Component\Security\Core\{
    Exception\UserNotFoundException,
    User\UserInterface,
    User\UserProviderInterface
};

final readonly class SecurityUserProvider implements UserProviderInterface
{
    public function __construct(
        private UserRepository $userRepository
    ) {
    }

    #[\Override]
    public function refreshUser(UserInterface $user): UserInterface
    {
        return $this->loadUserByIdentifier($user->getUserIdentifier());
    }

    #[\Override]
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->userRepository->getByEmail($email);
        if ($user === null) {
            throw new UserNotFoundException();
        }

        return SecurityUser::create($user);
    }

    #[\Override]
    public function supportsClass(string $class): bool
    {
        return $class === SecurityUser::class;
    }
}

Register the provider in config/packages/security.yaml:

YAML
security:
    # ...
    providers:
        app_user_provider:
            id: Infrastructure\Framework\Symfony\Security\SecurityUserProvider

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
                enable_csrf: true
            logout:
                path: app_logout
                target: app_login

    access_control:
        - { path: ^/login$, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: ROLE_USER }

Keep Symfony security at the boundary

Symfony includes authenticators such as form_login. Applications with different authentication rules can use a custom authenticator and load users through this SecurityUserProvider.

PHP
public function authenticate(Request $request): Passport
{
    $token = $request->request->get('_csrf_token');
	$email = $request->request->get('email');
	$password = $request->request->get('password');

	$request
		->getSession()
		->set(SecurityRequestAttributes::LAST_USERNAME, $email);

	$passport = new Passport(
		 new UserBadge($email, $this->securityUserProvider->loadUserByIdentifier(...)),
		 new PasswordCredentials($password),
		 [
		    new CsrfTokenBadge('authenticate', $token),
		    new RememberMeBadge(),
		 ]
	);

	return $passport;
}

When code fetches the authenticated user through getUser() or app.user, Symfony returns SecurityUser, not the domain User. SecurityUser exists only for authentication and authorization. The domain model therefore stays independent of Symfony's interfaces.

If a page needs profile or other domain data, load it into a view model. The view model can expose the required fields without making the domain User double as Symfony's security user.

References

  1. https://simshaun.medium.com/decoupling-your-application-user-from-symfonys-security-user-60fa31b4f7f2

  2. https://matthiasnoback.nl/2022/07/decoupling-your-security-user-from-your-user-model/

  3. https://stovepipe.systems/post/decoupling-your-security-user

  4. https://symfony.com/doc/7.2/security.html

Related writing