Cours de Rémi JarjatCours de Rémi Jarjat
  • Liste des cours
  • Culture numérique
  • Git
    • Terminologie
    • Avant de commencer
    • Créer un dépôt (local)
    • Enregistrer des changements
    • Des branches
    • Mise en commun du travail
    • Annuler des changements
    • Réécrire l'historique
    • Des outils pour se simplifier Git
    • Exercices
    • Exemples pratiques
  • Linux
    • Installation
    • Historique
    • Rangement des fichiers
    • Les processus
    • Commandes de base
    • Commandes avancées
    • /linux/6-other-technologies.html
    • Exercices
    • Correction des exercices
  • PHP
    • Environnement de travail
    • Bases du PHP
    • Tests et boucles
    • Procédures et fonctions
    • Interagir avec l'utilisateur
    • La temporisation de sortie
    • PHP Doc et PSR
    • PHP Orienté objet
    • Héritage et objets
    • Factorisation
    • Manipuler la BdD avec PDO (PHP Data Object)
    • Architecture MVC
    • Webservices REST
    • Exercices - Bases
    • Exercices - Séparer en plusieurs fichiers
    • Exercices - POST et SESSION
    • Exercices - Panier et validation
    • Exercices - Objets
    • Exercices - BdD avec PDO
    • Projet - montage d'ordinateurs
    • Projet - Personnages de Jeux de Rôle
  • Symfony
    • Installer Symfony et son environnement de travail
    • Structure et utilisation d'un projet
    • Le routing
    • Les controllers
    • Twig
    • Les services et l'injection de dépendances
    • Doctrine et la BdD
    • Formulaires
    • Les traductions
    • Event listeners/subscribers
    • Connexion et sécurisation
    • Bundles
    • Easy Admin Bundle
    • API Platform
    • Pense-bêtes
    • Symfony au quotidien
    • Travailler avec Docker
    • Projet : annonces de SPA / éleveurs
    • Exercices
  • Javascript
    • Les bases du langage
    • Manipulation logique
    • Le DOM
    • JQuery
    • Ajax
    • Programmation orientée objet
    • Webpack
    • Outils utiles
    • Révisions
  • Serveur Lamp
  • Déploiement
    • Des outils et manières de faire
    • Déploiement par FTP
    • Wordpress
    • Intégrer Git dans le processus
    • GitHub Pages pour déployer facilement
    • Symfony et Angular
  • Docker
  • Intégration continue
  • Sécurité informatique

Connexion et sécurisation

  • Pour résumer
  • Installation et préparation
  • Configuration
  • Connecter un utilisateur
  • Utiliser les droits
    • Security.yaml
    • IsGranted() et is_granted()
  • Récupérer le User connecté
    • Dans un contrôleur
    • Dans un service
    • Dans un template
  • Inscription
  • Gérer les droits avec les Voters

La documentation officielle, que l'on va suivre / reprendre.

⚠️ Cette partie a été écrite pour Symfony 5.3 et plusieurs choses ont été modifiées depuis la version 5.2 (le fonctionnement reste le même, mais plusieurs éléments étaient plus complexes). Vous trouverez une version de présentation de la version 5.2 de Symfony dans cette vidéo.

Pour résumer

  • php bin/console make:user pour créer une classe servant à la connexion
  • php bin/console make:auth pour créer le système de connexion et mettre en place la sécurisation de base
  • Mettre à jour l'Authenticator créé, ainsi que la page de connexion
  • Pour s'assurer des droits d'un utilisateur, on peut utiliser :
    • L'annotation @IsGranted() (Controllers)
    • La fonction is_granted() (Twig)
    • La méthode isGranted() du service Symfony\Component\Security\Core\Security (services)
  • On peut récupérer l'utilisateur connecté avec :
    • $this->getUser() dans un controller étendant AbstractController
    • $this->security->getUser() dans un service où le service Security a été injecté
    • app.user dans une vue Twig
  • php bin/console make:registration-form pour créer automatiquement un formulaire d'inscription

Installation et préparation

Tout d'abord, installer le bundle nécessaire (il est inclus par défaut, sur les projets initialisés avec --webapp) :

composer require symfony/security-bundle

