Apprendre Symfony 6 : Créer un espace membre

Inscription, connexion, déconnexion… Découvrons pas à pas comment créer un espace membre avec Symfony.

Icône de calendrier
Intermédiaire
13 chapitres

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.

copié !
composer require symfony/security-bundle

Ce bundle sera paramétrable depuis le fichier 📄 config/packages/security.yaml.

security.yaml
copié !
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 :

copié !
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).

User.php
copié !
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.yaml
copié !
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 application
  • property: 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.yaml
copié !
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.

copié !
symfony console make:migration
copié !
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 :

copié !
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() :

RegistrationController.php
copié !
<?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 :

  • Email
  • Acceptation des termes
  • Mot de passe
RegistrationFormType.php
copié !
<?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.

register.html.twig
copié !
{% 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.yaml
copié !
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 :

copié !
symfony console make:controller Security

Éditer le contrôleur📄 SecurityController.php en y ajoutant l’import de la classe 📄 AuthenticationUtils.php.

SecurityController.php
copié !
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

Remplacer la méthode index() par la méthode login() suivante :

SecurityController.php
copié !
#[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 :

security/login.html.twig
copié !
{% 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.yaml
copié !
security:
	# ...

	firewalls:
		secured_area:
			# ...
			form_login:
				# ...
				enable_csrf: true

Enfin, ajoutons le champ caché dans le formulaire 📄 login.html.twig :

login.html.twig
copié !
<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.yaml
copié !
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.yaml
copié !
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.

SecurityController.php
copié !
#[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.yaml
copié !
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.

User.php
copié !
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.yaml
copié !
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ôle ROLE_USER.
  • Les utilisateurs avec le rôle ROLE_ADMIN ont aussi le rôle ROLE_ALLOWED_TO_SWITCH et par extension les rôles ROLE_AUTHOR et ROLE_USER.

Sécuriser les URLs

Une fois nos rôles définis, nous allons pouvoir définir au niveau du pare-feu :

  1. Quels patterns d’URL protéger
  2. Quels rôles sont nécessaires pour chaque pattern

Pour cela, il faut éditer le fichier de configuration 📄 security.yaml :

security.yaml
copié !
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.

copié !
{% 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.