Je lance une série d'articles sur la partie Authentification de Symfony.
Il y en aura trois :
- L'authentification "classique" avec le maker user et les différents mécanismes du firewall
- Le passwordless avec les clés de sécurité
- Le 2FA avec l'OTP ou les liens magiques.
Le but c'est de voir un tour complet de ce que l'authentification chez Symfony peut nous apporter (et il y a de quoi faire !).
Pour ça je mets à dispo un projet Github : blog-authentication qui te permettra si tu le souhaites de suivre l'aventure.
Le début est sur master avec le style de l'app si tu veux suivre en même temps, sinon les branches serviront de chapitres.
Je vais m'appuyer abusivement sur la documentation technique du framework .
1. Le bundle Security
Première chose à faire, l'installer !
composer require symfony/security-bundle
Grâce à Symfony Flex, on obtient un nouveau fichier dans config/packages : security.yaml .
C'est la pierre angulaire du système d'auth et d'accès. On peut considérer ça comme le réglement interne de notre application.
Analysons un peu ce fichier :
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
users_in_memory: { memory: null }
firewalls:
dev:
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: users_in_memory
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
// J'ai omis volontairement de récupérer la partie when@test qu'on ne verra pas dans ce chapitre
On a ici 3 grandes parties :
password_hashers & providers: les sources d'utilisateurs et les types de hashages (qui)firewalls: les moyens de connexion (comment)access_control: les routes d'accès (quoi)
Autrement dit, qui peut accéder à quoi et comment ?
À ce stade du tuto, nous n'avons pas encore déclaré d'entité pour notre utilisateur. Donc dans notre partie providers c'est vide mais on va voir très vite comment le déclarer !
Pour la section firewalls on y dépose les différentes méthodes d'authentification.
Et enfin la section access_control c'est pour dire que telle route est autorisée à tel rôle de user ou telle adresse IP etc …
2. Le User
La première chose à voir ici c'est de quoi se compose une entité User de Symfony pour commencer la partie providers. Pour ça le framework nous met à dispo une commande maker.
bin/console make:user
Et de là s'ensuit une série de questions pour gérer notre employé :
// Ici rien de sorcier, c'est le nom de notre classe
The name of the security user class (e.g. User) [User]:
> Employee
// L'endroit du stockage de notre utilisateur (ça pourrait être ailleurs)
Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes
// La propriété qui permet au user d'être reconnu par le système d'auth
Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
> email
// Symfony doit il vérifier le password (ça arrive sur certaines app qu'un microservice tier vérifie à sa place)
Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).
Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes
Symfony prend un peu d'avance sur mes explications car on peut lui ajouter plus de propriétés avec make:entity si on veut lui ajouter plus de contexte.
Et pour enregistrer tout ça en base de données, il nous rappelle de faire un make:migration .

