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 idemailroles 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 qu'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 idemailusernamerolesname et en écriture emailusernamepasswordname. 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 emailusernamename 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.


Partager cet article

alioukahere

Mamadou Aliou Diallo

@alioukahere

Développeur web fullstack avec une passion pour l’entrepreneuriat et les nouvelles technologies. Fondateur de Kaherecode.