Créez des utilisateurs
Nos ressources sont disponibles. Nous pouvons en créer de nouvelles, nous pouvons les valider, nous avons même mis en place un système de gestion d’erreur. Bravo, c'est déjà beaucoup ! :-)
Passons maintenant à l'authentification. En effet, il n’est pas nécessairement souhaitable que tout le monde puisse réaliser toutes les opérations.
Même si un simple utilisateur doit pouvoir consulter les titres de notre bibliothèque, il semble plutôt logique que seul le bibliothécaire, autrement dit l’administrateur, puisse ajouter de nouveaux livres.
Créez l’authentification dans Symfony
L’authentification étant un mécanisme présent dans la plupart des projets, Symfony a déjà mis en place des éléments pour nous faciliter la vie !
La toute première étape est d’installer le composant Security
.
composer require security
Ensuite, il nous faut stocker les utilisateurs. Pour cela, nous pourrions tout à fait créer une nouvelle entité User
à la main, mais comme je l’ai mentionné plus tôt, il est tellement commun d’avoir à gérer des utilisateurs dans une application, que Symfony a carrément créé une commande spécifiquement pour ça :
php bin/console make:user
Symfony nous pose quelques questions, nous pouvons nous contenter d’accepter les choix par défaut, et nous voilà avec la nouvelle entité User
créée !
Petit point de détail pour nous simplifier la vie plus tard, nous allons ajouter une méthode getUsername()
dans l’entité User.php
. Celle-ci sera utilisée plus tard par JWT (voir un peu plus loin !) pour nous authentifier.
<?php
//src\Entity\User.php
//...
/**
* Méthode getUsername qui permet de retourner le champ qui est utilisé pour l'authentification.
*
* @return string
*/
public function getUsername(): string {
return $this->getUserIdentifier();
}
Cette méthode très simple est juste un alias vers $this->getUserIdentifier()
.
Il ne nous reste plus qu’à mettre à jour la base de données en conséquence :
php bin/console doctrine:schema:update --force
La table est bien créée. Nous voyons en particulier l'email
et le password
qui serviront de champs pour s’authentifier, et un champ roles
qui va contenir le rôle de notre user, afin de savoir s’il est admin ou simple utilisateur.
Mettez à jour des fixtures
Maintenant que la base de données est à jour, créons deux utilisateurs pour faire nos tests.
Mettons à jour notre fichier AppFixtures.php
:
<?php
// src\DataFixtures\AppFixtures.php
namespace App\DataFixtures;
use App\Entity\Author;
use App\Entity\Book;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class AppFixtures extends Fixture
{
private $userPasswordHasher;
public function __construct(UserPasswordHasherInterface $userPasswordHasher)
{
$this->userPasswordHasher = $userPasswordHasher;
}
public function load(ObjectManager $manager): void
{
// Création d'un user "normal"
$user = new User();
$user->setEmail("user@bookapi.com");
$user->setRoles(["ROLE_USER"]);
$user->setPassword($this->userPasswordHasher->hashPassword($user, "password"));
$manager->persist($user);
// Création d'un user admin
$userAdmin = new User();
$userAdmin->setEmail("admin@bookapi.com");
$userAdmin->setRoles(["ROLE_ADMIN"]);
$userAdmin->setPassword($this->userPasswordHasher->hashPassword($userAdmin, "password"));
$manager->persist($userAdmin);
// Création des auteurs.
$listAuthor = [];
for ($i = 0; $i < 10; $i++) {
// Création de l'auteur lui-même.
$author = new Author();
$author->setFirstName("Prénom " . $i);
$author->setLastName("Nom " . $i);
$manager->persist($author);
// On sauvegarde l'auteur créé dans un tableau.
$listAuthor[] = $author;
}
for ($i = 0; $i < 20; $i++) {
$book = new Book();
$book->setTitle("Titre " . $i);
$book->setCoverText("Quatrième de couverture numéro : " . $i);
$book->setAuthor($listAuthor[array_rand($listAuthor)]);
$manager->persist($book);
}
$manager->flush();
}
}
Nous avons ajouté, dans la méthode load, deux éléments : le ROLE_USER
pour créer un utilisateur normal, et un second, avec le ROLE_ADMIN
.
Notez en particulier comment le mot de passe a été encodé. Symfony nous fournit un UserPasswordHasherInterface
qui est une classe qui nous permet de réaliser cet encodage.
Cependant, comme nous ne sommes pas dans un contrôleur, nous ne pouvons pas directement récupérer cet élément en le passant dans les paramètres. Il nous faut créer un constructeur, qui lui peut recevoir cet élément, le sauvegarder dans le membre local $this->userPasswordHasher
, et seulement après nous en servir dans la méthode load
.
En particulier, l’instruction :
$this->userPasswordHasher->hashPassword($userAdmin, "password")
permet de retourner le mot de passe encodé pour l’utilisateur $userAdmin.
Lançons nos fixtures :
php bin/console doctrine:fixtures:load
Normalement tout devrait bien se passer, il nous reste seulement à vérifier dans la base de données que nos utilisateurs sont présents :
Notez le contenu du champ password
. Alors que j’ai demandé dans mes fixtures à créer à chaque fois le même mot de passe (qui est “password”), dans la base est stockée deux longues chaînes de caractères, et en plus différentes pour les deux utilisateurs créés !
Cela permet de ne donner aucun indice (même le fait que les deux mots de passe sont les mêmes !) à un éventuel pirate qui aurait accès à notre base, et voudrait réutiliser le mot de passe ailleurs.
Notez également le contenu du champ roles
, qui indique clairement quels rôles ont nos utilisateurs.
Protégez une API avec de l’authentification
Découvrez JWT
Maintenant que nos utilisateurs existent, il va falloir créer un mécanisme pour que l’API puisse authentifier et savoir que cette authentification a réussi.
La solution pour pallier ce problème va être de faire un premier appel pour s’authentifier. Ce premier appel va retourner un token, encodé, qui va contenir les informations sur la personne qui vient de s’authentifier.
À l’appel suivant, dans le header Authorization, il suffira de renvoyer ce token pour dire à l’application “Je suis authentifié, voici la preuve grâce à ce token.”
Ceci peut se faire grâce à JWT, qui signifie JSON Web Token, et qui est un standard qui définit les diverses étapes pour échanger les informations d’authentification de manière sécurisée.
Installez JWT
La sécurité étant un point très important, nous allons nous aider d’un bundle très populaire, fiable et open source : LexikJWT.
Commençons par l’installer :
composer require lexik/jwt-authentication-bundle
Ensuite, pour fonctionner, Lexik à besoin de générer des clefs. Sans rentrer dans les détails, il y a une clef publique et une clef privée.
La clef publique permet d’encoder le token généré, et la clef privée permet de le décoder. Comme la clef privée est… privée, même si quelqu’un intercepte le token, sans la clef privée il ne saura pas comment le décoder, et ne pourra donc rien en faire.
Voici comment créer ces clefs, toujours en ligne de commande :
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa _keygen_bits:4096 openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout
Une “passphrase” vous sera demandée. Cette passphrase va en quelque sorte servir de clef pour l’encodage/décodage du token. Elle doit rester secrète !
Notez qu’ici nous enregistrons nos clefs dans le dossier config/jwt
. Il faut que ce dossier existe, sinon la commande échouera.
Configurez JWT
Maintenant que JWT est installé et que nos clefs ont été créées, nous devons dire à Symfony où elles se trouvent.
Dans le fichier .env.local
, nous pouvons écrire ceci :
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=fdd719e8855fdf770a5141fd0afb817b
###< lexik/jwt-authentication-bundle ###
Maintenant, nous devons indiquer à Symfony quelle est l’URL qui va être utilisée pour l’authentification, et lui dire également que nous voulons que ce soit LexikJWT qui s’occupe de tout.
Pour cela, il faut aller dans le fichier security.yaml
et le mettre à jour :
#config\packages\security.yaml
security:
enable_authenticator_manager: true
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
App\Entity\User:
algorithm: auto
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# A METTRE EN COMMENTAIRE !
# main:
# lazy: true
# provider: app_user_provider
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
login:
pattern: ^/api/login
stateless: true
json_login:
check_path: /api/login_check
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/api
stateless: true
jwt: ~
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
Ici, j’ai notamment ajouté un bloc login
et un bloc api
pour donner à Symfony l’URL que nous allons utiliser pour nous authentifier, et lui dire que nous voulons utiliser JWT.
J’ai également mis à jour le bloc access_control
pour dire que toutes les routes doivent être authentifiées, sauf la route /api/login
.
Dernier point, j’ai commenté le bloc main
pour éviter un parasitage.
C’est presque terminé !
Il nous reste encore à aller dans le fichier route.yaml
et à lui rajouter un bloc api_login_check
pour créer la route elle-même.
# config\routes.yaml
controllers:
resource: ../src/Controller/
type: annotation
kernel:
resource: ../src/Kernel.php
type: annotation
api_login_check:
path: /api/login_check
Bien, après toute cette configuration, il est enfin temps de tester !
Testez l’authentification
Avec Postman, en mode POST, il va falloir appeler l’URL login_check
et passer dans le body les informations liées à l’authentification. Il faut également configurer le header pour passer une information en plus, Content-Type : application/json
.
Et si tout se passe bien, à ce stade, vous devriez, enfin, obtenir le token !
Gérez les droits
Accédez à la liste des livres
Nous avons créé notre token. Que se passe-t-il désormais si nous essayons, par exemple, d’accéder à la liste des livres ?
Cela ne marche plus !
Pas de panique, en réalité, c’est une très bonne nouvelle !
Cela signifie que notre application est bien protégée. Il n’est plus possible d’accéder aux ressources sans être authentifié, sous peine d’obtenir un code de retour 401 Unauthorized
.
Souvenez-vous, les API REST sont stateless. C’est-à-dire que s’être authentifié et avoir reçu le token n’est pas suffisant, il nous faudra désormais renvoyer ce token à chaque demande pour prouver que nous sommes bien authentifiés.
Pour cela, il faut aller dans l’onglet Headers et rajouter une nouvelle entrée : Authorization
. Et pour la valeur, il faut écrire le motBearer
suivi d’un espace et du token que vous aurez copié-collé.
Et là, magie, les données réapparaissent, vous êtes authentifié.
Autorisez les ressources en fonction des rôles
Souvenez-vous, nous avons créé deux utilisateurs. Un avec le rôle administrateur et un autre avec un rôle plus basique.
Essayons maintenant de faire en sorte que seul un utilisateur avec le rôle administrateur puisse créer un nouveau livre.
En fait, maintenant que le système est en place, c’est très facile à faire. Il suffit d’ajouter une annotation directement au-dessus de la méthode pour dire quel rôle est autorisé à accéder à la méthode, ainsi qu’un message d’erreur pour les utilisateurs qui n’ont pas les autorisations requises.
<?php
// src\Controller\BookController.php
// ...
#[Route('/api/books', name:"createBook", methods: ['POST'])]
#[IsGranted('ROLE_ADMIN', message: 'Vous n\'avez pas les droits suffisants pour créer un livre')]
public function createBook(Request $request, SerializerInterface $serializer, EntityManagerInterface $em, UrlGeneratorInterface $urlGenerator, AuthorRepository $authorRepository, ValidatorInterface $validator): JsonResponse
{
$book = $serializer->deserialize($request->getContent(), Book::class, 'json');
(...)
Pour tester, essayez d’abord de générer un token pour un simple user
. Il faut donc que le password
et le username
correspondent à un utilisateur ayant le rôle ROLE_USER
.
Avec nos fixtures, c’est l’utilisateur user@bookapi.com :
Et si vous testez, alors vous recevez le message d’erreur que nous avons spécifié directement avec nos annotations, et à nouveau un code de retour 403 - Forbidden
.
Testez à nouveau avec un token généré pour un utilisateur qui possède le ROLE_ADMIN
. Avec nos fixtures, c’est celui qui a pour username : admin@bookapi.com.
Et là, tout fonctionne.
En résumé
Symfony propose un mécanisme d’authentification interne avec hashage du mot de passe.
Un token JWT est une chaîne de caractères cryptée qui contient des informations sur la personne qui s’est authentifiée.
Pour créer ce token, on peut utiliser LexikJwt.
Chaque appel à l’API étant indépendant (stateless), une fois ce token obtenu, il faut le renvoyer à chaque fois qu’on fait appel à une route qui a besoin d’une authentification.
Les rôles permettent de différencier les traitements en fonction des privilèges de la personne qui s’est authentifiée.
Ce chapitre n’était pas le plus facile, alors je voudrais vous féliciter d’être arrivé ici ! Place au prochain chapitre qui va faire un peu d’optimisation avec la gestion du cache ou encore la pagination !