Il faut alors créer une entité pour gérer les utilisateurs (la plupart du temps, on l'appelle User, mais vous pouvez adapter le nom à votre besoin). On utilise la commande make:user pour générer cette entité particulière.

php bin/console make:user

Dans la plupart des cas, nous voulons stocker notre entité User en base (c'est pour cela que je parle d'entité 😉 ), mais sachez que ça n'est pas obligatoire.

Il faut alors déterminer une propriété qui nous servira d'identifiant (une propriété dont la valeur sera unique et que nous afficherons à nos utilisateurs). En général, on crée une propriété username ou email pour cela.

Et bien sûr, si nous stockons les informations en base, il faut demander à Symfony de hacher (hasher en franglais) les mots de passe.

La commande va nous ajouter / modifier plusieurs fichiers :

  • src/Entity/User.php notre entité User
  • src/Repository/UserRepository.php le repository associé (noter la méthode upgradePassword())
  • config/packages/security.yaml met à jour la configuration sur la sécurité, pour prendre en compte notre entité User

Si besoin d'ajouter plus de champs, on peut utiliser la commande make:entity. Il s'agit ensuite de créer une migration, de la vérifier et de la lancer.

On obtient alors une entité User comme celle-ci (je n'ai pas ajouté de propriétés et ma propriété identifiant est email) :

<?php

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)]
abstract 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 = [];

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

    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @deprecated since Symfony 5.3, use getUserIdentifier instead
     */
    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;
    }
}

Je vous conseille ensuite de créer des fixtures (fausses données) pour entrer un ou plusieurs User dans votre base (utiliser la commande make:fixtures de DoctrineFixturesBundle). Pour que les mots de passe soient encodés dans vos Fixtures, il faut bien penser à :

  • injecter le service UserPasswordHasherInterface et l'utiliser pour encoder le mot de passe
  • ou encoder vos mots de passe avec la commande security:encode-password de Symfony avant de les mettre dans vos User

Configuration

La configuration se fait dans le fichier config/packages/security.yaml. Détaillons-le (j'ai également ajouté des éléments utiles pour plus tard) :

security:
    # Pour activer certaines fonctionnalités (expérimentales) de Symfony 
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
    # On définit ici les différents moyens de hasher 
    # nos mots de passe, en fonction des entités
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
        # On dit au composant de Symfony de choisir 
        # l'algorithme (le plus efficace)
        # pour encoder les mots de passe de l'entité User.
        # On pourrait avoir plusieurs entités, avec des encodeurs différents
        App\Entity\User:
            algorithm: auto

    # On défini des providers (fournisseurs) pour dire quels entités 
    # nous servent à définir un utilisateur
    # et quelle propriété nous permet de l'identifier
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
                
    # Les firewalls vont nous permettre de définir différentes règles
    # de sécurité (avec des manières de fonctionner différentes)
    # Dans notre cas, le firewall main va être appliqué lors de l'appel 
    # de toutes nos routes et va vérifier si l'utilisateur
    # peut ou non accéder à une page avec ses autorisations en cours
    # (par exemple, s'il peut accéder à une page sans être connecté)
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            lazy: true
            provider: app_user_provider
            
            # On précise, au composant de sécurité, l'authenticator 
            # à utiliser pour gérer notre connexion
            custom_authenticator: App\Security\Authenticator
            # Plus de détails sur la fonctionnalité "se souvenir de moi" ici : 
            # https://symfony.com/doc/current/security/remember_me.html
            remember_me:
                secret:   '%kernel.secret%'
                lifetime: 604800 # 1 semaine, en secondes
                path:     /
                # Par défaut, cette fonctionnalité n'est activé que si l'utilisateur coche une case
                # Vous pouvez faire en sorte que tout utilisateur connecté le reste 1 semaine
                # en dé-commentant la ligne ci-dessous
                #always_remember_me: true
            
            # Le nom de la route gérant la déconnexion
            # Symfony se charge de déterminer s'il s'agit du nom d'une route ou d'un chemin
            # (j'aurais pu mettre /deconnexion, par exemple)
            logout:
                path: app_logout
                # Vous pouvez également choisir une route où envoyer votre utilisateur après déconnexion
                # target: app_any_route

            # 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

    # Ici, on donne des règles pour demander des rôles précis aux utilisateurs
    # selon une expression régulière sur les chemins demandés.
    # L'intérêt est de demander des droits précis pour accéder à des zones du site
    # (toute la partie admin, ou la gestion du compte, par exemple)
    # Si l'utilisateur n'a pas les bons droits, nous pouvons l'envoyer
    # vers le formulaire de connexion, par exemple
    access_control:
        # On demande à l'utilisateur d'avoir le rôle ROLE_ADMIN, 
        # pour toutes les routes commençant par /admin
        # - { path: ^/admin, roles: ROLE_ADMIN } 
        # On demande à l'utilisateur d'avoir le rôle ROLE_USER, 
        # pour toutes les routes commençant par /profile
        # - { path: ^/profile, roles: ROLE_USER }

Ce fichier est le cœur de la sécurisation de votre site, mais beaucoup d'autres éléments peuvent venir le compléter et le raffiner.

Connecter un utilisateur

Les différents Authentication Providers fournis par Symfony

Des bundles comme le HWIOAuthBundle complètent encore cette liste.

L'un des moyens les plus classiques, le formulaire de connexion, est le cas que nous allons voir. Si vous voulez apprendre en détail comment créer un système de connexion, je vous recommande chaudement la documentation de Symfony sur la création d'une authentification par token d'API.

Pour créer notre système de connexion, nous allons utiliser la commande make:auth qui va nous préparer le travail.

php bin/console make:auth

What style of authentication do you want? [Empty authenticator]:
 [0] Empty authenticator
 [1] Login form authenticator

Ici, on fait le choix 1.

The class name of the authenticator to create (e.g. AppCustomAuthenticator):

Si on suit la documentation, on va entrer LoginFormAuthenticator, mais vous pouvez le nommer comme bon vous semble.

Choose a name for the controller class (e.g. SecurityController) [SecurityController]:

Le nom de SecurityController est le plus courant, et vous le verrez dans beaucoup de projets Symfony.

Do you want to generate a '/logout' URL? (yes/no) [yes]:

Je vous conseille de toujours créer un moyen pour vos utilisateurs de se déconnecter. Ils apprécieront 😉 .

On obtient alors plusieurs fichiers, dont le SecurityController suivant :

// src/Controller/SecurityController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
    #[Route("/login", name: "app_login")]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // if ($this->getUser()) {
        //     return $this->redirectToRoute('target_path');
        // }

        // 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("/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.');
    }
}

