• 8 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 27/11/2023

Authentifiez et autorisez les utilisateurs de l’API avec JWT

Authentifiez et authorisez les utilisateurs de l'API avec JWT

Créez des utilisateurs

Nos ressources sont disponibles. Nous pouvons en créer de nouvelles, nous pouvons les valider, nous avons même mis en place un système de gestion d’erreur. Bravo, c'est déjà beaucoup ! :-)

Passons maintenant à l'authentification. En effet, il n’est pas nécessairement souhaitable que tout le monde puisse réaliser toutes les opérations. 

Même si un simple utilisateur doit pouvoir consulter les titres de notre bibliothèque, il semble plutôt logique que seul le bibliothécaire, autrement dit l’administrateur, puisse ajouter de nouveaux livres. 

Créez l’authentification dans Symfony

L’authentification étant un mécanisme présent dans la plupart des projets, Symfony a déjà mis en place des éléments pour nous faciliter la vie !

La toute première étape est d’installer le composant Security .

composer require security

Ensuite, il nous faut stocker les utilisateurs. Pour cela, nous pourrions tout à fait créer une nouvelle entité User à la main, mais comme je l’ai mentionné plus tôt, il est tellement commun d’avoir à gérer des utilisateurs dans une application, que Symfony a carrément créé une commande spécifiquement pour ça :

php bin/console make:user
En créant cet utilisateur, on accepte tous les choix par défaut de Symfony
Création de l’entité User grâce à la commande make:user

Symfony nous pose quelques questions, nous pouvons nous contenter d’accepter les choix par défaut, et nous voilà avec la nouvelle entité User créée !

Petit point de détail pour nous simplifier la vie plus tard, nous allons ajouter une méthode getUsername() dans l’entité User.php . Celle-ci sera utilisée plus tard par JWT (voir un peu plus loin !) pour nous authentifier.

<?php
    //src\Entity\User.php
    //...
    
    /**
     * Méthode getUsername qui permet de retourner le champ qui est utilisé pour l'authentification.
     *
     * @return string
     */
    public function getUsername(): string {
        return $this->getUserIdentifier();
    }

Cette méthode très simple est juste un alias vers $this->getUserIdentifier() .

Il ne nous reste plus qu’à mettre à jour la base de données en conséquence :

php bin/console doctrine:schema:update --force
Dans la table user sur phpMyAdmin, on voit bien les champs id, email, roles et password.
Vérification sur phpMyAdmin de la création de la table user

La table est bien créée. Nous voyons en particulier l'email  et le password  qui serviront de champs pour s’authentifier, et un champ roles qui va contenir le rôle de notre user, afin de savoir s’il est admin ou simple utilisateur.

Mettez à jour des fixtures

Maintenant que la base de données est à jour, créons deux utilisateurs pour faire nos tests.

Mettons à jour notre fichier AppFixtures.php  :

<?php
    // src\DataFixtures\AppFixtures.php
 
namespace App\DataFixtures;

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

class AppFixtures extends Fixture
{
    private $userPasswordHasher;
    
    public function __construct(UserPasswordHasherInterface $userPasswordHasher)
    {
        $this->userPasswordHasher = $userPasswordHasher;
    }

    public function load(ObjectManager $manager): void
    {
        // Création d'un user "normal"
        $user = new User();
        $user->setEmail("user@bookapi.com");
        $user->setRoles(["ROLE_USER"]);
        $user->setPassword($this->userPasswordHasher->hashPassword($user, "password"));
        $manager->persist($user);
        
        // Création d'un user admin
        $userAdmin = new User();
        $userAdmin->setEmail("admin@bookapi.com");
        $userAdmin->setRoles(["ROLE_ADMIN"]);
        $userAdmin->setPassword($this->userPasswordHasher->hashPassword($userAdmin, "password"));
        $manager->persist($userAdmin);

        // Création des auteurs.
        $listAuthor = [];
        for ($i = 0; $i < 10; $i++) {
            // Création de l'auteur lui-même.
            $author = new Author();
            $author->setFirstName("Prénom " . $i);
            $author->setLastName("Nom " . $i);
            $manager->persist($author);

            // On sauvegarde l'auteur créé dans un tableau.
            $listAuthor[] = $author;
        }

        for ($i = 0; $i < 20; $i++) {
            $book = new Book();
            $book->setTitle("Titre " . $i);
            $book->setCoverText("Quatrième de couverture numéro : " . $i);
            $book->setAuthor($listAuthor[array_rand($listAuthor)]);
            $manager->persist($book);
        }

        $manager->flush();
   }
}

Nous avons ajouté, dans la méthode load, deux éléments : le ROLE_USER pour créer un utilisateur normal,  et un second, avec le ROLE_ADMIN .

Notez en particulier comment le mot de passe a été encodé. Symfony nous fournit un UserPasswordHasherInterface  qui est une classe qui nous permet de réaliser cet encodage. 

