TP n°8 - Sessions, gestion des utilisateurs et autorisations

Table des matières

1. Introduction

Cette séquence est consacrée à deux ensembles de fonctionnalités nécessaires dans les applications Web:

une gestion de sessions permettant à chaque utilisateur d’interagir avec l’application, dans un contexte qui lui est propre;

la mise en œuvre d’une gestion de comptes utilisateurs, avec des profils utilisateur, et des permissions pour contrôler l’accès aux différentes fonctions de l’application.

Une partie des indications données dans le présent support sont orientées vers une utilisation sous Linux. Pour Windows, il conviendra d’adapter les commandes.

2. Étape 1 : Ajout de « messages flash » aux formulaires

L’objectif de cette étape est d’expérimenter l’utilisation des messages flash de Symfony, pour comprendre le fonctionnement de la session.

Dans la séquence de travail précédente, vous avez étudié le fonctionnement du mécanisme de gestion de la soumission de données dans les formulaires.

À la fin des traitements qui se déroulent avec succès, la gestion faite dans les contrôleurs envoie un code de réponse HTTP qui entraîne une redirection effectuée par le navigateur, pour recharger une autre page.

Or cette redirection, qui entraîne une nouvelle requête, donc l’exécution d’une nouvelle instance de l’application, fait perdre le contexte de l’exécution précédente.

Grâce aux sessions, on peut cependant mettre en oeuvre une continuité entre le traîtement de la requête de soumission de données, avant la redirection, et la gestion de la requête après redirection, par exemple si l’on doit afficher un message à l’utilisateur.

2.1. Principe des « messages flash » Symfony

Dans la mise en œuvre des gestionnaires de soumissions des formulaires, on effectue des redirections, une fois les requêtes de soumission traitées. Or le rechargement qui est effectué dans le navigateur de la nouvelle page vers laquelle on est redirigé, masque les messages éventuellement affichés sur la page de résultat du traitement de la requête précédente.

On va utiliser les Flash Messages de la session Symfony, pour mettre en attente des messages qui seront affichés dès que possible, c’est-à-dire au prochain affichage d’un gabarit de page de l’application.

En pratique, le code suivant permet de stocker un message flash de type « notice », dans le « sac » (Bag ou multi-ensemble) des messages flash de la session :

//$request->getSession()->getFlashBag()->add('notice', 'blah blah');
$this->addFlash('notice', 'blah blah');

Pour le retrouver, dans les templates Twig, il suffit d’accéder à la liste des messages en attente, qui est en fait stocké dans la session courante (app.session) :

