Tous droits réservés

Sécurisation de l'API 2/2

Dernière mise à jour :
Auteur :
Catégories :

Un client utilisant notre API est maintenant en mesure de créer des tokens d’authentification. Nous allons donc rajouter un système de sécurité afin d’imposer l’utilisation de ce token pour accéder à notre API REST. Au lieu d’envoyer le login et le mot de passe dans chaque requête, nous utiliserons le token associé au client.

Exploitons le token grâce à Symfony

Vu que nous allons imposer l’utilisation du token dans toutes les requêtes, nous devons vérifier sa validité afin de nous assurer que le client de l’API est bien authentifié.

Pour nous assurer de ce bon fonctionnement, chaque requête doit contenir une entête X-Auth-Token qui contiendra notre token fraichement crée.

Le nom de notre entête ne vient pas du néant. De manière conventionnelle, lorsqu’une requête contient une entête n’appartenant pas aux spécifications HTTP, le nom débute par X-. Ensuite, le reste du nom reflète le contenu de l’entête, Auth-Token pour Authentication Token.

Nous avons beaucoup d’exemples dans notre API actuelle qui suivent ce modèle de nommage. Lorsque nous consultons les entêtes d’une réponse quelconque de notre API, nous pouvons voir X-Debug-Token (créé par Symfony en mode config dev) ou encore X-Powered-By (créé par PHP).

Entêtes personnalisées renvoyées par notre API

Symfony dispose d’un mécanisme spécifique permettant de gérer les clés d’API. Il existe un cookbook décrivant de manière très succincte les mécanismes en jeu pour le mettre en place.

Pour résumer, à chaque requête de l’utilisateur, un listener est appelé afin de vérifier que la requête contient une entête nommée X-Auth-Token. Et si tel est le cas, son existence dans notre base de données et sa validité sont vérifiées.

Pour une requête permettant de créer un token d’authentification, ce listener ne fait aucune action afin d’autoriser la requête.

Cinématique d’authentification en succès

Pour simplifier notre implémentation, nous considérons qu’un token d’authentification est invalide si son ancienneté est supérieur à 12 heures. Vous pouvez cependant modifier ce comportement et définir les règles de validité que vous voulez.

Cinématique d’authentification en erreur

Comme pour tous les systèmes d’authentification de Symfony, nous avons besoin d’un fournisseur d’utilisateurs (UserProvider). Pour notre cas, il faut que notre fournisseur puisse charger un token en utilisant la valeur dans notre entête X-Auth-Token.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
# src/AppBundle/Security/AuthTokenUserProvider.php

namespace AppBundle\Security;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Doctrine\ORM\EntityRepository;

class AuthTokenUserProvider implements UserProviderInterface
{
    protected $authTokenRepository;
    protected $userRepository;

    public function __construct(EntityRepository $authTokenRepository, EntityRepository $userRepository)
    {
        $this->authTokenRepository = $authTokenRepository;
        $this->userRepository = $userRepository;
    }

    public function getAuthToken($authTokenHeader)
    {
        return $this->authTokenRepository->findOneByValue($authTokenHeader);
    }

    public function loadUserByUsername($email)
    {
        return $this->userRepository->findByEmail($email);
    }

    public function refreshUser(UserInterface $user)
    {
        // Le systéme d'authentification est stateless, on ne doit donc jamais appeler la méthode refreshUser
        throw new UnsupportedUserException();
    }

    public function supportsClass($class)
    {
        return 'AppBundle\Entity\User' === $class;
    }
}

Cette classe permettra de récupérer les utilisateurs en se basant sur le token d’authentification fourni.

Pour piloter le mécanisme d’authentification, nous devons créer une classe implémentant l’interface SimplePreAuthenticatorInterface de Symfony. C’est cette classe qui gère la cinématique d’authentification que nous avons décrite plus haut.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
# src/AppBundle/Security/AuthTokenAuthenticator.php
<?php
namespace AppBundle\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Http\HttpUtils;

class AuthTokenAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
    /**
    * Durée de validité du token en secondes, 12 heures
    */
    const TOKEN_VALIDITY_DURATION = 12 * 3600;

    protected $httpUtils;

    public function __construct(HttpUtils $httpUtils)
    {
        $this->httpUtils = $httpUtils;
    }

    public function createToken(Request $request, $providerKey)
    {

        $targetUrl = '/auth-tokens';
        // Si la requête est une création de token, aucune vérification n'est effectuée
        if ($request->getMethod() === "POST" && $this->httpUtils->checkRequestPath($request, $targetUrl)) {
            return;
        }

        $authTokenHeader = $request->headers->get('X-Auth-Token');

        if (!$authTokenHeader) {
            throw new BadCredentialsException('X-Auth-Token header is required');
        }

        return new PreAuthenticatedToken(
            'anon.',
            $authTokenHeader,
            $providerKey
        );
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof AuthTokenUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of AuthTokenUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $authTokenHeader = $token->getCredentials();
        $authToken = $userProvider->getAuthToken($authTokenHeader);

        if (!$authToken || !$this->isTokenValid($authToken)) {
            throw new BadCredentialsException('Invalid authentication token');
        }

        $user = $authToken->getUser();
        $pre = new PreAuthenticatedToken(
            $user,
            $authTokenHeader,
            $providerKey,
            $user->getRoles()
        );

        // Nos utilisateurs n'ont pas de role particulier, on doit donc forcer l'authentification du token
        $pre->setAuthenticated(true);

        return $pre;
    }

    public function supportsToken(TokenInterface $token, $providerKey)
    {
        return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
    }

    /**
    * Vérifie la validité du token
    */
    private function isTokenValid($authToken)
    {
        return (time() - $authToken->getCreatedAt()->getTimestamp()) < self::TOKEN_VALIDITY_DURATION;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        // Si les données d'identification ne sont pas correctes, une exception est levée
        throw $exception;
    }
}

La configuration du service est classique :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# app/config/services.yml

# Learn more about services, parameters and containers at
# http://symfony.com/doc/current/book/service_container.html
parameters:
#    parameter_name: value

services:
    auth_token_user_provider:
        class: AppBundle\Security\AuthTokenUserProvider
        arguments: ["@auth_token_repository", "@user_repository"]
        public:    false

    auth_token_repository:
        class:   Doctrine\ORM\EntityManager
        factory: ["@doctrine.orm.entity_manager", "getRepository"]
        arguments: ["AppBundle:AuthToken"]

    user_repository:
        class:   Doctrine\ORM\EntityManager
        factory: ["@doctrine.orm.entity_manager", "getRepository"]
        arguments: ["AppBundle:User"]

    auth_token_authenticator:
        class:     AppBundle\Security\AuthTokenAuthenticator
        arguments: ["@security.http_utils"]
        public:    false

