Mise en place des JWT dans une API REST avec Symfony 4

Aujourd’hui, nous allons voir comment sécuriser une API Symfony 4 avec les Json Web Tokens.

Dans ce tutotrompe, nous allons revoir ensemble les bases d’une API REST pour ensuite se focaliser sur les tokens JWT. Qui sont-ils ? A quoi servent-ils ? Et surtout comment les implémenter.

Tout le code présenté dans cette vidéo est disponible sur notre repo github.

Pour visualiser l'intégralité de la vidéo : rendez-vous sur notre chaîne Youtube !

Previously in APIs

Petit rappel pour les derniers arrivés, API signifie Application Programming Interface. Une interface est un moyen de communiquer entre 2 entités selon des normes. Cela peut-être par exemple un menu de restaurant qui vous permet de communiquer entre vous et la cuisine du restaurant. Les normes ici présentes sont la liste du menu : vous ne pouvez demander que les plats disponibles sur la carte.

Il existe de nombreuses API disponibles sur Internet. Par exemple, on peut citer l’API de Twitter qui permet entre autres de récupérer des tweets sans forcément consulter leur site.

La norme REST

Sans rentrer trop dans les détails, la norme REST d’une API implique plusieurs contraintes :

  • La structure des URL Les adresses URL sont composées d’un point d’entrée suivi du nom des ressources que l’on veut obtenir ou modifier. Si je veux récupérer des utilisateurs, je vise par exemple nomdedomaine/api/users. Si ce sont des articles, on visera nomdedomaine/api/articles. Pour modifer une ressource, on spécifie son ID dans la route, sous la forme de nomdedomaine/api/users/USERID ou nomdedomaine/api/articles/ARTICLEID.

  • Les verbes HTTP
    Chaque requête effectuée devra décrire correctement l’action qu’elle souhaite effectuer auprès du serveur. Si ce n’est pas correct, le serveur vous renverra une erreur. Il y a 5 actions possibles :

    • GET pour la récupération de données,

    • POST pour la création de données,

    • PUT & PATCH pour la modification de données,

    • DELETE pour la suppression de données,

  • Les codes de réponses
    A chaque requête effectuée, le serveur vous renverra un code HTTP spécifique. Sans rentrer dans le détail, on peut citer les codes 200 quand tout se passe bien, les 400 quand il y a eu une erreur dans les données envoyées par le client, mais aussi 500 quand il y a eu une erreur côté serveur.

  • Stateless
    L’API Rest doit être “stateless” soit “sans état” en français. On entend par là le fait de ne pas avoir d’informations de session. Le serveur doit avoir, pour une requête, l’ensemble des données nécessaires au traitement de la requête. Il ne doit ni se préoccuper de vos requêtes passées, ni de vos futures.

  • Le versionning
    Les sites évoluent, vous vous doutez bien que les API aussi. On doit également versionner les API. Idéalement, on suffixe le point d’entrée de l’api avec un numéro de version. Exemple : nomdedomaine.com/api/v1/users puis, nomdedomaine.com/api/v2/users

  • La documentation
    Si on ouvre ses données via une API au public ou à certains collaborateurs, il faut bien que ceux-ci comprennent comment interagir avec votre API. Documentez ! Il faut que pour chaque route disponible, on puisse comprendre son fonctionnement et les paramètres attendus éventuellement. Il faut également qu’on comprenne ce que le serveur nous renvoie et sous quelle forme !

JWT WTF ?

Un jeton JWT est une chaîne de caractères que l’on va envoyer à chaque requête que l’on souhaite effectuer auprès d’une API afin de s’authentifier. Il contient toutes les informations nécessaires à notre identification (n’oubliez pas que nous sommes dans un système Stateless et que le serveur ne stocke pas par exemple en session nos informations d’identifications !).

Ce jeton est composé de trois parties :

  • L’en-tête, qui contient l’algorithme utilisé pour la signature du jeton,

  • La charge utile du jeton, qui contient nos données utilisateurs, par exemple, notre nom d’utilisateur, ainsi que d’autres informations utiles telles que la date d’expiration du jeton, sa date de création, etc,

  • Et pour finir, la signature du jeton.

Les deux premières parties sont des objets JSON encodés en base 64 et la dernière partie est le résultat de l’encodage des deux premières parties avec l’algorithme défini dans l'en-tête.