Avant de faire ça on va d'abord se rendre dans notre entity User pour voir un petit peu de quoi se compose celle-ci.
namespace App\Entity;
use App\Repository\EmployeeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
class Employee implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private ?string $email = null;
/**
* @var list<string> The user roles
*/
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
// ...
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
Notre entity est donc composée des propriétés créées pour la plupart pendant le make:user en plus de $roles qui est un tableau de rôles de l'utilisateur qui sera utile pour la partie access_control de l'application.
Cette propriété est un peu spéciale car Symfony lors du getRoles, force et garantit qu'un utilisateur a au moins un ROLE_USER lorsqu'il est connecté. Si on veut changer ce comportement il suffira d'agir ici.
Le framework nous met aussi à dispo une fonction getUserIdentifier obtenue aussi par le contrat de l'interface UserInterface qui lui permet de récupérer l'identifiant (ici on a mis $email ) lorsqu'on en aura besoin.
L'interface PasswordAuthenticatedUserInterface quant à elle nous permettra lors de l'authentification de récupérer aussi le hash du password de l'utilisateur.
Retournons dans notre security.yaml suite à ce make:user :
providers:
app_user_provider:
entity:
class: App\Entity\Employee
property: email
firewalls:
main:
lazy: true
provider: app_user_provider
Quelques modifications :
providersa récupéré une nouvelle source d'utilisateur, ici nos employés qui sont bien une entité dans Symfony, sur la propriété uniqueemail. Le système a paramétré automatiquement le nom deapp_user_providermais nous sommes totalement libres de changer cette clé.firewallsajoute un provider dans sa partiemainfaisant référence directement à la clé de notreprovider.
On va créer une commande pour créer rapidement un employé (ci-dessous).
Code de la commande
<?php
namespace App\Command;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Validation;
#[AsCommand(
name: 'app:create-user',
description: 'Crée un nouvel employé.',
)]
class CreateUserCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EmployeeRepository $employeeRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::OPTIONAL, "L'adresse e-mail de l'employé")
->addArgument('password', InputArgument::OPTIONAL, 'Le mot de passe en clair');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
if (null === $email) {
$email = $io->ask("Adresse e-mail de l'utilisateur");
}
$violations = Validation::createValidator()->validate($email, [new Email()]);
if (0 !== count($violations)) {
$io->error(sprintf('Adresse e-mail invalide : "%s".', $email));
return Command::FAILURE;
}
if (null !== $this->employeeRepository->findOneBy(['email' => $email])) {
$io->error(sprintf('Un utilisateur avec l\'e-mail "%s" existe déjà.', $email));
return Command::FAILURE;
}
$plainPassword = $input->getArgument('password');
if (null === $plainPassword) {
$plainPassword = $io->askHidden('Mot de passe');
}
if (null === $plainPassword || '' === trim($plainPassword)) {
$io->error('Le mot de passe ne peut pas être vide.');
return Command::FAILURE;
}
$employee = new Employee();
$employee->setEmail($email);
$employee->setRoles([]);
$employee->setPassword($this->passwordHasher->hashPassword($employee, $plainPassword));
$this->entityManager->persist($employee);
$this->entityManager->flush();
return Command::SUCCESS;
}
}
On lance la commande bin/console app:create-user [employe@exemple.fr](<mailto:employe@exemple.fr>) '123456' pour continuer le tuto.
Parfait, maintenant on a notre user ! On peut passer au firewall.
3. Le Firewall
On a vu un peu plus haut que le firewall c'est le mode d'emploi de connexion.
Pour ça on a plusieurs options à notre disposition.
Actuellement on a ceci dans le security.firewall.main du yaml :
firewalls:
main:
lazy: true
provider: app_user_provider
lazy c'est pour indiquer que si une route peut être accédée de manière anonyme, Symfony n'engagera pas le process d'authentification pour rien.
provider c'est pour indiquer la source des users servant à la connexion.
tips : Tous les users peuvent ne pas être autorisés quand même, on dit juste à Symfony "prend cette source pour vérifier" mais pas forcément de laisser tout le monde passer. Ça c'est dans access_control.
Il faut savoir que les firewalls sont en mode "first match wins". Celui qui match en premier va être sélectionné.
Par conséquent rendons-nous sur /dashboard :
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: app_user_provider
Eh bien nous ne sommes pas dans _profiler|_wdt|assets|build alors le firewall par défaut sera main.

Et pourtant nous ne sommes pas bloqués, caraccess_control ressemble à ça :
access_control:
# - { path: ^/dashboard, roles: ROLE_USER }
La route est commentée ! Enlevons le commentaire et retentons la navigation.

On vient de déclencher une AccessDeniedException du fait de l'action combinée du firewall et de access_control .
Si on change le pattern du firewall, on peut carrément faire ignorer /dashboard du process :
firewalls:
main:
lazy: true
provider: app_user_provider
pattern: ^/dashboard-test
Si on refresh, on voit dans la profiler-bar que le firewall n'est même pas chargé tout simplement car /dashboard ne match plus avec les firewalls présents.
On peut supprimer notre pattern pour l'instant.
À présent on a une AccessDeniedException qui nous indique que nous ne sommes pas connectés. En ENV=PROD on aurait eu une 401.
C'est donc notre première step d'authentification. Il faut créer le process maintenant.
4. Le SecurityController
Symfony étant un framework merveilleux, on nous met à disposition une commande CLI pour créer tout ça : bin/console make:security:form-login pour créer une connexion "classique" login/mot de passe.
On répond aux questions :
Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
> SecurityController
Do you want to generate a '/logout' URL? (yes/no) [yes]:
> yes
Do you want to generate PHPUnit tests? [Experimental] (yes/no) [no]:
> no
Puis retournons dans notre firewall pour voir les modifications engendrées :
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
On a plusieurs propriétés qui se sont rajoutées :
form_login c'est une propriété pour indiquer la méthode de login, ici un simple formulaire.
À l'intérieur, on a login_path qui est la route appelée pour le formulaire de connexion en GET (on verra juste après le controller) tandis que check_path c'est la route pour entamer le process de connexion (même route en POST ).
logout quant à lui c'est pour gérer la déconnexion propre en supprimant le cookie PHPSESSID .
Retournons sur notre /dashboard , nous sommes redirigés automatiquement vers /login .