`

Nous devons maintenant activer le pare-feu (firewall) de Symfony et le configurer avec notre fournisseur d’utilisateurs et le listener que nous venons de créer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# app/config/security.yml

# To get started with security, check out the documentation:
# http://symfony.com/doc/current/book/security.html
security:

    # http://symfony.com/doc/current/book/security.html#where-do-users-come-from-user-providers
    providers:
        auth_token_user_provider:
            id: auth_token_user_provider

    firewalls:
        # disables authentication for assets and the profiler, adapt it according to your needs
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        main:
            pattern: ^/
            stateless: true
            simple_preauth:
                authenticator: auth_token_authenticator
            provider: auth_token_user_provider
            anonymous: ~

    encoders:
        AppBundle\Entity\User:
            algorithm: bcrypt
            cost: 12

Vous pouvez remarquer que le pare-feu (firewall) est configuré en mode stateless. À chaque requête, l’identité de l’utilisateur est revérifiée. La session n’est jamais utilisée.

Maintenant, lorsque nous essayons de lister les lieux sans mettre l’entête d’authentification, une exception est levée:

Requête Postman pour lister les lieux sans token d’authentification
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "error": {
    "code": 500,
    "message": "Internal Server Error",
    "exception": [
      {
        "message": "X-Auth-Token header is required",
        "class": "Symfony\\Component\\Security\\Core\\Exception\\BadCredentialsException",
        "trace": [
            "..."
        ]
      }
    ]
  }
}

Spoil ! Les codes de statut et les messages renvoyés pour ce cas de figure ne sont pas conformes aux principes REST. Nous verrons dans ce chapitre comment corriger le tir.

Pour le moment, l’exception BadCredentialsException, avec le message X-Auth-Token header is required, confirme bien que la vérification du token est effectuée.

En rajoutant le token que nous avions généré plus tôt, la réponse contient bien la liste des lieux de notre application.

Avec Postman, il faut accéder à l’onglet Headers en dessous de l’URL pour ajouter des entêtes à notre requête.

Requête Postman pour lister les lieux avec un token d’authentification
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[
  {
    "id": 1,
    "name": "Tour Eiffel",
    "address": "5 Avenue Anatole France, 75007 Paris",
    "prices": [
      {
        "id": 1,
        "type": "less_than_12",
        "value": 5.75
      }
    ],
    "themes": [
      {
        "id": 1,
        "name": "architecture",
        "value": 7
      },
      {
        "id": 2,
        "name": "history",
        "value": 6
      }
    ]
  },
  // ...
]

Notre API est maintenant sécurisée !

Par contre, la gestion des exceptions n’est pas encore très élaborée. En plus, vous l’avez peut-être déjà remarqué mais le format des messages d’erreur n’est pas uniforme. Lorsque le formulaire est invalide ou une exception est levée, les réponses renvoyées ne sont pas identiques, un client de l’API aura donc du mal à gérer les réponses en erreur.

Gestion des erreurs avec FOSRestBundle

Le bundle FOSRestBundle met à notre disposition un ensemble de composants pour gérer différents aspects d’une API. Et vous vous en doutez donc, il existe un listener pour gérer de manière simple et efficace les exceptions.

À l’état actuel, les exceptions sont gérées par le listener du bundle Twig. La première configuration à effectuer est de le remplacer par l’exception listener de FOSRestBundle. Pour cela, il suffit de rajouter une ligne dans la configuration du bundle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# app/config/config.yml

# ...
fos_rest:
    routing_loader:
        include_format: false
    # ...
    exception:
        enabled: true

`

En activant ce composant, la gestion des exceptions avec Twig est automatiquement désactivée. Rien qu’avec cette configuration, nous pouvons voir un changement dans la réponse lorsque l’entête X-Auth-Token n’est pas renseignée.

1
2
3
4
{
  "code": 500,
  "message": "X-Auth-Token header is required"
}
Code de statut de l’erreur interne

Lorsque le token renseigné n’est pas valide, nous obtenons comme réponse :

1
2
3
4
{
  "code": 500,
  "message": "Invalid authentication token"
}

Les messages correspondent à ceux que nous avons défini dans les exceptions parce que l’application est en mode développement. En production, ces messages sont remplacés par Internal Server Error. Pour s’en rendre compte, il suffit de lancer la même requête avec comme URL rest-api.local/app.php/places pour forcer la configuration en production.

1
2
3
4
{
  "code": 500,
  "message": "Internal Server Error"
}

Il peut arriver que nous voulions conserver les messages des exceptions même en production. Pour ce faire, il suffit de rajouter dans la configuration un système d’autorisation des exceptions concernées.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# app/config/config.yml

# ...
fos_rest:
    routing_loader:
        include_format: false
    # ...
    exception:
        enabled: true
        messages:
            'Symfony\Component\Security\Core\Exception\BadCredentialsException': true

Le tableau message contient comme clés les noms des exceptions à autoriser et la valeur vaut true.

En re-testant, la requête sur l’URL rest-api.local/app.php/places (n’oubliez pas de vider le cache avant de tester), le message est bien affiché :