{# would work: app.session.flashBag.get('notice') #}
{% for message in app.flashes('notice') %}
    {{ message }}
{% endfor %}

Comme ils sont stockés dans la session, ces messages peuvent survivre à la redirection qui est provoquée en fin de traitement des requêtes POST.

Si les messages sont sauvés dans la session avant la redirection, on les y retrouve à l’exécution suivante pour traiter la requête GET post-redirection.

Vous allez ajouter une gestion de messages de confirmation, sur le résultat du traitement des données soumises via les formulaires CRUD : sauvegarde effectuée, confirmation d’ajout, etc.

Ces messages seront mis en oeuvre via des « messages flash » Symfony.

2.2. TODO Étape 1-a : Ajout des messages flash au code PHP et Twig

Procédez aux étapes suivantes :

  1. Dans le code d’un des gestionnaires de soumissions de formulaire (pour la création des Pastes, par exemple), ajoutez un « message flash », juste avant la redirection :

    Par exemple :

    public function new(Request $request, EntityManagerInterface $entityManager): Response
    {
         // ...
    
         if ($form->isSubmitted() && $form->isValid()) {
                 $entityManager->persist($paste);
                 $entityManager->flush();
    
                 // Make sure message will be displayed after redirect
                 $this->addFlash('message', 'bien ajouté');
                 // $this->addFlash() is equivalent to $request->getSession()->getFlashBag()->add()
    
                 return $this->redirectToRoute('app_paste_index', [], Response::HTTP_SEE_OTHER);
         }
    
         // ...
    }
    
  2. Ajoutez dans le template Twig de base (base.html.twig), le code du bloc Twig alerts ci-dessous, pour ajouter l’affichage des messages flashs.

    Insérez-le bloc alerts juste au-dessus de la section <main>, pour obtenir quelque chose comme :

    {% block body %}
    
       <div class="container body-container">
         <div class="row">
           <div id="main" class="col-md-12">
    
             {% block alerts %}
                {# Borrowed from the demo app :
                   https://github.com/symfony/demo/blob/v2.4.0/templates/base.html.twig #}
                {#
                   The check is needed to prevent starting the session when looking for "flash messages":
                   https://symfony.com/doc/current/session.html#avoid-starting-sessions-for-anonymous-users
                #}
                {% if app.request.hasPreviousSession %}
                   <div class="messages">
                      {% for type, messages in app.flashes %}
                         {% for message in messages %}
                            {# Bootstrap alert, see https://getbootstrap.com/docs/3.4/components/#alerts #}
                            {%if type == 'error'%} {% set type = 'danger' %} {%endif%}
                            {%if type == 'message'%} {% set type = 'info' %} {%endif%}
                            <div class="alert alert-{{ type }} alert-dismissible fade show" role="alert">
                                <div>{{ message|trans }}</div>
                                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{{ 'action.close'|trans }}"></button>
                            </div>
                         {% endfor %}
                      {% endfor %}
                   </div> {# .messages #}
                {% endif %}
             {% endblock %} {# alerts #}
    
             <main>
    
                {# Ici la partie utile que les gabarits des pages vont surcharger #}
                {% block main %}
                                  <div class="row">
                                    <div class="col-md-12">
                                      <p>
                                        <i>MAIN</i>
                                      </p>
                                    </div>
                                  </div>
                {% endblock %} {# main #}
    
             </main>
           </div> <!-- main -->
    
         </div> <!-- row -->
    
       </div> <!-- /.body-container -->
    
       ...
    
    {% endblock %} {# body #}
    ...
    

    Le code Twig de ce block alerts accède à tous les messages en attente dans le flashbag de la session, et affiche des boutons Bootstrap correspondant au type de message.

2.3. TODO Étape 1-b : Test de fonctionnement des messages

Maintenant, testez l’application ainsi modifiée en créant une nouvelle Paste dans la base de données :

  1. Ouvrez une « fenêtre de navigation privée » dans le navigateur Firefox
  2. Avant de charger une page de l’application, ouvrez le moniteur réseau, dans les outils du développeur.

    La navigation privée permet de simuler une première connexion à l’application dans une session de navigation complètement nouvelle, sans conserver des cookies précédemment enregistrés par le navigateur.

    Cela a pour conséquence, côté serveur, de regénérer une nouvelle session Symfony.

  3. Connectez-vous à la page d’ajout de nouvelle Paste de l’application. Regardez les détails des en-tête de réponse à la requête GET d’affichage du formulaire :

    set-cookie.png

    Figure 1 : En-tête Set-Cookie en réponse à l’affichage du formulaire

    La gestion du formulaire a besoin de gérer une session Symfony, qui s’appuie sur la session PHP. Le serveur répond donc au client qu’il doit stocker un identifiant de session PHP, dans un cookie appelé PHPSESSID.

  4. Consultez les paramètres de la session dans le Profiler. Dans le panneau « Requêtes / Réponses » vous voyez que la session est effectivement utilisée :

    session-params-1.png

    Figure 2 : En-tête Set-Cookie en réponse à l’affichage du formulaire

    Elle contient en-effet un élément : _csrf/paste nécessaire au bon fonctionnement du formulaire (Cf. mécanisme CSRF vu en cours).

  5. Remplissez le formulaire et soumettez-le. Une fois que la soumission est bien effectuée, la page de liste des Pastes se raffiche (après traitement et redirection).

    Le message « bien ajouté » est bien affiché en haut d’écran.

  6. Ouvrez le Profiler à nouveau et remontez jusqu’au traitement de la requête POST de soumission du formulaire :

    profiler-session-bag.png

    Figure 3 : Message flash en attente dans la session

    on voit le résultat de l’appel à addFlash() dans le traitement de la soumission, qui a bien stocké ce message dans la session Symfony.

    Il est en attente de récupération par l’exécution du foreach sur app.flashes dans le gabarit pour l’affichage de la page suivante, qui viendra après la redirection.

Maintenant que le gabarit de base affiche des messages flash s’ils sont présents, autant ne pas s’en priver.

On peut désormais ajouter des messages flash de confirmation dans tous les traitements de formulaires.

3. Étape 2 : Mise en œuvre des « marque-pages » de tâches prioritaires, grâce à la session

L’objectif de cette séquence est de mettre en œuvre dans l’application une gestion des « marque-pages », en s’appuyant sur le mécanismes des sessions.

Imaginons qu’on souhaite pouvoir utiliser des « marque-pages » qui permettent de naviguer dans la liste de tâches et de marquer des tâches comme étant prioritaires, au fur et à mesure de la navigation.

Cette fonction ressemble aux « paniers » des sites d’e-commerce qui permettent de préparer des achats, en mémorisant des articles dans un panier, même sans s’être encore connecté au site.

On va voir qu’on peut ajouter cela sans modifier le modèle de données, ni la base de données, grâce à la session.

Les visiteurs devront donc pouvoir « marquer » les tâches qu’ils jugent urgentes/prioritaires, dans leur session, au fur et à mesure de leur visite sur le site, pour les retrouver ultérieurement.

Mais cette information sera inconnue des autres visiteurs, car chaque session est propre à un navigateur Web.

3.1. Principe de sauvegarde dans la session

Si on déploie notre application de gestion de tâches sur un serveur, afin que les membres d’une équipe puissent partager une liste de tâches, la même base de données servira à enregistrer la liste de toutes les tâches, de tous les utilisateurs, mélangées.

Dans la version actuelle de notre modèle de données, en effet, on ne gère pas de comptes, d’authentification, donc tout le monde a accès à l’ensemble des tâches de la base de données.

On va tirer parti du mécanisme de sessions Symfony pour reconnaître le visiteur (ou plutôt son client HTTP, son navigateur) et afficher ainsi des données propres à ce visiteur.

Ces données seront connues du code de notre application, et dépendront des requêtes précédentes quand ce même utilisateur a visité des pages sur le site.

Mais ces données seront partagées uniquement avec l’exécution courante de l’application, dans le contexte de cette session.

Aucune trace ne sera laissée dans la base de données.

Aucune des données créées ainsi pour un visiteur ne sera accessible à un autre visiteur, qui utilise un navigateur distinct.

Ces concepts de cookies et de sessions Symfony qui ont été abordés en cours.

On peut stocker par exemple dans la session Symfony (qui est déjà utilisée pour les « messages flash ») des références à des tâches, en stockant dans la session d’une liste d’identifiants des tâches urgentes, pour un utilisateur donné.

Exemple de récupération, en mémoire, d’une liste d’identifiants de tâches urgentes précédemment stockée dans la session via la clé urgents :

$urgents = $request->getSession()->get('urgents');
foreach ($urgents as $id) {
    ...

Exemple de sauvegarde de la même liste (vide) dans la session, pour la clé urgents :

$urgents = array();
$request->getSession()->set('urgents', $urgents);

3.2. TODO Étape 2-a : Ajout d’une méthode de marquage de tâche urgente

Vous allez ajouter de nouvelles routes permettant de mettre en œuvre une gestion des marque-pages / tâches urgentes dans un contrôleur de l’application.

Procédez aux opérations suivantes :

  1. Ajoutez dans le code PHP du contrôleur des tâches, une nouvelle méthode gérant l’invocation d’une nouvelle route.

    Vous pouvez partir du modèle suivant, qui ressemble pour l’instant à la consultation d’une tâche:

    /**
     * Mark a task as current priority in the user's session
     * 
     */
    #[Route('/mark/{id}', name: 'todo_mark', requirements: ['id' => '\d+'], methods: ['GET'])]
    public function markAction(Request $request, Todo $todo): Response
    {
         dump($todo);
         return $this->redirectToRoute('todo_show', 
              ['id' => $todo->getId()]);
    }
    
  2. Ajoutez, dans le gabarit de consultation d’une tâche todo/show.html.twig un lien vers la route todo_mark pour la même tâche.

    Vous pouvez reprendre et adapter le code de la balise <a href du lien de consultation de tâche, qui pointe vers la route todo_show (dans le gabarit de liste des tâches « todo/index.html.twig »).

  3. Modifiez ce code. Au lieu d’afficher les détails, le but est de mémoriser cette tâche dans les tâches « urgentes » de la session, et rediriger ensuite vers l’affichage de la tâche.

    Dans le corps de la méthode, gérez la mémorisation de l’identifiant de la tâche dans un tableau qui sera stocké dans la session. Le stockage du tableau urgents dans la session obéit par exemple à ce type d’algorithme :

    public function markAction(Request $request, Todo $todo): Response
    {
        dump($todo);
    
        // Récupération du tableau d'id urgents dans la session
        $urgents = $request->getSession()->get('urgents');
        dump($urgents);
        if( ! is_array($urgents) ) {
            $urgents = array();
        }
    
        // TODO: mémoriser dans la liste des urgents
    
        $this->addFlash('message', 'marqué');
    
        dump($urgents);
    
        // Sauvegarde du tableau d'id urgents dans la session
        $request->getSession()->set('urgents', $urgents);
    
        return $this->redirectToRoute('todo_show',
                                      ['id' => $todo->getId()]);
    }
    
  4. Testez le fonctionnement de l’appel à cette méthode.

    Consultez le contenu des variables présentes dans la session, avec les outils du profiler Symfony (le contenu de la session est visible dans les détails des requêtes/réponses).

    Retrouvez-vous la variable urgents ? Elle reste désespérément vide, pour l’instant.

  5. Modifiez le code pour stocker l’identifiant dans ce tableau urgents (au niveau du commentaire « TODO ») :

    // DONE: mémoriser dans la liste des urgents
    $id = $todo->getId();
    $urgents[]=$id;
    
    $this->addFlash('message', 'id' . $id . ' bien marké');
    
    
  6. Vérifiez que ça semble marger, cette fois, avec l’identifiant qui est bien sauvegardé dans la session.

3.3. TODO Étape 2-b : Amélioration de l’interface pour gérer les tâches prioritaires

Une fois que vous aurez ajouté ce mécanisme de « marque-page », modifiez la page de consultation d’une tâche, pour y ajouter un indicateur visuel, si elle est marquée comme urgente par l’utilisateur.

  1. Modifiez todo/show.html.twig pour changer l’affichage selon si l’identifiant de la tâche est dans le tableau sauvegardé dans la session.

    Par exemple le tests suivant, en syntaxe Twig, permet d’avoir un un lien qui donne l’état actuel, et permet de le changer :

    {% set urgents = app.session.get('urgents') %}
    {% dump(urgents) %}
    
    <a href="{{ path('todo_mark' , {'id': todo.id}) }}">
    {% if todo.id in urgents %}
    URGENT
    {% else %}
    NOT URGENT
    {% endif %}
    </a>
    
  2. Vérifiez en consultant une autre tâche, qu’elle apparaît initialement comme non-urgente, puis urgente une fois qu’on a cliqué sur le lien.
  3. Lancez une nouvelle fenêtre navigation privée du navigateur, puis chargez l’affichage de la même tâche urgence.

    Vous devriez constater qu’elle est cette-fois affichée comme non-urgente.

    Cette liste est propre à chaque « session » de navigation, liée au cookie de session PHP.

    Comme il est réinitialisé en navigation privée, on perd l’accès à la session précédente. Le tableau des tâches urgentes est différent.

3.4. TODO Étape 2-c : Réglage des derniers petits détails

Le code semble fonctionner, mais sous le capot il y a quand même quelques détails qu’on peut améliorer.

  1. Cliquez plusieurs fois de suite sur le bouton urgent pour la même tâche, puis consultez le contenu de la session, dans le Profiler, ou dans le dump() d’urgents.

    Vous devriez constater qu’il y a des doublons sur le numéro d’identifiant.

    Ce n’est pas idéal, même si ça ne bloque pas le fonctionnement actuel du test {% if todo.id in urgents %}.

    On va corriger ça.

  2. Modifiez le code de markAction() pour régler le défaut ci-dessus, et faire en sorte que le marque-page fonctionne comme un interrupteur booléen.

    Si on accède une deuxième fois à la même route, on annule le marque-page : on supprime l’identifiant du tableau des tâches urgentes.

    Exemple de code de changement de l’urgence associée à une tâche d’identifiant donné (utilisant la fonction PHP array_diff()):

    $id = $todo->getId();
    // si l'identifiant n'est pas présent dans le tableau des urgents, l'ajouter
    if (! in_array($id, $urgents) ) 
    {
        $urgents[] = $id;
    }
    else
    // sinon, le retirer du tableau
    {
        // substract two arrays
        $urgents = array_diff($urgents, array($id));
    }
    

Vérifiez que ça marche, et qu’il n’y a plus de scories dans la session. Vous vous aiderez encore une fois des outils intégrés à la barre d’outils de Symfony, de dump( ), pour examiner l’impact sur la session, et les requêtes.

Notez bien les points suivants :

  • la base de données n’est pas impactée par ce mécanisme de marque-pages. Vous n’avez normalement vu aucune requête INSERT ou UPDATE tout au long de ces « marquages » de tâches urgentes;
  • la notion d’urgence est donc propre à une certain session, pendant le temps où un visiteur consulte l’application avec un même navigateur Web;
  • tant qu’un cookie de session n’est pas fourni par un utilisateur, impossible de retrouver sa session;
  • il est donc impossible pour le serveur de traîter les tâches urgentes en bloc, pour tous les utilisateurs confondus;
  • impossible aussi de déterminer si une tâche n’est jugée prioritaire par personne.

Si on veut faire des traitements sur les données entre utilisateurs différents, ou sur tous les utilisateurs, alors il faudrait que cette informatio soit ajoutée au modèle des données et stockée dans la base de données partagée entre tous les utilisateurs (et le serveur).

Il reste quand même quelque chose d’un peu mystérieux : où est stockée cette session, pour qu’on puisse la récupérer entre deux exécutions consécutives de l’application par un même visiteur. On détaille cela dans la section suivante optionnelle.

3.5. Consultation des fichiers de stockage de la session côté serveur HTTP/PHP (optionnel)

  1. Lancez l’application, et utilisez le marque-page pour marquer une tâche urgente
  2. Allez dans les outils du Profiler Symfony. Dans l’écran « Configuration », puis dans la section « PHP Configuration », cliquez sur « View full PHP configuration ».
  3. Cherchez la valeur de session.save_path. Cela vous donne l’emplacement des fichiers de sauvegarde des sessions du serveur PHP, par exemple /var/lib/php/sessions (sur Linux).
  4. Lancez un autre terminal en parallèle de l’exécution du serveur Web, et listez le contenu du répertoire de stockage des sessions :

    $ ls -alrt /var/lib/php/sessions
    ls: impossible d'ouvrir le répertoire '/var/lib/php/sessions': Permission non accordée
    
    

    le répertoire n’est pas accessible, par défaut, à n’importe qui, pour éviter du vol de données sensibles des applications Web s’exécutant sur le serveur.

    Si vous le pouvez, utilisez sudo pour forcer la lecture (sur machines personnelles) :

    $ sudo ls -alrt /var/lib/php/sessions
    [sudo] Mot de passe de olivier : 
    total 12
    drwxr-xr-x 4 root    root    4096 30 avril  2018 ..
    drwx-wx-wt 2 root    root    4096  9 oct.  13:07 .
    -rw------- 1 olivier olivier  266  9 oct.  14:00 sess_dosclgq3dt6v7uaetnjq1n6g60
    
  5. Consultez le fichier de session (puisque vous en avez les droits… oui, vous ne savez pas quel nom il a, sans l’aide de sudo, mais ensuite vous pouvez le consulter… enfin, comme le processus php-cli que vous faites tourner).

    Vous verrez qu’il contient des sérialisations (dans un format proche du JSON) des données de la session courante, dont le tableau d’identifiants stocké dans urgents :

    $ cat /var/lib/php/sessions/sess_dosclgq3dt6v7uaetnjq1n6g60
    _sf2_attributes|a:2:{s:13:"_csrf/delete4";s:43:"6U_2ctZWxuSkaIWPnWUxjb00G6V5HlJEd1eBcHbndjs";s:7:"urgents";a:1:{i:0;i:4;}}_sf2_meta|a:3:{s:1:"u";i:1602763751;s:1:"c";i:1602763749;s:1:"l";s:1:"0";}
    

Vous constatez que la session peut donc être gérée dans un fichier stocké sur le serveur, sous le contrôle du serveur d’exécution des programmes PHP (en production).

Ce stockage est indépendant de la base de données, et n’est pas partagé avec d’autres applications système, contrairement à un SGBD qui peut être accédé par différentes applications en parallèle.

Symfony s’appuie sur PHP, qui va aller retrouver, grâce au cookie PHPSESSID d’un navigateur particulier, le bon fichier de session dans lequel retrouver les données de la session Symfony.

En production, le stockage de la session peut aussi se faire de bien d’autres façons, par exemple dans un système de mémoire partagée, quand l’exécution des applications PHP s’effectue sur un cluster, avec chaque requête gérée sur une machine différente. Le stokage en système de fichier n’est qu’une option parmi d’autres.

C’est nativement celle qui est utilisée dans l’environnement de développement Symfony qu’on utilise pour notre mise-au-point.

4. Étape 3 : Ajout du contrôle d’accès dans l’application

Ajouter une fonction de gestion de comptes d’utilisateurs stockés dans la base de données, sur l’application « fil-rouge » ToDo.
Puis intégrer les fonctions nécessaires à l’authentification et au contrôle d’accès.

Vous allez procéder aux étapes suivantes, pour ajouter la gestion des utilisateurs dans l’application de gestion des tâches.

Cela permettra que l’application soit utilisée par différents utilisateurs simultanément, mais que chaque utilisateur ne puisse pas nécessairement polluer les tâches qui correspondent à un autre utilisateur, s’il fait des modifications.

4.1. TODO Étape 3-a : Ajout d’une entité User

Ajouter l’entité gérant les comptes utilisateurs dans le modèle de données du projet.

Utilisez l’assistant, qui va procéder à l’ajout d’une entité au modèle, dans le répertoire de l’application Symfony :

symfony console make:user
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

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

created: src/Entity/User.php
created: src/Repository/UserRepository.php
updated: src/Entity/User.php
updated: config/packages/security.yaml

  Success! 


 Next Steps:
   - Review your new App\Entity\User class.
   - Use make:entity to add more fields to your User entity and then run make:migration.
   - Create a way to authenticate! See https://symfony.com/doc/current/security.html

Il est alors nécessaire, comme d’habitude, de mettre à jour le schéma de la base de données.

Plutôt que d’appliquer les migrations comme suggéré par l’outil, nous vous conseillons de regénérer complètement la base de données :

  1. suppression de la base de données : symfony console doctrine:database:drop --force
  2. recréation de la base de données : symfony console doctrine:database:create
  3. recréation du schéma de la base de données : symfony console doctrine:schema:create

4.2. TODO Étape 3-b : Ajout d’utilisateurs de tests

La documentation de Symfony explique comment faire l’ajout d’utilisateurs dans la base de données, dans la génération des données de test de l’application (Fixtures), au sein de la section Accessing Services from the Fixtures.

Pour que cela soit plus simple à faire, ajoutez un nouveau fichier aux Data Fixtures de votre projet, en recopiant directement dans le fichier src/DataFixtures/UserFixtures.php l’exemple suivant :

<?php

namespace App\DataFixtures;

use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use App\Entity\User;

class UserFixtures extends Fixture
{
    private UserPasswordHasherInterface $hasher;

    public function __construct(UserPasswordHasherInterface $hasher)
    {
        $this->hasher = $hasher;
    }

    public function load(ObjectManager $manager)
    {
        $this->loadUsers($manager);
    }

    private function loadUsers(ObjectManager $manager)
    {
        foreach ($this->getUserData() as [$email,$plainPassword,$role]) {
            $user = new User();
            $password = $this->hasher->hashPassword($user, $plainPassword);
            $user->setEmail($email);
            $user->setPassword($password);

            $roles = array();
            $roles[] = $role;
            $user->setRoles($roles);

            $manager->persist($user);
        }
        $manager->flush();
    }
    private function getUserData()
    {
        yield [
            'chris@localhost',
            'chris',
            'ROLE_USER'
        ];
        yield [
            'anna@localhost',
            'anna',
            'ROLE_ADMIN'
        ];
    }
}

Lorsque l’application sera déployée en production, les comptes des utilisateurs seront créés depuis l’interface de l’application, mais pour les besoins des tests, nous avons chargé des utilisateurs dans la base de données.

Consultez dans votre IDE :

  • la nouvelle entité User ajoutée au modèle de données
  • le code de chargement des Data Fixtures qui a servi à initialiser les données de tests présentes dans la base, qui donne les identifiants et les mots-de-passe des utilisateurs de tests

Rechargez les données de test pour ré-encoder les mots-de-passe par rapport à l’environnement local :

symfony console doctrine:fixtures:load

4.3. TODO Étape 3-c : Mise en œuvre de l’authentification

Dans ce qui suit, on va résumer différentes étapes de modification nécessaires, qui sont documentées dans la documentation Authenticating Users / Form Login de Symfony.

  1. créons un contrôleur Symfony standard qui servira au login

    symfony console make:controller Login
    
  2. Modifions le fichier de configuration config/packages/security.yaml pour y placer les directives suivantes :

    security:
         # ...
    
         firewalls:
                main:
                    # ...
                    form_login:
                        # "app_login" is the name of the route created previously
                        login_path: app_login
                        check_path: app_login
                        enable_csrf: true
                        default_target_path: todo_list
    
                    logout:
                        path: app_logout
                        # where to redirect after logout
                        target: home
    
         role_hierarchy:
                ROLE_ADMIN:       ROLE_USER
                ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
    
         access_control:
                - { path: ^/admin, roles: ROLE_ADMIN }
                # - { path: ^/profile, roles: ROLE_USER }
    

    Explications :

    • on définit une route pour invoquer le login via un formulaire « classique » Symfony (qui contiendra une option de sécurité : CSRF)
    • on définit une route pour la redirection par défaut une fois le login réussi (’default_target_path’, ici sur todo_list)
    • on définit une route pour le logout, qui, une fois réussi, renvoit sur une route de l’application (ici home, à adapter à votre code)
    • on définit une hiérarchie de rôles pour les utilisateurs
    • enfin, on déclare que le backoffice (derrière l’URL /admin) sera réservé aux utilisateurs administrateurs.
  3. on modifie le code du contrôleur LoginController pour ajouter la construction de la page de login via un gabarit login/index.html.twig :

    <?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 LoginController extends AbstractController
    {
         #[Route('/login', name: 'app_login')]
         public function index(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('login/index.html.twig', [
                         'last_username' => $lastUsername,
                         'error'         => $error,
                 ]);
         }
    }
    
    
  4. on ajoute enfin le gabarit Twig correspondant, dans templates/login/index.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">
        <div class="form-outline mb-4">
            <label for="username">Email:</label>
            <input type="text" id="username" name="_username" value="{{ last_username }}"/>
         </div>
         <div class="form-outline mb-4">
            <label for="password">Password:</label>
            <input type="password" id="password" name="_password"/>
         </div>
            {# If you want to control the URL the user is redirected to on success
            <input type="hidden" name="_target_path" value="/account"/> #}
    
         <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
    
            <button type="submit" class="btn btn-primary btn-block mb-4">login</button>
        </form>
    {% endblock %}
    

    Ce gabarit affiche un simple formulaire login (email) + mot-de-passe. Il contient quelques directives pour Bootstrap pour améliorer un peu l’affichage

  5. Testez que l’authentification fonctionne quand on navigue sur /login (avec les utilisateurs définis précédemment dans les fixtures).

    Si le login est réussi, vous devez être redirigé vers todo_list.

    Vous devez voir l’utilisateur courant affiché dans les icônes de la barre d’outils Symfony (profiler).

  6. Ajoutez enfin une route dans le contrôleur LoginController pour permettre le logout :

    #[Route('/logout', name: 'app_logout', methods: ['GET', 'POST'])]
    public function logout()
    {
        // controller can be blank: it will never be called!
        dump("logout");
        // throw new \Exception('Don\'t forget to activate logout in security.yaml');
        return new Response();
    }
    
  7. Testez que la déconnexion (logout) fonctionne bien, et qu’une fois déconnecté, il n’y a plus de spécification d’utilisateur courant dans la barre d’outils Symfony
  8. Naviguez vers le back-offiche /admin/ et vérifiez que l’authentification est bien demandée. Connectez-vous alternativement avec un utilisateur ADMIN ou un utilisateur normal, et vérifiez si l’accès est autorisé ou non.

Vous pourriez ensuite générer un formulaire d’inscription avec l’assistant symfony console make:registration-form, mais ce n’est pas forcément utile pour notre TP.

4.4. TODO Étape 3-d : Adaptation des menus

Cette étape suppose que votre application utilise déjà les menus Bootstrap pour afficher les menus de haut de page, si vous avez effectué cette étape de la mise en œuvre (cf. TP 6).

Vous allez alors ajouter un menu offrant des fonctions de connexion / déconnexion.

Le principe consiste à prévoir, dans le bloc menu du gabarit de base des pages, une section optionnelle dépendant de si l’utilisateur est connecté (ou de son rôle), du style :

{% if app.user %}
...
    {# Affichage d'un menu 'account' permettant de se déconnecter #}
...
{% else %}
...
    {# Affichage d'un menu 'anonymousaccount' permettant de se connecter #}
...
{% endif %}

La détection de l’utilisateur connecté est faite en vérifiant s’il y a un utilisateur courant, auquel cas, app.user est définie.

On prépare ainsi deux menus différents : celui pour les utilisateurs déjà connectés, et l’autre qui permettra justement, de se connecter.

  1. adaptez le gabarit de base, qui définit les menus, pour ajouter deux entrées après l’afichage du menu main. Vous obtiendre un code ressemblant à ceci :

    <div class="collapse navbar-collapse" id="navbarSupportedContent">
         <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
              {{ render_bootstrap_menu('main') }}
              {% if app.user %}
                   {{ render_bootstrap_menu('account') }}
              {% else %}
                   {{ render_bootstrap_menu('anonymousaccount') }}
              {% endif %} {# app.user #}
         </ul>
    </div>
    
  2. dans la configuration des menus, dans config/packages/bootstrap_menu.yaml ajoutez les définitions des deux menus :

    bootstrap_menu:
      menus:
        main:
    ...
        anonymousaccount:
          items:
             login:
                label: 'Login'
                route: 'app_login'
    #        register:
    #           label: 'Register'
    #           route: 'app_register'
        account:
          items:
             logout:
                label: 'Logout'
                route: 'app_logout'
                roles: [ 'ROLE_USER' ]
    
    

Testez le fonctionnement. Le menu doit afficher des liens différents selon si l’utilisateur est authentifié ou non.

4.5. TODO Étape 3-e : Adaptations de l’interface en fonction de l’utilisateur

On pourrait souhaiter que l’accès en consultation aux pastes présentes dans la base de donnée de l’application soit accessible à tout le monde (on a par exemple déployé l’application en intranet : pas besoin de se connecter pour cela).

Mais on souhaiterait que les fonctions de modifications soient réservées à des utilisateurs authentifiés :

  • modification possible par tout utilisateur
  • suppression possible uniquement par l’administrateur.

Effectuons les modifications nécessaires.

On va commencer par modifier les gabarits de l’application pour masquer les liens vers les fonctions qui ne doivent pas être accessibles :

  1. Modifiez le template d’affichage de la liste des pastes (templates/paste/index.html.twig), afin de n’afficher le lien vers les fonctions d’édition que si l’utilisateur connecté possède le rôle ROLE_USER :

    {% if is_granted('ROLE_USER') %}
        <a href="{{ path('app_paste_edit', {'id': paste.id}) }}">edit</a>
    {% endif %}
    
  2. Procédez de même pour la suppression, dans le gabarit d’affichage templates/paste/edit.html.twig (ou directement dans paste/_delete_form.html.twig), pour que l’affichage du bouton « Delete » ne se fasse que pour le rôle ROLE_ADMIN

Lancez l’application et vérifiez que l’application a donc un fonctionnement qui dépend du profil de l’utilisateur connecté.

4.6. TODO Étape 3-f : Vérification du contrôle d’accès effectif

Cette étape vise à vérifier que le contrôle d’accès fonctionne effectivement, pour protéger l’accès aux fonctions de l’application, en fonction de l’authentification ou non de l’utilisateur.

Qu’advient-il si un utilisateur devine que le lien /paste/{id}/edit permet d’accéder aux fonctions de modification d’une paste ?

Le lien masqué vers « edit » suffit-il à protéger l’accès aux fonctions de l’application ?

Modifiez le code du contrôleur pour sécuriser l’accès aux routes, comme indiqué dans la documentation Securing Controllers and other Code :

  1. Modifiez les annotations au-dessus de la méthode PasteController::edit qui gère les modifications, pour ajouter l’annotation IsGranted("ROLE_USER")

    use Symfony\Component\Security\Http\Attribute\IsGranted;
    ...
        #[Route('/{id}/edit', name: 'app_paste_edit', methods: ['GET', 'POST'])]
        #[IsGranted('ROLE_USER')]
        public function edit(Request $request, Paste $paste, EntityManagerInterface $entityManager): Response
        {
    
  2. Modifiez de façon identique la route des suppressions, pour réserver l’accès aux utilisateurs possédant le rôle ROLE_ADMIN
  3. Vérifiez que vous aboutissez bien à la page de connexion lorsque vous essayez de rentrer manuellement les URL ’edit’.

On sait donc bien masquer les lien ET contrôler effectivement l’accès aux fonctions CRUD, pour prévenir des tentatives d’utilisation malveillantes.

4.7. Gestion des droits d’accès plus contextuelle (optionnelle)

Cette étape optionnelle suppose qu’on souhaite faire le même genre de contrôle d’accès, mais sur les modifications des Tâches, à présent.

On souhaiterait affiner un peu plus le fonctionnement de l’application de façon à ce que les contrôles sur les droits d’accès à la fonction de suppression soient moins restrictifs, de façon à ce que :

  • les utilisateurs authentifiés et administrateurs peuvent supprimer toutes les tâches
  • les utilisateurs authentifiés non-administrateurs peuvent supprimer uniquement les tâches déjà terminées.

Il faudra modifier la gestion de la suppression comme suit :

  1. modifiez le code du gabarit d’édition des tâches de façon à afficher le bouton delete, soit :
    • pour les utilisateurs administrateurs
    • soit pour les utilisateurs authentifiés « ordinaires », mais dans ce cas, uniquement quand la tâche est terminée
  2. vérifiez que le bouton s’affiche bien dans ces conditions
  3. testez la suppression avec un utilisateur de rôle ROLE_USER

    Normalement, la suppression est toujours refusée, car même si le bouton est affiché, on garde la vérification « inconditionnelle » sur le rôle ROLE_ADMIN dans l’annotation de la route todo_delete.

  4. modifiez donc le code de la méthode delete pour effectuer un traitement conditionnel :
    1. remplacez l’attribut IsGranted("ROLE_ADMIN") par IsGranted("ROLE_USER") sur la méthode

      Cette fois-ci, tout utilisateur peut tenter sa chance même s’il n’est pas administrateur…

    2. dans le corps du code, vérifiez la règle de privilège administrateur, ou de tâche terminée :

      if ($todo->isCompleted() == false) {
          $this->denyAccessUnlessGranted('ROLE_ADMIN');
      }
      

      Essayez de passer par le formulaire d’affichage ou le lien Delete reste encore quelque soit le contexte, pour déclencher la suppression.

      Une page « 403 » Access denied est affichée en réponse à la requête POST du « delete », lorsque vous essayez de supprimer sans être administrateur :

      La barre d’outils Symfony indique pourquoi :

      • HTTP status 403 Forbidden
      • Controller TodoController:: delete
      • Route name todo_delete

Vous comprenez maintenant la façon dont on peut gérer les permissions dans l’application, de façon large (via les attributs IsGranted() en fonction des rôles), ou contextuelle, selon les utilisateurs ou les données à traîter (dans un algorithme, grâce à denyAccessUnlessGranted()).

5. Évaluation

Vous comprenez les mécanismes suivants des applications Web :

  • le stockage d’informations dans la session, qui est déterminée grâce aux cookies échangées entre le client HTTP et le serveur Web. Ces informations sont donc propres à chaque client, chaque utilisateur, et non partagées entre tous les utilisateurs comme dans le cas des données présentes dans la base de données
  • l’utilisation des outils du Profiler pour comprendre l’impact sur la session
  • la fonctionnalité des messages flash pour afficher des messages malgré les redirections
  • comment personnaliser l’affichage de morceaux de pages dans les conditionnelles, avec Twig
  • les principes de la gestion des permissions avec le modèle RBAC tel qu’il est mis en œuvre dans Symfony. Elle se décompose en deux grands axes :
    • personnaliser l’affichage en fonction du profil de l’utilisateur, ce qui est fait essentiellement dans les gabarits
    • le contrôle effectif des permissions effectué dans le routage ou dans le code qu’on place dans les contrôleurs, pour une gestion plus fine.
  • Le fonctionnement d’un formulaire de connexion
  • La customisation des menus en fonction de l’utilisateur connecté ou non

Author: Olivier Berger (TSP)

Date: 2024-09-06 Fri 13:21

Emacs (Org mode)