Regardons de plus près notre contrôleur qui gère app_login qui est SecurityController créé grâce au maker-bundle .
class SecurityController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
return $this->redirectToRoute('dashboard');
}
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}
J'ai ajouté $this->isGranted('IS_AUTHENTICATED_FULLY') pour éviter de retomber sur la page de login si on est déjà connecté.
On y voit bien les deux routes impliquées dans le process : app_login et app_logout .
Dans app_login on peut voir que c'est un simple render du formulaire qui sert à la connexion.
On a quelques aides comme $error qui permet d'avoir le retour d'erreur en cas d'échec de connexion et $lastUsername qui permet à l'application de se souvenir du dernier username rentré (ça évite de rager quand on doit re-remplir tout parce qu'on s'est trompé).
Le formulaire twig est aussi plutôt succinct :
<form method="post">
<label for="username">Email</label>
<input type="email" value="{{ last_username }}" name="_username" id="username"
autocomplete="email" placeholder="vous@exemple.fr" required autofocus
{% if error %}aria-invalid="true"{% endif %}>
<label for="password">Mot de passe</label>
<input type="password" name="_password" id="password"
autocomplete="current-password" required
{% if error %}aria-invalid="true"{% endif %}>
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
<button type="submit">Se connecter</button>
</form>
On a un input de username, password, un jeton CSRF pour éviter les usurpations et un submit button pour envoyer tout ça en POST et engager le process d'authentification de check_path du form_login.
5. L'Authenticator
Quand on soumet notre identifiant, on va s'engager dans notre Authenticator. Ici c'est un FormLoginAuthenticator qui est fourni en interne par Symfony lorsqu'on a indiqué "form-login" lors de la commande du maker-bundle .
Ça m'embête un petit peu car je voulais vous montrer tout le process pour qu'on apprenne ensemble.
Symfony étant un framework à nouveau merveilleux, on va juste modifier notre firewall pour y indiquer un authenticator custom qui refera à peu de choses près la même chose que le FormLoginAuthenticator mais c'est surtout pour apprendre !
Pour ça on va relancer notre maker-bundle avec bin/console make:security:custom :
What is the class name of the authenticator (e.g. CustomAuthenticator):
> LoginFormAuthenticator
On check dans notre firewall :
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
custom_authenticators:
- App\Security\LoginFormAuthenticator
On va retirer le form_login et aller directement dans notre Authenticator tant attendu !
Il y a beaucoup de fonctions, on va les décortiquer pas à pas. Elles héritent toutes de AbstractAuthenticator qui lui implémente AuthenticatorInterface et qui nous donne le contrat de ces cinq fonctions :
supports()authenticate()createToken()onAuthenticationSuccess()onAuthenticationFailure()
4.1 La fonction Support()
class LoginFormAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// return $request->headers->has('X-AUTH-TOKEN');
}
// ...
}
C'est la fonction qui va trigger l'authentification. L'équivalent du check_path, nous on va juste vérifier que c'est une méthode HTTP POST, sur la route app_login qui est appelée.
public function supports(Request $request): ?bool
{
return $request->isMethod('POST')
&& $request->attributes->get('_route') === 'app_login';
}
Si la fonction retourne un true alors on passe directement dans authenticate() .
4.2 La fonction authenticate()
public function authenticate(Request $request): Passport
{
// $apiToken = $request->headers->get('X-AUTH-TOKEN');
// if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
// throw new CustomUserMessageAuthenticationException('No API token provided');
// }
// implement your own logic to get the user identifier from `$apiToken`
// e.g. by looking up a user in the database using its API key
// $userIdentifier = /** ... */;
// return new SelfValidatingPassport(new UserBadge($userIdentifier));
}
Dans cette fonction, c'est ici qu'on va récupérer les infos du formulaire, les préparer dans un Passport pour les comparer par la suite.
Il faut voir la fonction authenticate() comme un passage à l'aéroport. Avant de passer la douane et les contrôles de sécurité, on prépare son passeport, son visa etc … ici cette fonction fait exactement ça, on met à dispo l'ensemble des informations nécessaires à l'authentification pour le système d'auth de Symfony.

