Découpler le modèle utilisateur de votre application du système de sécurité de Symfony

Illustration de Découpler le modèle utilisateur de votre application du système de sécurité de Symfony

Une approche de la sécurité avec Symfony qui sépare les utilisateurs de l’application des modèles de sécurité propres au framework.

Introduction

Lorsqu’on construit une application Symfony selon des modèles d’architecture avancés, comme l’architecture hexagonale, l’objectif principal est souvent de découpler le domaine (la logique métier) de l’infrastructure (les choix techniques). Cette séparation permet de se concentrer clairement sur les aspects métier de l’application sans les mêler excessivement à des implémentations techniques particulières.

Dans une configuration Symfony classique :

les autorisations sont toujours liées à un objet utilisateur. Pour sécuriser tout ou partie de votre application, vous devez créer une classe utilisateur. Cette classe implémente UserInterface. Il s’agit souvent d’une entité Doctrine [4].

Cette approche est simple, mais elle crée un couplage fort entre le domaine et la couche d’infrastructure — ici, Symfony lui-même.

Plusieurs membres expérimentés de la communauté Symfony ont étudié différentes stratégies de découplage, en particulier pour la sécurité. L’une d’elles consiste à créer une classe exclusivement dédiée à la sécurité [1, 2, 3].

Cependant, la dernière étude technique approfondie sur ce sujet a été publiée il y a six ans, et le système de sécurité de Symfony a beaucoup évolué depuis. Dans cet article, je reviens sur ce problème et propose une approche actualisée.

Comment les séparer ?

  1. Créer une classe utilisateur distincte pour la sécurité (SecurityUser.php)

  2. Créer un fournisseur d’utilisateurs pour la sécurité (SecurityUserProvider.php)

  3. Configurer Symfony pour utiliser le nouvel utilisateur de sécurité et son fournisseur dans config/packages/security.yaml

SecurityUser : dans cette classe, je définis une entité utilisateur réservée à la sécurité et découplée de l’entité User de mon domaine. La classe implémente UserInterface ainsi que, si nécessaire, d’autres interfaces requises par le système de sécurité de Symfony.

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
    {
    }
}

SecurityUserProvider : le fournisseur d’utilisateurs est chargé de récupérer le SecurityUser à partir d’un identifiant, comme une adresse e-mail. Il peut charger l’utilisateur depuis une base de données ou toute autre source, généralement au moyen du modèle de domaine de votre application (l’entité User réelle).

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;
    }
}

Il ne reste plus qu’à déclarer le fournisseur d’utilisateurs auprès de Symfony dans 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 }

Conclusion

Symfony propose plusieurs authentificateurs intégrés, comme form_login, qui simplifient l’implémentation de l’authentification des utilisateurs. Si votre application exige toutefois une logique d’authentification plus complexe, vous pouvez créer un authentificateur personnalisé et vous appuyer sur le SecurityUserProvider que j’ai implémenté pour charger les utilisateurs pendant le processus d’authentification.

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;
}

Il est important de noter que, lorsque vous récupérez l’utilisateur actuellement connecté — par exemple avec la méthode getUser() ou app.user dans Twig — Symfony renvoie l’objet SecurityUser créé précédemment, et non le User de votre domaine. Cette distinction est essentielle : SecurityUser sert uniquement à l’authentification et à l’autorisation, ce qui maintient ma logique métier découplée de l’infrastructure Symfony.

Si vous avez besoin d’informations supplémentaires provenant de l’entité User réelle — comme les données du profil ou d’autres informations propres au domaine — il est recommandé d’utiliser un modèle de vue. Celui-ci sert de passerelle : il récupère les données nécessaires depuis l’entité User et les expose sous une forme exploitable par votre application, sans rompre la séparation entre le domaine et la couche d’infrastructure.

Bon développement !

Références

  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

Articles liés