Hey salut, bienvenue dans cette troisième partie sur comment créer une API REST avec Symfony et API Platform. Dans le précédent tutoriel, nous avons parler des relations entre nos entités et aussi des sous ressources. Dans cette partie, nous allons parler de l'authentification et aussi de l'autorisation. Si tu ne sais pas encore comment fonctionne l'authentification et l'autorisation en Symfony, je te conseil d'aller lire ce super tutoriel de moi qui en parle.
Nous allons donc commencer tout de suite par créer notre entité User
.
Créer la classe User
Nous allons créer la classe User
avec make:user
$ php bin/console make:user
Tu réponds ensuite par défaut à toutes les questions qui vont suivre.
Un fichier User.php
a été créé dans le dossier src/Entity
, c'est notre classe User
. Cette classe contient pour l'instant juste les attributs id
, email
, roles
et password
. Tu peux continuer à utiliser la classe comme tel. Mais si tu veux comme moi ajouter d'autres attributs, tu utilises la commande make:entity
:
$ php bin/console make:entity
Tu choisis ensuite l'entité User
et tu ajoutes les attributs que tu souhaites. J'ai personnellement ajouter les attributs username
et name
.
Avant de passer à l'authentification, nous allons d'abord définir l'entité User
comme étant une ressource. Par défaut quand on essaye de créer une nouvelle entité avec la commande make:entity
, la ligne de commande nous demande si cette entité est une ressource ou pas, malheureusement avec la commande make:user
, nous n'avons pas cette option, il faut donc que nous définissons nous même l'entité comme étant une ressource.
Mais comment est-ce que api-platform sait si une classe est une ressource ou pas? Simplement en ajoutant l'annotation @ApiResource
à la classe:
<?php
// src/Entity/User.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*
* @ApiResource
*/
class User implements UserInterface
{
// ...
}
Et voilà, nous avons maintenant tout les endpoints pour la ressource User
:
La prochaine étape c'est de définir les groupes de lecture et d'écriture sur les attributs de l'entité User
. Je ne veux pas par exemple retourner le mot de passe d'un utilisateur. Le mot de passe doit donc seulement être en écriture.
Pour ce cas, nous aurons en lecture id
, email
, username
, roles
, name
et en écriture email
, username
, password
, name
. Nous allons donc créer les groupes user:read
et user:write
<?php
// src/Entity/User
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*
* @ApiResource(
* normalizationContext={"groups"={"user:read"}},
* denormalizationContext={"groups"={"user:write"}}
* )
*/
class User implements UserInterface
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*
* @Groups("user:read")
*/
private $id;
/**
* @ORM\Column(type="string", length=180, unique=true)
*
* @Groups({"user:read", "user:write"})
*/
private $email;
/**
* @ORM\Column(type="json")
*
* @Groups("user:read")
*/
private $roles = [];
/**
* @var string The hashed password
* @ORM\Column(type="string")
*
* @Groups("user:write")
*/
private $password;
/**
* @ORM\Column(type="string", length=255)
*
* @Groups({"user:read", "user:write"})
*/
private $username;
/**
* @ORM\Column(type="string", length=255)
*
* @Groups({"user:read", "user:write"})
*/
private $name;
// ...
}
On est bien parti la, nous allons maintenant faire les migrations et mettre à jour la base de données:
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate
Créer un utilisateur
La création d'un utilisateur équivaut à l'inscription. Sur le front end, l'utilisateur aura un formulaire à remplir avec son email
, username
, name
et password
, ensuite nous enverrons ces données a l'endpoint POST /api/users
tout simplement.
Pour l'instant, nous n'avons pas de front end, nous allons donc nous même, à partir de l'interface Swagger ou postman renseigner ces données pour tester notre API. Je vais dans mon cas utiliser l'interface swagger directement:
{
"email": "aliou@kaherecode.com",
"password": "secret123",
"username": "alioukahere",
"name": "Aliou Diallo"
}
Je vais créer mon compte avec les infos ci-dessus.
Quand j'exécute la requête, j'ai une réponse 201
, qui veut dire que l'utilisateur a été créer avec succès. Mais ne crions pas tout de suite victoire, on vient de faire quelque chose d'horrible, je dirais même qu'on a commis un crime et tu vas être déçu.
Notre utilisateur a été enregistrer en base de données et c'est top, mais quand tu regardes les informations qui ont été enregistrées, le champ password
tu vois tout de suite le danger, aller je te montre ce que j'ai:
Tu vois le crime? Et si t'es pas déçu la, c'est qu'il y a vraiment un problème.
Qu'est-ce qui s'est passé? On a envoyer un mot de passe en clair, qui n'est pas crypter et ce mot de passe à été enregistré comme tel, il ne faut jamais le faire, JAMAIS, NEVER.
Qu'allons nous donc faire? Nous allons écrire notre propre méthode pour enregistrer un utilisateur (nous allons créer un DataPersister
), crypter le mot de passe et enregistrer le mot de passe crypté, on pourra ensuite dormir sans avoir des cauchemars.
Crypter le mot de passe
Je vais créer un autre attribut plainPassword
dans la classe User.php
, c'est cet attribut qui sera écrit, donc l'attribut password
n'aura plus le groupe user:write
. Ensuite je vais lire l'attribut plainPassword
, crypter son contenu et mettre le mot de passe crypté dans l'attribut password
qui sera enregistré en base de données.
<?php
// src/Entity/User.php
namespace App\Entity;
// ...
class User implements UserInterface
{
// ...
/**
* @var string The hashed password
* @ORM\Column(type="string")
*/
private $password;
// ...
/**
* @Groups("user:write")
*/
private $plainPassword;
// ...
}
L'attribut plainPassword
n'a pas l'annotation @ORM\Column
, il ne sera donc pas enregistrer en base de données.
Le schema du body pour l'endpoint POST /api/users
a été mis à jour:
Nous avons maintenant plainPassword
au lieu de password
. Personnellement, j'aime garder le nom password
au lieu de plainPassword
, pour le modifier il suffit d'ajouter l'annotation @SerializedName("password")
à l'attribut plainPassword
:
<?php
// src/Entity/User.php
namespace App\Entity;
// ...
class User implements UserInterface
{
// ...
/**
* @Groups("user:write")
*
* @SerializedName("password")
*/
private $plainPassword;
// ...
}
Nous pouvons maintenant créer la classe UserDataPersister.php
dans src/DataPersister/
pour ajouter notre logique à la création d'un utilisateur:
<?php
// src/DataPersister/UserDataPersister.php
namespace App\DataPersister;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
*
*/
class UserDataPersister implements ContextAwareDataPersisterInterface
{
private $_entityManager;
private $_passwordEncoder;
public function __construct(
EntityManagerInterface $entityManager,
UserPasswordEncoderInterface $passwordEncoder
) {
$this->_entityManager = $entityManager;
$this->_passwordEncoder = $passwordEncoder;
}
/**
* {@inheritdoc}
*/
public function supports($data, array $context = []): bool
{
return $data instanceof User;
}
/**
* @param User $data
*/
public function persist($data, array $context = [])
{
if ($data->getPlainPassword()) {
$data->setPassword(
$this->_passwordEncoder->encodePassword(
$data,
$data->getPlainPassword()
)
);
$data->eraseCredentials();
}
$this->_entityManager->persist($data);
$this->_entityManager->flush();
}
/**
* {@inheritdoc}
*/
public function remove($data, array $context = [])
{
$this->_entityManager->remove($data);
$this->_entityManager->flush();
}
}
Nous utilisons l'interface UserPasswordEncoderInterface
pour crypter le mot de passe. C'est une interface qui nous est fourni par défaut par Symfony, et cette interface utilise l'algorithme que nous avons défini dans notre fichier config/packages/security.yaml
pour faire l'encryption.
J'ai aussi modifier la méthode eraseCredentials()
dans User.php
, on appelle cette méthode juste après avoir crypter le mot de passe, c'est pour effacer toute trace du mot de passe qui n'est pas crypté:
<?php
// src/Entity/User.php
namespace App\Entity;
// ...
class User implements UserInterface
{
// ...
/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
$this->plainPassword = null;
}
// ...
}
On va tout de suite vérifier si ça marche. J'ai supprimer le premier utilisateur que j'avais créer:
{
"email": "aliou@kaherecode.com",
"password": "secret123",
"username": "alioukahere",
"name": "Aliou Diallo"
}
Et si j'exécute la requête, l'utilisateur a bien été ajouter. Je vais donc regarder ce qui a été enregistrer cette fois:
Et booommm! Tout est bien maintenant, on a bien un mot de passe qui est crypté. Bravo.
Maintenant que nous avons un utilisateur en base de données, avec un vrai mot de passe crypté, nous allons passer à l'authentification.
Authentifier vos utilisateurs
Bon tu dois maintenant le savoir, toute application qui se respecte doit avoir un système d'authentification. L'authentification c'est juste le fait de s'identifier en donnant ses informations comme son email et mot de passe par exemple, ou aussi son téléphone puis le système vérifie ses informations et dis si elles sont correctes ou pas. C'est comme quand tu arrives à une soirée privée, tu te présentes devant la sécurité, ils te demandent ton nom, tu donnes ton nom puis ils vérifient si tu es sur la liste des invités, si tu y es tu peux passer, sinon tu restes loin au risque de te faire taser.
Comment se passe l'authentification sur une application web normal, l'utilisateur renseigne son email/username et mot de passe, le système vérifie ces informations, si elles sont pas correctes, le système lui renvoie une erreur. Si les informations sont correctes, une session est créer sur le serveur pour se rappeler de l'utilisateur, et quand la session va expirer, l'utilisateur devra s'authentifier à nouveau.
Pour reprendre notre exemple sur la soirée privée, si tu es sur la liste des invités, la sécurité va te remettre un badge avec ton nom dessus, ainsi dans la soirée, tout le monde te reconnaîtra par le nom qu'il y a sur ton badge.
Mais là nous développons une API REST, et les API REST sont stateless
, c'est à dire qu'elles n'ont pas d'état, elles ne doivent donc pas enregistrer de session.
Qu'allons nous faire? Nous allons utiliser un système qui s'appelle le token based authentication (authentification à base de jeton), le fonctionnement est simple: l'utilisateur entre ses informations pour s'authentifier, si c'est bon, le système lui renvoie un jeton, il enverra ensuite ce jeton a chaque requête.
Nous allons utiliser Lexik JWT pour authentifier nos utilisateurs, nous allons donc l'installer avec composer:
$ composer require jwt-auth
Il faut maintenant le configurer. Commence par créer un dossier jwt
dans le dossier config/
:
$ mkdir config/jwt
Nous allons ensuite générer la clé privé avec openssl:
$ openssl genrsa -out config/jwt/private.pem -aes256 4096
La console va te demander de renseigner un pass phrase, c'est comme un mot de passe pour sécuriser ton token, moi je vais saisir kaherecode
, en production il faut choisir un pass phrase plus sécurisé. Une fois que tu as choisi ton pass phrase, valide le et ressaisi le à nouveau pour confirmer.
Il faut ensuite générer la clé public:
$ openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
Saisi le même pass phrase que tout à l'heure et c'est bon.
Les deux fichiers private.pem
et public.pem
ne doivent pas être pris en compte par git
, ils sont donc ignorés dans le .gitignore
.
Modifie ensuite le fichier .env
, la section lexik/jwt-authentication-bundle
comme 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=
###< lexik/jwt-authentication-bundle ###
La clé JWT_PASSPHRASE
doit etre vide ici, parce qu'il ne faut pas partager le pass phrase, il faut ensuite renseigner le passphrase dans le fichier .env.local
:
###> lexik/jwt-authentication-bundle ###
JWT_PASSPHRASE=kaherecode
###< lexik/jwt-authentication-bundle ###
Nous allons ensuite modifier le fichier security.yaml
:
# config/packages/security.yaml
security:
encoders:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: username
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
login:
pattern: ^/api/login
stateless: true
anonymous: true
json_login:
check_path: /api/login
username_path: username
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/api/
stateless: true
anonymous: true
provider: app_user_provider
guard:
authenticators:
- lexik_jwt_authentication.jwt_token_authenticator
main:
anonymous: lazy
provider: app_user_provider
access_control:
- { path: ^/api/docs, roles: IS_AUTHENTICATED_ANONYMOUSLY } # Allows accessing the Swagger UI
- { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/users, roles: IS_AUTHENTICATED_FULLY }
Nous définissons les firewalls login
et api
. Dans le firewall login
, la route pour la connexion c'est /api/login
et nous allons l'envoyer un objet JSON qui contient les clés username
et password
.
Dans la section access_control
, je rattache l'endpoint /api/users
au rôle IS_AUTHENTICATED_FULLY
, ce qui veut dire qu'il faut être authentifié pour accéder à la liste des utilisateurs, ajouter un utilisateur, modifier, ...
Il faut ensuite ajouter la route pour la connexion dans le fichier routes.yaml
:
# config/routes.yaml
authentication_token:
path: /api/login
methods: ['POST']
Si tu essaies d'accéder à la route http://127.0.0.1:8000/api/users tu as une erreur:
Il n'y a pas de token JWT.
On avait dit que pour accéder aux routes sécurisées, il faut à chaque fois envoyer un token dans la requête, et pour avoir ce token, il faut s'authentifier. Je vais utiliser postman:
Tu peux remarquer que quand j'essaie de m'identifier avec de mauvais identifiants, il y a une erreur qui le signale. Mais quand les informations sont bonnes, je recois un token en reponse, c'est ce token qu'il faut ensuite envoyer avec la requête. Je vais te montrer comment l'utiliser sur postman:
Ce token contient plusieurs informations comme le username de l'utilisateur, ses rôles et aussi la date d'expiration du token, et oui, le token comme les sessions à une date d'expiration, il faudra ensuite s'authentifier pour obtenir un nouveau token. Tu peux lire le contenu du token sur jwt.io, il y a une section debugger ou tu peux coller ton token dans le champ encoded et les informations seront affichées à droite juste a côté.
Et si tu veux directement tester tes endpoints dans ton navigateur avec l'interface de swagger, il faut modifier le fichier api_platform.yaml
comme ceci:
# config/packages/api_platform.yaml
api_platform:
title: 'Symfony REST API'
description: 'A Symfony API to manage a simple blog app.'
version: '1.0.0'
mapping:
paths: ['%kernel.project_dir%/src/Entity']
patch_formats:
json: ['application/merge-patch+json']
swagger:
versions: [3]
api_keys:
apiKey:
name: Authorization
type: header
Un bouton Authorize
va s'afficher sur l'interface:
Et pour renseigner le token:
Il faut toujours saisir Bearer
puis coller le token juste après.
Et voilà, nous arrivons maintenant à authentifier nos utilisateurs et bloquer certains endpoints pour seulement les utilisateurs authentifiés.
Dans la prochaine partie, nous allons parler de l'autorisation et voir comment interdire certaines actions aux utilisateurs qui n'ont pas les bon rôles. D'ici là, pratique tout ce que nous avons fait jusque là, de la partie 1, en passant par la partie 2. Tu as déjà toutes les connaissances pour développer ce que tu veux avec Symfony et API Platform, je compte sur toi. N'hésite pas à laisser un commentaire ci-dessous ou à m'écrire sur le chat discord de Kaherecode où je serais plus disponible pour te répondre le plus vite possible. Merci, à bientôt.
Participe à la discussion