Je vous conseille de personnaliser les chemins et les noms des routes, pour correspondre à vos conventions de nommage. Par exemple, je mettrai les chemins en français et renommerai les routes security_login et security_logout. Il faudra bien sûr modifier ces noms à plusieurs endroits (login.html.twig et security.yaml, notamment).

Notez que le fichier login.html.twig contient un formulaire html et non un formulaire Symfony. C'est ici voulu et rien ne vous empêche d'en créer un, si ce n'est de faire extrêmement attention aux noms des champs. Il contient également une section à dé-commenter si vous souhaitez activer la fonctionnalité "Se souvenir de moi".

Il nous reste maintenant à décortiquer / expliquer le LoginFormAuthenticator qui a été généré :

// src/Security/LoginFormAuthenticator.php

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class Authenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    // La route de login par défaut. À adapter à vos besoins (ici, je remplace par security_login, personnellement)
    public const LOGIN_ROUTE = 'app_login';

    // Service de génération d'URL / de chemins
    private UrlGeneratorInterface $urlGenerator;

    // On pourrait injecter ici d'autres services qui nous seraient utiles lors de la connexion (un service qui vérifierait si nous nous connectons depuis une nouvelle IP, par exemple)
    public function __construct(UrlGeneratorInterface $urlGenerator)
    {
        $this->urlGenerator = $urlGenerator;
    }

    public function authenticate(Request $request): PassportInterface
    {
        // On récupère les données envoyées via POST
        // Si vous modifiez les noms des champs de votre formulaire,
        // c'est dans cette méthode qu'il faudra faire les modifications nécessaires
        $email = $request->request->get('email', '');

        $request->getSession()->set(Security::LAST_USERNAME, $email);

        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($request->request->get('password', '')),
            [
                new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
            ]
        );
    }


    // Cette méthode permet de définir le comportement
    // après une connexion réussie.
    // Par défaut, on redirige l'utilisateur vers la page demandée au départ
    // ou une page définie par défaut (souvent, la page d'accueil
    // ou du compte)
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // Par défaut, un utilisateur est renvoyé vers la page où il souhaitait aller.
        // Par exemple, s'il avait demandé la page /admin, sans être connecté, la page de connexion apparait. Une fois ses identifiants entrés et vérifiés, il sera renvoyé vers cette page /admin, automatiquement
        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
            return new RedirectResponse($targetPath);
        }

        // For example:
        //return new RedirectResponse($this->urlGenerator->generate('some_route'));
        throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
    }

    // Pour récupérer l'url (le chemin) de la route de login
    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