Cependant, comme nous ne sommes pas dans un contrôleur, nous ne pouvons pas directement récupérer cet élément en le passant dans les paramètres. Il nous faut créer un constructeur, qui lui peut recevoir cet élément, le sauvegarder dans le membre local $this->userPasswordHasher , et seulement après nous en servir dans la méthode load . 

En particulier, l’instruction  :

$this->userPasswordHasher->hashPassword($userAdmin, "password")

permet de retourner le mot de passe encodé pour l’utilisateur $userAdmin.

Lançons nos fixtures :

php bin/console doctrine:fixtures:load

Normalement tout devrait bien se passer, il nous reste seulement à vérifier dans la base de données que nos utilisateurs sont présents :

Nos utilisateurs user et admin apparaît bien dans la table user.
Vérification sur phpMyAdmin de la création des users

Notez le contenu du champ password . Alors que j’ai demandé dans mes fixtures à créer à chaque fois le même mot de passe (qui est “password”), dans la base est stockée deux longues chaînes de caractères, et en plus différentes pour les deux utilisateurs créés ! 

Cela permet de ne donner aucun indice (même le fait que les deux mots de passe sont les mêmes !) à un éventuel pirate qui aurait accès à notre base, et voudrait réutiliser le mot de passe ailleurs.

Notez également le contenu du champ roles, qui indique clairement quels rôles ont nos utilisateurs.

Protégez une API avec de l’authentification

Découvrez JWT

Maintenant que nos utilisateurs existent, il va falloir créer un mécanisme pour que l’API puisse authentifier et savoir que cette authentification a réussi.

La solution pour pallier ce problème va être de faire un premier appel pour s’authentifier. Ce premier appel va retourner un token, encodé, qui va contenir les informations sur la personne qui vient de s’authentifier

À l’appel suivant, dans le header Authorization, il suffira de renvoyer ce token pour dire à l’application “Je suis authentifié, voici la preuve grâce à ce token.” 

Ceci peut se faire grâce à JWT, qui signifie JSON Web Token, et qui est un standard qui définit les diverses étapes pour échanger les informations d’authentification de manière sécurisée. 

Installez JWT

La sécurité étant un point très important, nous allons nous aider d’un bundle très populaire, fiable et open source : LexikJWT.

Commençons par l’installer :

composer require lexik/jwt-authentication-bundle

Ensuite, pour fonctionner, Lexik à besoin de générer des clefs. Sans rentrer dans les détails, il y a une clef publique et une clef privée. 

La clef publique permet d’encoder le token généré, et la clef privée permet de le décoder. Comme la clef privée est… privée, même si quelqu’un intercepte le token, sans la clef privée il ne saura pas comment le décoder, et ne pourra donc rien en faire. 

Voici comment créer ces clefs, toujours en ligne de commande :

openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa
_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem
 -pubout

Une “passphrase” vous sera demandée. Cette passphrase va en quelque sorte servir de clef pour l’encodage/décodage du token. Elle doit rester secrète !

Notez qu’ici nous enregistrons nos clefs dans le dossier config/jwt . Il faut que ce dossier existe, sinon la commande échouera.

Configurez JWT

Maintenant que JWT est installé et que nos clefs ont été créées, nous devons dire à Symfony où elles se trouvent.

Dans le fichier .env.local , nous pouvons écrire ceci :

###> lexik/jwt-authentication-bundle ###

JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=fdd719e8855fdf770a5141fd0afb817b

###< lexik/jwt-authentication-bundle ###

Maintenant, nous devons indiquer à Symfony quelle est l’URL qui va être utilisée pour l’authentification, et lui dire également que nous voulons que ce soit LexikJWT qui s’occupe de tout.

Pour cela, il faut aller dans le fichier security.yaml  et le mettre à jour :

#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'
        App\Entity\User:
            algorithm: auto

    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
        entity:
            class: App\Entity\User
            property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        # A METTRE EN COMMENTAIRE !
        # main:
        #     lazy: true
        #     provider: app_user_provider
        
        # activate different ways to authenticate
        # https://symfony.com/doc/current/security.html#the-firewall

        # https://symfony.com/doc/current/security/impersonating_user.html
        # switch_user: true

        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        api:
            pattern: ^/api
            stateless: true
            jwt: ~

    # 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: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

when@test:
    security:
        password_hashers:
        # By default, password hashers are resource intensive and take time. This is
        # important to generate secure password hashes. In tests however, secure hashes
        # are not important, waste resources and increase test times. The following
        # reduces the work factor to the lowest possible values.
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
            algorithm: auto
            cost: 4 # Lowest possible value for bcrypt
            time_cost: 3 # Lowest possible value for argon
            memory_cost: 10 # Lowest possible value for argon

Ici, j’ai notamment ajouté un bloc login  et un bloc api  pour donner à Symfony l’URL que nous allons utiliser pour nous authentifier, et lui dire que nous voulons utiliser JWT.

J’ai également mis à jour le bloc access_control  pour dire que toutes les routes doivent être authentifiées, sauf la route /api/login  .

Dernier point, j’ai commenté le bloc main  pour éviter un parasitage.

C’est presque terminé !

Il nous reste encore à aller dans le fichier route.yaml  et à lui rajouter un bloc api_login_check  pour créer la route elle-même.