1
2
3
4
{
  "code": 500,
  "message": "Invalid authentication token"
}

Nous venons de franchir un premier pas.

Mais comme nous l’avons déjà vu, le code 500 ne doit être utilisé que pour les erreurs internes du serveur. Pour le cas d’une authentification qui a échoué, le protocole HTTP propose un code bien spécifique - 401 Unauthorized - qui dit qu’une authentification est nécessaire pour accéder à notre ressource.

Encore une fois, FOSRestBundle propose un système très simple. À l’instar du tableau messages, nous pouvons rajouter un tableau codes avec comme clés les exceptions et comme valeur les codes de statut associés.

Nous aurons donc comme configuration finale :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# app/config/config.yml

# ...
fos_rest:
    routing_loader:
        include_format: false
    # ...
    exception:
        enabled: true
        messages:
            'Symfony\Component\Security\Core\Exception\BadCredentialsException': true
        codes:
            'Symfony\Component\Security\Core\Exception\BadCredentialsException': 401

Encore une fois ce bundle, nous facilite grandement le travail et réduit considérablement le temps de développement.

Lorsque nous re-exécutons notre requête :

Requête Postman sans token d’autorisation

La réponse contient le bon message et le code de statut est aussi correct :

Code de statut de la réponse non autorisée
1
2
3
4
{
  "code": 401,
  "message": "X-Auth-Token header is required"
}

L’attribut code dans la réponse est créé par FOSRestBundle par soucis de clarté. La contrainte REST, elle, exige juste que le code HTTP de la réponse soit conforme.

Vu que le bundle est conçu pour interagir avec Symfony, toutes les exceptions du framework qui implémentent l’interface `Symfony\Component\HttpKernel\Exception\HttpExceptionInterface peuvent être traitées automatiquement.

Si par exemple, nous utilisons l’exception NotFoundHttpException, le code de statut devient automatiquement 404. En général, il est aussi utile d’autoriser tous les messages des exceptions de type HttpException pour faciliter la gestion des cas d’erreurs.

La configuration du bundle devient maintenant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/config/config.yml
# ...

fos_rest:
    routing_loader:
        include_format: false
    # ...
    exception:
        enabled: true
        messages:
            'Symfony\Component\HttpKernel\Exception\HttpException' : true
            'Symfony\Component\Security\Core\Exception\BadCredentialsException': true
        codes:
            'Symfony\Component\Security\Core\Exception\BadCredentialsException': 401

`

Toutes les occurrences de return \FOS\RestBundle\View\View::create(['message' => 'Place not found'], Response::HTTP_NOT_FOUND); peuvent être remplacées par throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException('Place not found');.

Et de même return \FOS\RestBundle\View\View::create(['message' => 'User not found'], Response::HTTP_NOT_FOUND); peut être remplacé par throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException('User not found');.

Les réponses restent identiques mais les efforts fournis pour les produire sont réduits.

Récupération d’un utilisateur inexistant avec Postman
Code de statut de la réponse

401 ou 403, quand faut-il utiliser ces codes de statut ?

Les codes de statuts 401 et 403 sont souvent source de confusion. Ils sont tous les deux utiliser pour gérer les informations liées à la sécurité.

Le code 401 est utilisé pour signaler que la requête nécessite une authentification. Avec notre système de sécurité actuel, nous exigeons que toutes les requêtes - à part celle de création de token - aient une entête X-Auth-Token valide. Donc si une requête ne remplit pas ces conditions, elle est considérée comme non autorisée d’où le code de statut 401. En général, c’est depuis le pare-feu de Symfony que ce code de statut doit être renvoyé.

Par contre, le code 403 est utilisé pour signaler qu’une requête est interdite. La différence réside dans le fait que pour qualifier une requête comme étant interdite, nous devons d’abord identifier le client de l’API à l’origine de celle-ci. Le code 403 doit donc être utilisé si le client de l’API est bien identifié via l’entête X-Auth-Token mais ne dispose pas des privilèges nécessaires pour effectuer l’opération qu’il souhaite.