Première step, récupération des infos du form :
public function authenticate(Request $request): Passport
{
$email = $request->getPayload()->getString('_username');
$password = $request->getPayload()->getString('_password');
$token = $request->getPayload()->getString('_csrf_token');
}
Une fois les éléments récupérés, on va créer le Passport():
public function authenticate(Request $request): Passport
{
$email = $request->getPayload()->getString('_username');
$password = $request->getPayload()->getString('_password');
$token = $request->getPayload()->getString('_csrf_token');
$passport = new Passport(new UserBadge($email), new PasswordCredentials($password), [new CsrfTokenBadge("authenticate", $token)]);
return $passport;
}
Passport attend 2 choses obligatoires :
- Un
UserBadgequi est le User Identifier contenu dans le provider du security.yaml associé au firewall correspondant - un
PasswordCredentialsqui est le password
On peut aussi lui mettre différents badges (BadgeInterface) optionnels dans un array, ici on rajoute le jeton CSRF pour comparaison et vérifier qu'on n'usurpe pas le client.
Juste par curiosité on peut regarder à quoi ressemble un passport Symfony :

C'est un ensemble de Badge.
On est prêt à passer la douane maintenant donc on retourne le Passport() et Symfony s'occupe de faire les vérifications avec ce qu'on lui a donné via le AuthenticatorManager que tu trouveras ici si tu veux voir un peu comment fonctionne l'auth en profondeur : Symfony\Component\Security\Http\Authentication\AuthenticatorManager .
Ici on a deux possibilités, soit on est refoulé, soit on est passé. Ça tombe bien, le AbstractAuthenticator nous a donné deux méthodes pour gérer les cas :
onAuthenticationSuccess()onAuthenticationFailure()
4.3 La Fonction onAuthenticationSuccess()
Cette fonction c'est la porte de sortie de notre Authenticator. On est connecté, et on peut décider de ce qu'on veut faire. Soit rien, et on laisse le process se terminer, soit on peut rajouter des choses en fonction des use case métier (un compteur de connexion etc …).
Ici on va juste faire un dd() pour voir un peu à quoi ressemble un token de connexion :
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
dd($token);
return new RedirectResponse($this->urlGenerator->generate('dashboard'));
}

En fait c'est notre user contenu dans un token et lié à un firewall précis (ici notre "main").
Ce qui veut dire que si l'on change de route et qu'on tombe dans un autre firewall, on devra repasser le process d'authentification car ce ne sera pas le même firewall.
4.4 La fonction onAuthenticationFailure()
Celle-là c'est le retour à la case départ.
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
// you may want to customize or obfuscate the message first
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
C'est aussi cette fonction qui gère le retour des erreurs vers la page d'échec (chez nous le app_login).
Comme l'indique la fonction pré-remplie : Il ne faut pas laisser passer des messages trop précis sur la partie d'authentification pour éviter à des personnes malveillantes d'avoir des informations précises.
Si on dump $data, on a ça :

Le mieux étant de soi-même écrire l'erreur pour éviter une fuite un jour :
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse($this->urlGenerator->generate('app_login'));
}
Au moins on est safe sur le retour !
Si on log $error du SecurityController pendant la soumission d'un mauvais email par exemple, on récupère ça :

Ça l'injectera dans le twig. On peut en installant le bundle de translation avoir les retours en français.
On va injecter une dernière interface dans notre Authenticator pour rediriger l'utilisateur non connecté vers la page de connexion s'il atteint une page sécurisée :
class LoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
// ...
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return new RedirectResponse($this->urlGenerator->generate('app_login'));
}
}
Avec ça on est automatiquement redirigé vers le login si on active une page protégée !
On a maintenant un système d'authentification standard. On est allé un peu en profondeur sur le fonctionnement avec un CustomAuthenticator (qui fait la même chose qu'un form_login).
6. Le UserChecker
On va s'attarder quelques instants sur une classe intéressante du système d'authentification mais pour ça il vaut mieux faire un petit schéma pour voir un peu les étapes du système dans sa globalité. Et maintenant qu'on a balayé pas mal d'étapes, le schéma fait moins peur maintenant.