Attention à ne jamais mettre d’informations sensibles dans la charge utile ! En effet, le jeton est facilement décodable. Ce qui fait sa force, c’est qu’il est signé par le serveur avec une clé secrète que lui seul connait. Et le serveur dans un premier temps ne s’occupera que de valider votre jeton avant de le décoder et d’utiliser les informations utiles contenues dans celui-ci.

En plus de ces informations utiles, il y a certaines propriétés / mots-clefs réservés. En voici une liste non exhaustive :

  • Iss : permet de savoir qui a délivré le jeton,

  • Sub : l’identifiant unique de l’utilisateur du jeton. Souvent l’ID de l’utilisateur ou son login / email,

  • Exp : le timestamp à partir duquel le jeton ne sera plus valide,

  • Iat : l’heure à laquelle le jeton a été delivré.

La liste exhaustive est disponible en consultant la norme RFC 7519 ici : https://tools.ietf.org/html/rfc7519#section-4.1.
Pour s’authentifier auprès de l’API, il faut transmettre ce token dans les en-têtes de la requête souvent dans l’en-tête “Authorization” avec pour valeur “Bearer ” + jeton.

Attention, selon votre serveur, celui-ci a peut-être besoin d’une règle spécifique pour bien avoir accès aux en-têtes HTTP “Authorization”. Sous Apache, vous pouvez rajouter ces lignes dans votre .htaccess.

# Authorization  header

RewriteCond %{HTTP:Authorization} ^(.*)

RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]

De plus, vous pouvez utiliser le site https://jwt.io pour vérifier s'il est valide, ce que contient votre jeton et surtout, tester s'il est signé avec les clés privées / publiques que vous détenez.

Un jeton JWT et sa structure sur le site jwt.io

Mise en place de l’authentification

Configuration côté Symfony

Pour illustrer ce tutotrompe, j’ai créé une API simple qui permet de récupérer une liste de chansons.

Des utilisateurs sont déjà inscrits en base de données, vous pourrez retrouver les informations de connexion dans le README du projet.

Le but du jeu va être de sécuriser cette API. Actuellement, n’importe qui peut consulter cette liste. Nous allons faire en sorte que :

  • seul les utilisateurs authentifiés puissent y avoir accès

  • seul l’administrateur puisse ajouter une chanson

Cela permet de voir les différences entre authentification et autorisation : l’authentification est le fait d’identifier quel utilisateur fait la requête; l’autorisation permet de savoir s’il a le droit d’accéder ou non aux ressources demandées.

Tout d’abord, nous allons installer le bundle lexik jwt authentication grâce à cette commande composer :

composer require lexik/jwt-authentication-bundle

Première chose à faire ensuite, la création de clés publique et privée pour signer et valider les jetons !

Vous pouvez utiliser l'utilitaire OpenSSL pour générer ces deux clefs. Il faudra ensuite les placer dans un dossier config/jwt à la racine de votre application. Attention, une passphrase vous sera demandée. Il faudra reporter celle-ci dans le fichier de variables d’environnement de Symfony au niveau de la variable JWT_PASSPHRASE.

###> 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= *ICI*

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

Maintenant, Symfony a tout sous le capot pour authentifier nos utilisateurs et leur fournir des jetons. Voyons comment configurer tout cela.

Ouvrez votre fichier config/packages/security.yaml. Ce fichier va vous permettre de configurer toutes les règles de sécurité de votre application et quelles méthodes d’authentification vous allez utiliser.

Nous voulons protéger par JWT uniquement la partie API de notre application. Vous pourrez voir dans le code un exemple de login standard avec un formulaire de connexion (qui se base ensuite sur des informations de session, donc non stateless). Et vous verrez que les deux méthodes fonctionnent indépendamment l’une de l’autre, comme souhaité.

Dans ce fichier, insérez les lignes suivantes :

login:
    pattern: ^/api/login
    stateless: true
    anonymous: 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
    guard:
        authenticators:
            - lexik_jwt_authentication.jwt_token_authenticator

Examinons tout ça :

  • Pour la route du login, - on indique que notre route de login doit être accessible aux utilisateurs anonymes, - on indique également que le bundle lexik jwt authentication doit s’occuper de gérer la vérification des informations utilisateurs avec ses propres méthodes
  • Pour le reste de notre api - tout est stateless, chaque requête doit contenir des informations d'authentification, - toutes les routes qui commencent par api seront protégées par l’authenticator JWT Lexik.

