Apprendre Symfony 6 : Créer un espace membre
Inscription, connexion, déconnexion… Découvrons pas à pas comment créer un espace membre avec Symfony.
SecurityBundle
Nombreux sont les sites web nécessitant la mise en place d’un espace membre. Inscription, connexion, déconnexion… Ces opérations de routines sont paramétrables depuis le bundle Symfony « SecurityBundle ».
’
Il est possible d’installer ce bundle via composer. Notez qu’il est par défaut installé à l’initialisation d’un projet Symfony avec le flag --webapp
.
composer require symfony/security-bundle
Ce bundle sera paramétrable depuis le fichier 📄 config/packages/security.yaml
.
security:
enable_authenticator_manager: true
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
users_in_memory: { memory: null }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#firewalls-authentication
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
Utilisateurs
Création de l’entité User
Pas d’espace membre possible sans utilisateur. Commençons par créer une entité 📄 User.php
avec le MakerBundle :
symfony console make:user
Réponses à saisir :
The name of the security user class (e.g. User) [User]:
> User
Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes
Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
> email
Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes
Cette ligne de commande se comporte dans un premier temps comme une sorte de surcouche au make:entity User
, puisqu’elle va implémenter UserInterface
et PasswordAuthenticatedUserInterface
. Elle génère une entité avec des champs utilisateur élémentaires (id
, email
, roles
, password
).
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @ORM\Entity(repositoryClass=UserRepository::class)
*/
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=180, unique=true)
*/
private $email;
/**
* @ORM\Column(type="json")
*/
private $roles = [];
/**
* @var string The hashed password
* @ORM\Column(type="string")
*/
private $password;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/**
* The public representation of the user (e.g. a username, an email address, etc.)
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @deprecated since Symfony 5.3
*/
public function getUsername(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* Returning a salt is only needed if you are not using a modern
* hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
*
* @see UserInterface
*/
public function getSalt(): ?string
{
return null;
}
/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
}
User Provider
La commande make:user
va dans un second temps ajouter une configuration initiale pour le « provider » utilisé.
Le User Provider est un service dédié au chargement des utilisateurs à partir d’un identifiant (e-mail, username…).
Il est notamment utilisé pour la connexion, la fonction « se souvenir de moi », le chargement des données de session d’un utilisateur, etc.
4 User Providers sont fournis par Symfony :
Entity User provider
Charge les utilisateurs stockés en base de données via Doctrine.
LDAP User Provider
Charge les utilisateurs depuis un serveur LDAP (Lightweight Directory Access Protocol).
Memory User Provider
Charge les utilisateurs depuis un fichier de configuration.
Chain User Provider
Fusionne deux ou davantage de user providers en un nouveau.
Nous utiliserons dans notre cas Entity User Provider, car les utilisateurs seront stockés en base de données.
Ce bout de code a ainsi été ajouté par le make:user
:
security:
# ...
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
class: App\Entity\User
spécifie l’entité définissant les utilisateurs de notre applicationproperty: email
spécifie la propriété unique utilisée pour identifier un utilisateur
De la même manière, le provider par défaut users_in_memory
utilisé par le pare-feu a été remplacé par app_user_provider
. Nous reviendrons sur cela lors de l’étape dédiée à l’authentification.
security:
# ...
firewalls:
# ...
main:
lazy: true
provider: app_user_provider
Migration
Générons et appliquons une migration en base de données afin de reproduire notre modèle d’entité User dans une table dédiée en base de données.
symfony console make:migration
symfony console doctrine:migrations:migrate
Inscription
L’inscription d’un utilisateur est elle aussi grandement simplifiée grâce au MakerBundle. Taper la ligne de commande :
symfony console make:registration-form
Une fois l’utilisateur enregistré, il vous est demandé de définir vers quelle route le rediriger. Ecrivez l’indice de la route noté entre crochets :
[0 ] _preview_error
[1 ] _wdt
[2 ] _profiler_home
[3 ] _profiler_search
[4 ] _profiler_search_bar
[5 ] _profiler_phpinfo
[6 ] _profiler_xdebug
[7 ] _profiler_search_results
[8 ] _profiler_open_file
[9 ] _profiler
[10] _profiler_router
[11] _profiler_exception
[12] _profiler_exception_css
[13] app_demo <-- route perso
Cette commande va créer et configurer plusieurs fichiers pour mettre en place le système d’inscription : contrôleur, form type, template et hasher.
Contrôleur : enregistrement d’un utilisateur
Le contrôleur 📄 RegistrationController.php
a été créé. Il contient une méthode register()
:
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
class RegistrationController extends AbstractController
{
#[Route('/register', name:'app_register')]
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager): Response
{
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// encode the plain password
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
)
);
$entityManager->persist($user);
$entityManager->flush();
// do anything else you need here, like send an email
return $this->redirectToRoute('ma_route');
}
return $this->render('registration/register.html.twig', [
'registrationForm' => $form->createView(),
]);
}
}
$this->redirectToRoute('ma_route')
redirige l’utilisateur vers la route spécifiée lors de la saisie via l’invite de commande. Vous pouvez la modifier à tout moment ici.
Form type
Un form type 📄 RegistrationFormType.php
a été créé, définissant les champs du formulaire d’inscription :
- Acceptation des termes
- Mot de passe
<?php
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email')
->add('agreeTerms', CheckboxType::class, [
'mapped' => false,
'constraints' => [
new IsTrue([
'message' => 'You should agree to our terms.',
]),
],
])
->add('plainPassword', PasswordType::class, [
// instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
Affichage du formulaire d’inscription
Le form type précédemment créé est appelé depuis le template 📄 register.html.twig
.
{% extends 'base.html.twig' %}
{% block title %}Register{% endblock %}
{% block body %}
<h1>Register</h1>
{{ form_start(registrationForm) }}
{{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.plainPassword, {
label: 'Password'
}) }}
{{ form_row(registrationForm.agreeTerms) }}
<button type="submit" class="btn">Register</button>
{{ form_end(registrationForm) }}
{% endblock %}
Hashage du mot de passe
L’algorithme utilisé pour hasher le mot de passe est spécifié dans le fichier de configuration 📄 security.yaml
. Il s’agit à ce jour de l’algorithme bcrypt.
security:
# ...
password_hashers:
# Use native password hasher, which auto-selects and migrates the best
# possible hashing algorithm (starting from Symfony 5.3 this is "bcrypt")
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
Si vous complétez le formulaire d’inscription à l’adresse /register
, vous pourrez voir apparaître un premier utilisateur dans votre base de données ! 🎉
Connexion
Contrôleur : processus de connexion
Commençons par créer un contrôleur 📄 SecurityController.php
dédié à l’authentification :
symfony console make:controller Security
Éditer le contrôleur📄 SecurityController.php
en y ajoutant l’import de la classe 📄 AuthenticationUtils.php
.
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
Remplacer la méthode index()
par la méthode login()
suivante :
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response {
// 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,
]);
}
Cette méthode retourne le formulaire de connexion avec :
- Des erreurs de login potentielles via la méthode
getLastAuthenticationError()
. - Le nom d’utilisateur saisi dans le formulaire via la méthode
getLastUsername()
.
Affichage du formulaire de connexion
Éditer le template 📄 security/login.html.twig
:
{% extends 'base.html.twig' %}
{# ... #}
{% block body %}
{% if error %}
<div>{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
<form action="{{ path('app_login') }}" method="post">
<label for="username">Email:</label>
<input type="text" id="username" name="_username" value="{{ last_username }}"/>
<label for="password">Password:</label>
<input type="password" id="password" name="_password"/>
{# If you want to control the URL the user is redirected to on success
<input type="hidden" name="_target_path" value="/account"/> #}
<button type="submit">login</button>
</form>
{% endblock %}
Protection CSRF
La faille CSRF (Cross-Site Request Forgery) est liée à une vulnérabilité de l’authentification d’un site web. Elle consiste à faire exécuter à un utilisateur authentifié d’un site web une requête HTTP (souvent à travers la transmission d’une URL) afin qu’il exécute une action interne du site (par exemple la suppression d’une ressource) sans s’en rendre compte.
Pour se protéger contre les attaques CSRF, une mesure de sécurité courante consiste à demander au serveur de générer aléatoirement un jeton unique nommé token CSRF, puis de le transmettre au client. Lors de la soumission d’un formulaire, ce token est envoyé au serveur via un champ de formulaire caché. Sa présence et validité permettront ainsi d’attester que l’utilisateur effectue bien cette action de son plein gré. En savoir plus.
Activons là pour notre formulaire de connexion dans 📄 security.yaml
.
security:
# ...
firewalls:
secured_area:
# ...
form_login:
# ...
enable_csrf: true
Enfin, ajoutons le champ caché dans le formulaire 📄 login.html.twig
:
<form action="{{ path('app_login') }}" method="post">
...
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">login</button>
</form>
Firewall
Le firewall, en français pare-feu, est au cœur de l’authentification d’une application Symfony. Il va :
- Analyser chaque requête afin de savoir si elle nécessite une authentification.
- Permettre de configurer le mode d’authentification souhaité ainsi que son fonctionnement.
security:
# ...
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
Nous avons ici 2 firewalls :
- Firewall
dev
: son rôle est de ne pas bloquer les outils de Symfony en ne requérant pas d’authentification pour y accéder. - Firewall
main
: son rôle est de démarrer une session pour toutes les URLs nécessitant d’être loguées (l’absence de clépattern
signifie « toutes les URLs »).
Activer l’authentification via le formulaire de connexion en ajoutant l’extrait suivant dans le fichier 📄 security.yaml
:
security:
# ...
firewalls:
main:
# ...
form_login:
# "app_login" is the name of the route created previously
login_path: app_login
check_path: app_login
Si vous complétez le formulaire de connexion à l’adresse /login
, vous pourrez vous authentifier à votre application ! 🎉
Vous verrez d’ailleurs apparaître dans la partie dédiée à l’authentification du Profiler votre nom d’utilisateur au lieu de N/A
(sauf si vous êtes sur une page 404, comme c’est le cas de la « Starter Page » par défaut de Symfony).
Déconnexion
Pour déconnecter un utilisateur, il faut dans un premier temps créer une route associée au chemin /logout
. La particularité de cette route est qu’il n’est pas nécessaire de lui implémenter de logique via une méthode de contrôleur. En ce sens, on créera notre route en dessous de /login
, dans SecurityController.php
.
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
public function logout(): never {}
Ensuite, on édite le fichier de configuration en spécifiant la route associée à la déconnexion :
security:
# ...
firewalls:
main:
# ...
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
Contrôle d’accès
Le concept d’autorisation permet de restreindre l’accès à une ressource.
Rôles
Mécanique des rôles
Lorsqu’un utilisateur se connecte au site web, Symfony appelle la méthode getRoles()
sur l’entité User
afin de récupérer les rôles de l’utilisateur en question. Chaque utilisateur connecté se voit par défaut toujours attribuer au moins le rôle : ROLE_USER
.
Ces rôles sont stockés en base de données sous forme de tableau, dans la colonne roles
de la table user
.
class User {
/**
* @ORM\Column(type="json")
*/
private $roles = [];
// ...
public function getRoles(): array {
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
}
Vous pourrez créer des rôles sur-mesure en fonction des besoins de votre application. Par exemple : ROLE_AUTHOR
.
Vous utiliserez ensuite ces rôles pour accorder l’accès à des sections spécifiques de votre site.
Hiérarchie des rôles
Au lieu de donner plusieurs rôles à chaque utilisateur, il est d’usage de définir une hiérarchie de rôles en définissant des règles d’héritage dans le fichier 📄 security.yaml
:
security:
# ...
role_hierarchy:
ROLE_AUTHOR: ROLE_USER
ROLE_ADMIN: [ROLE_AUTHOR, ROLE_ALLOWED_TO_SWITCH]
- Les utilisateurs avec le rôle
ROLE_AUTHOR
ont aussi le rôleROLE_USER
. - Les utilisateurs avec le rôle
ROLE_ADMIN
ont aussi le rôleROLE_ALLOWED_TO_SWITCH
et par extension les rôlesROLE_AUTHOR
etROLE_USER
.
Sécuriser les URLs
Une fois nos rôles définis, nous allons pouvoir définir au niveau du pare-feu :
- Quels patterns d’URL protéger
- Quels rôles sont nécessaires pour chaque pattern
Pour cela, il faut éditer le fichier de configuration 📄 security.yaml
:
security:
# ...
firewalls:
# ...
access_control:
# Les admin peuvent accéder à /admin*
- { path: '^/admin', roles: ROLE_ADMIN }
# Les admin et auteurs peuvent accéder à /admin/formation* et /admin/category*
- { path: '^/admin/(formation|category)', roles: ROLE_AUTHOR }
# Les utilisateurs authentifiés peuvent accéder à /formations*
- { path: '^/formations', roles: ROLE_USER }
# Les utilisateurs authentifiés peuvent accéder aux URLs de la forme /api/formations/7298 et /api/category/528491
- { path: '^/api/(formations|category)/\d+$', roles: ROLE_USER }
path
: permet de définir le pattern d’URL concerné via une expression régulière. Si plusieurs patterns matchent avec l’URL, alors Symfony s’arrêtera au premier valide. L’ordre des règles de contrôle a donc une importance. Seul un pattern va matcher pour une requête.
roles
: la liste des rôles autorisés à accéder à la ressource. Ici je n’ai pas besoin de préciser plusieurs rôles pour un pattern car une hiérarchie de rôle a été mise en place. En revanche, il est tout à fait possible de spécifier un tableau de rôles en valeur. Exemple :roles: [ROLE_1, ROLE_2]
Sécuriser les templates
La fonction Twig is_granted()
permet d’afficher une portion de template, de manière conditionnée par le rôle d’un utilisateur.
{% if is_granted('ROLE_AUTHOR') %}
<a href="#">Editer</a>
<a href="#">Supprimer</a>
{% endif %}
Pour aller plus loin, je vous invite à consulter la documentation officielle du SecurityBundle.