On va agir sur la partie UserChecker::class, c'est-à-dire à deux endroits de la pipeline d'authentification. L'une où l'on n'est pas encore connecté (checkPreAuth()) et l'une en post authentification (checkPostAuth()).
Pour illustrer tout ça, on va créer une nouvelle migration avec deux nouvelles propriétés dans notre Employee : $isActive et $loginCounter on fait la migration dans la foulée.
Créons à présent la classe UserChecker dans security\UserChecker.php
namespace App\Security;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if (!$user instanceof Employee) {
return;
}
if (!$user->isActive()) {
throw new CustomUserMessageAccountStatusException('Votre compte a été désactivé');
}
}
}
Cette fonction checkPreAuth() nous permet ainsi de vérifier avant la validité du mot de passe, si le compte a été désactivé par l'entreprise. Ça nous permet ainsi d'éviter une comparaison de hash de mot de passe car il déclenche tout de suite une exception.
On va activer notre UserChecker dans la partie security.yaml :
main:
lazy: true
provider: app_user_provider
logout:
path: app_logout
custom_authenticators:
- App\Security\LoginFormAuthenticator
user_checker: App\Security\UserChecker
Tentons de se connecter :

Je passe en base de données directement isActive à 1. Et là sans surprise, je passe la fonction sans déclencher une exception.
On va faire la même chose côté checkPostAuth() mais lui agit une fois que le user est authentifié. Pour l'exemple, un compteur de connexion :
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void
{
if (!$user instanceof Employee) {
return;
}
$user->setLoginCounter($user->getLoginCounter() + 1);
$this->entityManager->persist($user);
try {
$this->entityManager->flush();
} catch (\Exception $e) {
// Je laisse vide
}
}
Une simple incrémentation de compteur avec un flush.
Une fois la connexion réussie, on a bien le compteur qui s'incrémente.