Charge utile

Il faut maintenant implémenter ce que le jeton contiendra dans sa charge utile. Dans le SecurityController, nous créons la méthode apilogin qui est bien reliée à notre route /api/logincheck. Ici, on récupère l’utilisateur authentifié grâce à Lexik et on décide d’encoder un json avec son email ainsi que ses rôles.

Nous avons également ajouté une méthode dans notre SongsController afin de permettre l’ajout de chanson uniquement pour le rôle SUPER_ADMIN grâce à l’annotation IsGranted.

Postman pour tester

Voyons un peu dans la pratique comment cela se passe. Nous allons utiliser Postman, un outil qui permet de tester des requêtes HTTP et de les personnaliser de fond en comble. Cet outil est un must-have pour tester les API et il est même possible de créer des scénarii pour effectuer des tests fonctionnels d’une API.

En premier lieu, nous allons nous authentifier. Pour cela, on saisit l’URL de login, sur ma machine locale, http://127.0.0.1:8000/api/login_check, et on envoie dans le corps de la requête notre objet JSON qui contient la clé username avec pour valeur l’email de notre admin et la clé password avec son mot de passe.

On envoie la requête et TADAM, le serveur nous renvoie un jeton !

Obtention de notre premier jeton JWT

Voyons ce qu'il contient. On voit bien notre nom d’utilisateur, nos rôles et les dates de création et d’expiration !

Le token n’est pas signé mais si l’on copie colle les contenus de notre clé publique et privé dans les champs prévus à cet effet, le jeton est signé !

Jeton signé avec saisie des clés publique et privée

Voyons ensuite ce qui se passe lorsque l’on essaye d’attaquer notre API sur notre route de récupération de chansons.

Le serveur nous renvoie un code d’erreur 401 Unauthorized car il ne nous identifie pas et nous spécifie l’erreur : Aucun jeton n’a été trouvé.

Postman intègre cette notion d’authentification et permet de spécifier facilement le jeton pour une requête dans l’onglet “Authorization”.

On va choisir le type d’authentifcation en sélectionnant “Bearer token”, comme vu dans la partie théorique sur les jetons JWT. On colle notre jeton précédemment obtenu via la route de login dans le champ prévu à cet effet.

Essayons à nouveau notre requête...

… et voilà, nous avons récupéré notre liste de chansons au format json !

Mise en place de l'autorisation

Par rapport à notre code initial de la branche api, nous allons rajouter une méthode POST postSong.

Les annotations permettent plusieurs choses pour cette méthode :

  • Pour continuer à appliquer la norme REST, on définit cette route comme une route POST sur l’URL /api/songs,

  • On convertit les paramètres d’entrée pour essayer de transformer le corps de la requête en entité Song directement grâce au bundle fos_rest,

  • On valide également les paramètres pour vérifier qu’ils correspondent bien à ce qui est attendu en terme de données : tous les champs de l’entité sauf ID sont requis,

  • On restreint l’accès de cette route aux utilisateurs possédant le role SUPER_ADMIN.

Dans cette méthode, on vérifie que l’entité est correcte, puis on la sauvegarde et on la renvoie au format JSON.

Dans Postman, on modifie la méthode de la requête en POST, toujours sur la même URL et on saisit le corps de la requête avec les champs nécessaires, un titre et un artiste.

Une fois la requête lancée, on voit que le serveur nous répond un JSON avec les informations souhaitées.

Si jamais on essayait de lancer la même requête avec un jeton obtenu pour l’utilisateur user@attineos.com qui ne possède que le rôle “User”, le serveur nous renvoie une erreur 403.

Accès refusé pour la création d'une entité

Le code 403 nous indique ici que le serveur nous authentifie bien en tant que user@attineos.com mais que nous n’avons pas le droit d’effectuer cette action.

Et la suite ?

Voilà vous possédez les bases de la mise en place d’une authentification par jeton JWT sur une API. Pour peaufiner le fonctionnement de ceci, il faudrait améliorer le système de gestion des jetons notamment dans le cas où ceux-ci sont expirés. Des solutions existent pour “rafraîchir le token” et ne pas avoir à se reconnecter dans le cas où celui-ci expire. Ce qui est préconisé en terme de sécurité est un jeton à durée de vie très courte mais rafraîchi à chaque requête effectuée.

Pour aller plus loin, jetez un coup d'oeil du côté de JWTRefreshTokenBundle !