# config\routes.yaml
controllers:
    resource: ../src/Controller/
    type: annotation

kernel:
    resource: ../src/Kernel.php
    type: annotation

api_login_check:
    path: /api/login_check

Bien, après toute cette configuration, il est enfin temps de tester !

Testez l’authentification

Avec Postman, en mode POST, il va falloir appeler l’URL login_check  et passer dans le body les informations liées à l’authentification. Il faut également configurer le header pour passer une information en plus,  Content-Type : application/json  .

On ajoute Content-Type : application/json au Headers
Contenu du Headers dans Postman avant de réaliser le login_check
Dans le Body, on récupère le token de notre utilisateur user
Test avec Postman : réalisation du login_check pour récupérer le token d’authentification

Et si tout se passe bien, à ce stade, vous devriez, enfin, obtenir le token !

Gérez les droits

Accédez à la liste des livres

Nous avons créé notre token. Que se passe-t-il désormais si nous essayons, par exemple, d’accéder à la liste des livres ?

Sans être authentifié, nous obtenons une erreur 401 Unauthorized et un message “JWT Token not found”.
Test avec Postman : chargement de l’ensemble des livres sans être authentifié

Cela ne marche plus !

Pas de panique, en réalité, c’est une très bonne nouvelle !

Cela signifie que notre application est bien protégée. Il n’est plus possible d’accéder aux ressources sans être authentifié, sous peine d’obtenir un code de retour  401 Unauthorized   . 

Souvenez-vous, les API REST sont stateless. C’est-à-dire que s’être authentifié et avoir reçu le token n’est pas suffisant, il nous faudra désormais renvoyer ce token à chaque demande pour prouver que nous sommes bien authentifiés. 

Pour cela, il faut aller dans l’onglet Headers et rajouter une nouvelle entrée : Authorization  . Et pour la valeur, il faut écrire le motBearersuivi d’un espace et du token que vous aurez copié-collé. 

Les livres sont récupérés après authentification dans le Body de Postman.
Test avec Postman : chargement des livres après authentification

Et là, magie, les données réapparaissent, vous êtes authentifié.

Autorisez les ressources en fonction des rôles

Souvenez-vous, nous avons créé deux utilisateurs. Un avec le rôle administrateur et un autre avec un rôle plus basique.

Essayons maintenant de faire en sorte que seul un utilisateur avec le rôle administrateur puisse créer un nouveau livre.

En fait, maintenant que le système est en place, c’est très facile à faire. Il suffit d’ajouter une annotation directement au-dessus de la méthode pour dire quel rôle est autorisé à accéder à la méthode, ainsi qu’un message d’erreur pour les utilisateurs qui n’ont pas les autorisations requises.

<?php
    // src\Controller\BookController.php
    // ...
    
    #[Route('/api/books', name:"createBook", methods: ['POST'])]
    #[IsGranted('ROLE_ADMIN', message: 'Vous n\'avez pas les droits suffisants pour créer un livre')]
    public function createBook(Request $request, SerializerInterface $serializer, EntityManagerInterface $em, UrlGeneratorInterface $urlGenerator, AuthorRepository $authorRepository, ValidatorInterface $validator): JsonResponse 
    {
         $book = $serializer->deserialize($request->getContent(), Book::class, 'json');

    (...)

Pour tester, essayez d’abord de générer un token pour un simple user . Il faut donc que le password et le username correspondent à un utilisateur ayant le rôle ROLE_USER .

Avec nos fixtures, c’est l’utilisateur user@bookapi.com :

Quand un utilisateur simple essaie de créer un livre, il obtient une erreur 403 Forbidden et le message “Vous n’avez pas les droits suffisants pour créer un livre”.
Test avec Postman : tentative de création d’un livre en étant authentifié en tant que simple utilisateur

Et si vous testez, alors vous recevez le message d’erreur que nous avons spécifié directement avec nos annotations, et à nouveau un code de retour  403 - Forbidden  .

Testez à nouveau avec un token généré pour un utilisateur qui possède le ROLE_ADMIN . Avec nos fixtures, c’est celui qui a pour username : admin@bookapi.com.

Et là, tout fonctionne.

En résumé

  • Symfony propose un mécanisme d’authentification interne avec hashage du mot de passe. 

  • Un token JWT est une chaîne de caractères cryptée qui contient des informations sur la personne qui s’est authentifiée. 

  • Pour créer ce token, on peut utiliser LexikJwt.

  • Chaque appel à l’API étant indépendant (stateless), une fois ce token obtenu, il faut le renvoyer à chaque fois qu’on fait appel à une route qui a besoin d’une authentification.

  • Les rôles permettent de différencier les traitements en fonction des privilèges de la personne qui s’est authentifiée. 

Ce chapitre n’était pas le plus facile, alors je voudrais vous féliciter d’être arrivé ici ! Place au prochain chapitre qui va faire un peu d’optimisation avec la gestion du cache ou encore la pagination !

Exemple de certificat de réussite
Exemple de certificat de réussite