⚠️ Ne pas oublier de compléter la méthode onAuthenticationSuccess de notre authenticator et lui donner une url pour rediriger l'utilisateur.

Utiliser les droits

Nos User ont toujours au moins le ROLE_USER (voir leur méthode getRoles()), donc un utilisateur connecté a au moins ce rôle. Voir également la section role_hierarchydans le security.yaml de ce chapitre.

Security.yaml

Avec Symfony, il y a de nombreux moyens de vérifier si un utilisateur peut réaliser une action. Le premier, dans le fichier security.yaml, permet de définir une sécurisation pour des plages d'urls :

    access_control:
        # On demande à l'utilisateur d'avoir le rôle ROLE_ADMIN, 
        # pour toutes les routes commençant par /admin
        - { path: ^/admin, roles: ROLE_ADMIN } 

Cet exemple basique contient l'essentiel, mais vous pouvez aller beaucoup plus loin dans la précision.

IsGranted() et is_granted()

Dans un contrôleur :

    // src/Controller/AdminController.php
    // ...

    use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
   
    /**
     * Pour accéder à TOUTES les méthodes de ce contrôleur, il faut avoir le ROLE_ADMIN
     */
     #[IsGranted("ROLE_ADMIN")]
    class AdminController extends AbstractController
    {
       /**
        * Il faut avoir le rôle ROLE_ADMIN pour cette méthode seulement
        */
        #[IsGranted("ROLE_ADMIN")]
        public function adminDashboard(): Response
        {
            // Fait exactement la même chose que l'annotation au-dessus.
            $this->denyAccessUnlessGranted('ROLE_ADMIN');
            // ...
        }
    }

Dans un template :

{% if is_granted('ROLE_ADMIN') %}
    {# Seuls les admins peuvent voir ceci #}
{% endif %}

Vous pouvez également utiliser cette fonctionnalité dans un service, en injectant le service Symfony\Component\Security\Core\Security :

// src/Newsletter/NewsletterManager.php

// ...
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;

class SalesReportManager
{
    private $security;

    public function __construct(Security $security)
    {
        $this->security = $security;
    }

    public function sendNewsletter()
    {
        $salesData = [];

        if ($this->security->isGranted('ROLE_SALES_ADMIN')) {
            $salesData['top_secret_numbers'] = rand();
        }

        // ...
    }

    // ...
}

Récupérer le User connecté

Dans un contrôleur

public function index(): Response
{
    // Renvoie votre utilisateur (ou null s'il n'est pas connecté)
    // Il est conseillé d'ajouter un commentaire pour que l'IDE connaisse
    // la classe exacte utilisée (par défaut, il voir un objet UserInterface, sans vos méthodes)

    /** @var User $user */    
    $user = $this->getUser();
}

Dans un service

// src/Service/ExampleService.php
// ...

use Symfony\Component\Security\Core\Security;

class ExampleService
{
    private $security;

    public function __construct(Security $security)
    {
        // Il vaut mieux éviter d'appeler le User directement
        // dans le constructeur d'un service.
        // Il pourrait ne pas être correctement initialisé à ce moment-là
        $this->security = $security;
    }

    public function someMethod()
    {
        // Retourne le User (ou null si pas connecté)
        $user = $this->security->getUser();

        // ...
    }
}

Dans un template

app.user Retourne le User (ou null si pas connecté)

Inscription

Pour créer un formulaire d'inscription, vous pouvez créer un formulaire classique ou utiliser la commande : php bin/console make:registration-form (cette solution est recommandée, car plus simple 😉 )

Gérer les droits avec les Voters

Une fonctionnalité très avancée, les Voters, vous permet de gérer les droits aussi finement que vous le souhaitez. Bien que les rôles permettent déjà beaucoup de puissance, il arrive que des droits plus précis soient nécessaires. Par exemple, si vous créez un site pour une grosse entreprise, avec un service marketing et un autre commercial, il se peut que chaque service (et chaque utilisateur de ces services) aient accès seulement à des fonctionnalités précises.

Pour ces cas, bien plus complexes, de gestion des ACL (Access Control Lists), je vous recommande très fortement la documentation Symfony sur les voters et vous pouvez aller encore plus loin, avec la documentation sur le processus d'autorisation de Symfony

Dernières mise à jour :
Prev
Event listeners/subscribers
Next
Bundles