Évidemment on aurait pu mettre ce compteur dans le onAuthenticationSuccess() du LoginFormAuthenticator mais c'est bien d'éviter les responsabilités multiples. Quand on connaît le process, on sait où chercher ensuite !
onAuthenticationSuccess() nous sert uniquement à rediriger tandis que checkPostAuth() nous permet de faire des actions supplémentaires en post connexion.
On a vu globalement comment se comporter le système d'authentification.
7. Les voters
Une fois toutes les étapes passées, on est parfaitement FULLY_AUTHENTICATED avec nos rôles associés à notre utilisateur fournis par getRoles() qui injecte ROLE_USER par défaut.
Tout cela on le récupère dans le token d'authentification.
Maintenant on peut aller plus loin dans la sécurité avec les voters.
Ce sont des fichiers qui permettent d'agir de manière granulaire sur des permissions sans tomber dans l'enfer des rôles hérités.
Déjà c'est quoi ? C'est juste un fichier qui vérifie une condition et qui retourne true / false.
Si tu fais déjà du Symfony, tu as déjà du voir ce type d'écriture dans les contrôleurs :
class DashboardController extends AbstractController
{
#[Route('/dashboard', name: 'dashboard')]
#[isGranted('ROLE_EMPLOYEE')]
public function index(): Response
Ce isGranted appelle en fait un Voter sous le capot.
Ici on vérifie simplement si l'utilisateur possède le rôle adéquat, c'est finalement ce que l'on fait déjà dans access_control de notre security.yaml .
Mais voyons un peu comment en customiser un maison. Décortiquons tout ça.
namespace App\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class IPVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool {}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool {}
}
Voilà le squelette d'un Voter custom. Ici on va se baser sur l'IP d'un employé pour décider s'il peut ou non faire certaines actions qui pourraient nécessiter d'être dans l'entreprise physiquement (Certaines actions bancaires par exemple peuvent nécessiter d'être présent dans l'infrastructure).
La fonction supports() nous est familière, on l'utilise aussi sur notre Authenticator pour décider ou non si ce voter doit être utilisé lorsqu'on appelle un isGranted .
Pour simplifier. Quand on appelle un isGranted , Symfony va charger l'ensemble des Voter (y compris les customs) et va regarder si le retour de supports() ressort à true . Si c'est le cas alors on trigger voteOnAttribute() .
Définissons un attribut : IS_ON_SITE .
protected function supports(string $attribute, mixed $subject): bool
{
if($attribute === "IS_ON_SITE")
return true;
return false;
}
Très simple, mais suffisant.
Dès qu'on appellera un isGranted('IS_ON_SITE') on va trigger ce voter.
Occupons de la seconde fonction :
const SITE = "192.168.1.2"; // Une IP locale qui n'est pas mon docker.host
public function __construct(private readonly RequestStack $requestStack) {}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$request = $this->requestStack->getCurrentRequest();
$ips = $request->getClientIps();
if(in_array(self::SITE, $ips, true))
return true;
return false;
}
Pour cette fonction, j'ai ajouté une constante SITE pour simuler une IP et un __construct() pour récupérer la requête actuelle de l'utilisateur ($request ) et ses éventuelles IPs ($ips ).
De cette manière on peut facilement retourner true ou false en fonction.
C'est bien, c'est simple, mais on ne peut pas l'utiliser directement sur des routes dans le access_control car on ne vérifie pas que le user est connecté. C'est dommage, et un peu risqué en fonction de l'endroit où on veut l'utiliser. Par conséquent on va aussi vérifier que l'utilisateur est connecté, grâce au TokenInterface pour récupérer le user.
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$employee = $token->getUser();
if (!$employee instanceof Employee) {
return false;
}
$request = $this->requestStack->getCurrentRequest();
$ips = $request->getClientIps();
if(in_array(self::SITE, $ips, true))
return true;
return false;
}
De cette manière c'est plutôt safe. Par contre si le user tombe sur une 403 à cause de ce voter, il ne va pas bien comprendre.
On va l'accompagner d'un petit message d'explication, pour ça on va utiliser une méthode de l'objet Vote qui est $vote->addReason() :
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$employee = $token->getUser();
if (!$employee instanceof Employee) {
$vote?->addReason('Vous devez être connecté pour utiliser cette fonctionnalité');
return false;
}
$request = $this->requestStack->getCurrentRequest();
$ips = $request->getClientIps();
if(in_array(self::SITE, $ips, true))
return true;
$vote?->addReason('Vous devez être sur place pour utiliser cette fonctionnalité');
return false;
}
Si je retourne sur /dashboard alors que isGranted('IS_ON_SITE') est mis dedans, je tombe bien sur une 403 avec une belle explication (qu'on mettra en forme évidemment dans une page custom c'est plus sympa !).

On l'enlève évidemment pour la suite de la démonstration.
Mais on va le placer dans notre access_control comme ceci :
access_control:
- { path: ^/dashboard, roles: ROLE_USER }
- { path: ^/, roles: is_granted("IS_ON_SITE")}
De cette manière, c'est un peu plus fin que juste mettre un rôle ou une restriction d'IP à la suite comme ceci : - { path: ^/, roles: PUBLIC_ACCESS, ips: [192.168.1.0/24, 127.0.0.1] } car son avantage c'est son caractère réutilisable, notamment dans les fichiers Twig.
Par contre on va inclure un bouton "Ajouter un client" qui devrait se faire uniquement sur site dans notre dashboard, comme ceci :
{% if(is_granted("IS_ON_SITE")) %}
<button>Ajouter un client</button>
{% endif %}
Avec une ip qui ne correspond pas à la mienne, je ne vois pas le bouton.
Dans notre voter, si on change avec l'ip exacte (const SITE = "172.19.0.1"; // IP docker host ) ça match parfaitement au niveau de la fonction onVoteAttribute() de IPVoter.
On a accès au bouton ! Sans avoir eu besoin d'ajouter un rôle spécifique supplémentaire !

C'était le premier article d'une série de trois thèmes sur l'authentification.
Le but est de démystifier un peu les méthodes de connexions pour rendre nos applications plus sûres et pratiques à utiliser pour nos utilisateurs !
Le prochain sera sur le Passwordless notamment avec les clés de sécurité comme les Yubikey !