Si par exemple, nous disposons d’un appel API réservé uniquement aux administrateurs, si un client simple essaye d’effectuer cette requête, nous devrons renvoyer un code de statut 403. Ce code de statut peut être utilisé au niveau des ACLs (Access Control List) ou des voteurs de Symfony.

En résumé, le code de statut 401 permet de signaler au client qu’il doit décliner son identité et le code de statut 403 permet de notifier à un client déjà identifié qu’il ne dispose pas de droits suffisants.

Suppression d'un token ou la déconnexion

Pour en finir avec la partie sécurisation, il ne reste plus qu’à rajouter une méthode pour se déconnecter de notre API. La déconnexion consiste juste à la suppression du token d’authentification. Par contre, une petite précaution va s’imposer. Pour traiter la suppression d’un token, il faudra juste nous assurer que l’utilisateur veut supprimer son propre token et pas celui d’un autre. À part cette modification, tous les autres mécanismes déjà vus rentrent en jeu.

Pour supprimer la ressource, il faudra donc une requête DELETE sur la ressource rest-api.local/auth-tokens/{id}. La réponse en cas de succès doit être vide avec comme code de statut: 204 No Content.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# src/AppBunle/Controller/AuthTokenController.php
<?php
namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations
use AppBundle\Form\Type\CredentialsType;
use AppBundle\Entity\AuthToken;
use AppBundle\Entity\Credentials;

class AuthTokenController extends Controller
{
    //...

    /**
     * @Rest\View(statusCode=Response::HTTP_NO_CONTENT)
     * @Rest\Delete("/auth-tokens/{id}")
     */
    public function removeAuthTokenAction(Request $request)
    {
        $em = $this->get('doctrine.orm.entity_manager');
        $authToken = $em->getRepository('AppBundle:AuthToken')
                    ->find($request->get('id'));
        /* @var $authToken AuthToken */

        $connectedUser = $this->get('security.token_storage')->getToken()->getUser();

        if ($authToken && $authToken->getUser()->getId() === $connectedUser->getId()) {
            $em->remove($authToken);
            $em->flush();
        } else {
            throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException();
        }
    }

    // ...
}
Requête de suppression d’un token avec Postman

Si tout se passe bien, la réponse lors d’une suppression est vide avec comme statut 204. En cas d’erreur une réponse 400 est renvoyée au client.

1
2
3
4
{
  "code": 400,
  "message": "Bad Request"
}

Notre fameux tableau récapitulatif s’enrichit d’un nouveau code de statut et listing des entêtes HTTP utilisables:

Opération souhaitée Verbe HTTP
Lecture GET
Création POST
Suppression DELETE
Modification complète (remplacement) PUT
Modification partielle PATCH
Code statut Signification
200 Tout s’est bien passé et la réponse a du contenu
204 Tout s’est bien passé mais la réponse est vide
400 Les données envoyées par le client sont invalides
401 Une authentification est nécessaire pour accéder à la ressource
403 Le client authentifié ne dispose pas des droits nécessaires
404 La ressource demandée n’existe pas
500 Une erreur interne a eu lieu sur le serveur
Entête HTTP Contenu
Accept Un ou plusieurs média type souhaités par le client
Content-Type Le média type de la réponse ou de la requête
X-Auth-Token Token d’authentification

Un client de l’API peut maintenant se connecter et se déconnecter et toutes ses requêtes nécessitent une authentification. Notre API vient d’être sécurisée ! La durée de validité du token et les critères de validation de celui-ci sont purement arbitraires. Vous pouvez donc les changer à votre guise.

Pour les besoins de ce cours les requêtes se font via HTTP mais il faudra garder en tête que la meilleure des sécurités ne vaut rien si le protocole utilisé n’est pas sécurisé. Donc dans une API en production, il faut systématiquement utiliser le HTTPS.