Le composant Security Symfony
Symfony Security gère en une seule config : authentification (qui es-tu ?), autorisation (as-tu le droit ?) et protection (CSRF, brute-force, remember-me…).
composer require symfony/security-bundle
php bin/console make:user
php bin/console make:security:form-login
security.yaml — configuration complète
# config/packages/security.yaml
security:
password_hashers:
App\Entity\User:
algorithm: auto # bcrypt/argon2id selon PHP
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~ # LexikJWTAuthenticationBundle
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
default_target_path: app_dashboard
logout:
path: app_logout
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # 7 jours
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
- { path: ^/api/admin, roles: ROLE_ADMIN }
role_hierarchy:
ROLE_MODERATOR: ROLE_USER
ROLE_ADMIN: [ROLE_MODERATOR, ROLE_USER]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
Entité User — UserInterface
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity]
#[UniqueEntity(fields: ['email'], message: 'Cet email est déjà utilisé')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private string $email = '';
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column]
private string $password = '';
// Interface UserInterface
public function getUserIdentifier(): string { return $this->email; }
public function getPassword(): string { return $this->password; }
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER'; // tous les users ont ce rôle
return array_unique($roles);
}
public function eraseCredentials(): void
{
// efface le mot de passe en clair si stocké temporairement
}
// Getters/setters classiques
public function getId(): ?int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $e): static { $this->email = $e; return $this; }
public function setPassword(string $p): static { $this->password = $p; return $this; }
public function setRoles(array $r): static { $this->roles = $r; return $this; }
}
Firewalls & Authenticators
<?php
// Authenticator personnalisé (ex: token API header)
namespace App\Security;
use Symfony\Component\HttpFoundation\{Request, Response, JsonResponse};
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\{Passport, SelfValidatingPassport};
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(private readonly UserRepository $userRepo) {}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-API-Token');
}
public function authenticate(Request $request): Passport
{
$token = $request->headers->get('X-API-Token');
return new SelfValidatingPassport(
new UserBadge($token, function(string $token) {
$user = $this->userRepo->findOneBy(['apiToken' => $token]);
if (!$user) throw new AuthenticationException('Token invalide');
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, $token, string $firewallName): ?Response
{
return null; // continue la requête
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return new JsonResponse(['error' => 'Unauthorized'], 401);
}
}
Formulaire de connexion
php bin/console make:security:form-login
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $utils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('app_dashboard');
}
return $this->render('security/login.html.twig', [
'last_username' => $utils->getLastUsername(),
'error' => $utils->getLastAuthenticationError(),
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): never
{
// intercepté par Symfony — ne jamais atteindre ici
throw new \LogicException('This should not be reached.');
}
}
{# templates/security/login.html.twig #}
{% extends 'base.html.twig' %}
{% block content %}
<form method="post">
{% if error %}<div class="alert alert-danger">{{ error.messageKey|trans }}</div>{% endif %}
<input type="email" name="email" value="{{ last_username }}" required autofocus>
<input type="password" name="password" required>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<label><input type="checkbox" name="_remember_me"> Se souvenir de moi</label>
<button type="submit">Connexion</button>
</form>
{% endblock %}
Voters — autorisation fine
php bin/console make:voter PostVoter
<?php
namespace App\Security\Voter;
use App\Entity\{Post, User};
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class PostVoter extends Voter
{
const VIEW = 'view';
const EDIT = 'edit';
const DELETE = 'delete';
public function __construct(private readonly Security $security) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Post;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
/** @var Post $post */
$post = $subject;
$user = $token->getUser();
if ($this->security->isGranted('ROLE_ADMIN')) return true;
if (!$user instanceof User) return false;
return match($attribute) {
self::VIEW => $post->isPublished() || $post->getAuthor() === $user,
self::EDIT,
self::DELETE => $post->getAuthor() === $user,
default => false,
};
}
}
<?php
// Controller — utilisation du Voter
#[Route('/post/{id}/edit')]
public function edit(Post $post): Response
{
$this->denyAccessUnlessGranted('edit', $post);
// … suite du traitement
}
{# Twig — utilisation du Voter #}
{% if is_granted('edit', post) %}
<a href="{{ path('post_edit', {id: post.id}) }}">Modifier</a>
{% endif %}
JWT Auth avec LexikJWTAuthenticationBundle
composer require lexik/jwt-authentication-bundle
php bin/console lexik:jwt:generate-keypair
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600 # 1 heure
# security.yaml — firewall API avec JWT
firewalls:
api:
pattern: ^/api
stateless: true
jwt: ~
json_login:
check_path: /api/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
# Obtenir un token JWT
curl -X POST https://api.example.com/api/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret"}'
# Réponse : {"token":"eyJ0eXAiOiJKV1QiLCJhbGci..."}
# Appel authentifié
curl -X GET https://api.example.com/api/me \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGci..."
Rôles & contrôle d'accès
<?php
// Attribut sur la méthode ou le controller
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController { /* … */ }
#[IsGranted('ROLE_USER')]
#[Route('/profile')]
public function profile(): Response { /* … */ }
// IsGranted avec message
#[IsGranted('ROLE_ADMIN', message: 'Accès réservé aux administrateurs', statusCode: 403)]
public function sensitiveAction(): Response { /* … */ }
// Dans le controller manuellement
if (!$this->isGranted('ROLE_ADMIN')) {
throw $this->createAccessDeniedException();
}
{# Twig #}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('admin_dashboard') }}">Administration</a>
{% endif %}
{% if app.user %}
Bonjour {{ app.user.email }} !
<a href="{{ path('app_logout') }}">Déconnexion</a>
{% else %}
<a href="{{ path('app_login') }}">Connexion</a>
{% endif %}