Aller au contenu principal

Authentification avec API Token

Maintenant qu'on a un endpoint qui renvoie nos articles, on aimerait le protéger par authentification, afin de limiter son utilisation uniquement à certains utilisateurs.

Pour ce faire, on va imposer aux clients d'accompagner leurs requêtes d'une en-tête X-API-TOKEN. Le rôle du serveur sera donc d'extraire cette en-tête, puis vérifier si le jeton (token) est valide (enregistré en base de données).

Fonctionnement​

L'API que nous allons créer va venir s'intégrer à une application full-stack, créée en mode --webapp. Nous avons donc déjà un mécanisme d'authentification par formulaire de login, et une classe de sécurité User.

Pour notre API, nous voulons un système légèrement différent : nous voulons conserver le formulaire de login et nos utilisateurs normaux, afin qu'ils puissent se connecter et utiliser l'application pour créer des articles, etc...mais nous voulons que notre section API soit accessible à des utilisateurs externes, d'autres services ou applications qui voudraient lire nos articles au format JSON.

Par conséquent, nous n'allons pas intégrer une clé d'API à la classe User existante, mais créer une nouvelle classe de sécurité ApiToken, qui sera stockée en base de données.

Cette classe deviendra donc une table, portant un jeton d'authentification qui permettra d'autoriser ou non l'accès à notre API.

Ainsi, tout client qui effectuera une requête sur notre API devra joindre son token dans les en-têtes de la requête. C'est grâce à l'en-tête portant le jeton que nous pourrons identifier la requête.

La classe de sécurité​

Pour créer une classe de sécurité, nous pouvons utiliser la commande make:user de la console.

La donnée d'identification sera donc une colonne token que nous stockerons en base de données (on voudra donc en faire une entité Doctrine).

En revanche, aucun mot de passe ne sera nécessaire. Nous ne voudrons donc pas hacher ni vérifier de mot de passe avec notre classe de sécurité ApiToken. Concrètement, qu'est-ce que cela veut dire ? Que la classe qui va être créée par le maker n'implémentera qu'une seule interface UserInterface et pas l'interface PasswordAuthenticatedUserInterface.

Une fois que nous avons répondu correctement aux questions du maker pour créer notre nouvelle classe de sécurité, on génèrera une migration qu'on exécutera après un passage en revue, pour créer notre nouvelle table.

make:entity

On peut ajouter d'autres champs à notre entité avec make:entity une fois qu'on a créé l'entité.

Par exemple, on va ajouter un champ name pour pouvoir connaître le client associé à la requête. On pourrait également ajouter un compteur de requêtes pour limiter le nombre de requêtes par jour, par mois, etc...

Le provider de token​

Le maker a automatiquement créé un provider pour notre classe de sécurité, dans le fichier de configuration config/packages/security.yaml.

Si on avait déjà un provider existant (celui de notre classe User), il l'a peut-être écrasé. Dans ce cas, réécrivons la configuration pour l'adapter à notre fonctionnement : nous voulons deux fournisseurs (providers), un pour les User et un pour les ApiToken :

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"
# 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
api_token_provider:
entity:
class: App\Entity\ApiToken
property: token

Le pare-feu (firewall)​

Afin de capter nos requêtes d'API et les protéger par authentification, il va falloir ajouter un pare-feu à notre configuration.

En le configurant sur un format d'URL particulier, on pourra isoler les requêtes d'API et les soumettre à notre mécanisme d'authentification.

Dans la section firewalls de la configuration, nous allons rajouter un pare-feu qu'on nommera sobrement api :

firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: /api/articles
provider: api_token_provider
main:
#...
indentation

Attention au format YAML et aux indentations !

Le pattern qu'on définit est très spécifique : vu qu'on n'a qu'un endpoint /api/articles, on va définir un pare-feu pour cette URL pour le moment. À l'avenir, on pourrait étendre le pattern si on définit davantage d'endpoints, par exemple commençant par /api, etc...

On indique également le provider qu'on voudra utiliser : notre fournisseur d'utilisateurs, dans ce cas, sera le api_token_provider ajouté plus tôt dans les providers.

Ordre des pare-feux

Attention à l'ordre des pare-feux : ici, le pare-feu api se trouve avant le pare-feu main. Ce dernier est le plus généraliste, celui qui captera toutes les requêtes, en-dehors des requêtes d'API.

Le composant de sécurité ne vérifie pas tous les pare-feux : dès qu'il en trouve un qui correspond à la requête, il s'arrête là et l'exécute.

L'authenticator​

Une fois notre classe de sécurité et notre pare-feu créés, il va falloir implémenter le mécanisme d'authentification qui permettra d'extraire le jeton de l'en-tête de requête (s'il est présent), puis de vérifier s'il existe dans la base de données afin d'autoriser ou non la requête.

Nous l'avons vu également, pour créer un authenticator, on peut utiliser la commande make:auth. Mais contrairement au précédent pour créer notre formulaire de login, on va créer un authenticator vide (Empty Authenticator). En réalité, le maker va générer une classe d'authenticator pré-remplie, puis ce sera à nous d'écrire l'implémentation des différentes méthodes.

On va nommer cet authenticator ApiTokenAuthenticator.

pare-feux

Le maker détecte dans notre configuration les différents pare-feux présents, et peut nous demander sur quel pare-feu nous voulons appliquer notre authenticator. Nous choisirons donc le pare-feu api

La classe créée contient diverses méthodes issues du contrat d'implémentation porté par l'interface Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface :

  • supports indique si notre authenticator est capable d'authentifier un utilisateur à partir de la requête entrante
  • authenticate permet d'authentifier l'utilisateur à partir de la requête entrante. C'est dans cette méthode qu'on écrira la manière d'authentifier. Pour ce faire, on devra créer un Passport qu'on retournera au composant Security
  • onAuthenticationSuccess indiquera quoi faire si l'authentification a réussi
  • onAuthenticationFailure, quant à elle, indiquera l'inverse : ce qui sera fait si l'authentification a échoué
Security component

Toutes ces méthodes sont appelées automatiquement par le composant de sécurité, qui détecte et enregistre les authenticators présents au sein de l'application.

Nous venons donc fournir un nouveau comportement qui s'intègrera au processus global exécuté par le composant de sécurité.

supports​

C'est la première méthode appelée par le composant de sécurité. Elle vise à vérifier si notre authenticator peut être utilisé ou pas.

Dans notre cas, on indiquera qu'on est capable d'authentifier cette requête si et seulement si elle contient une en-tête X-API-TOKEN :

class ApiTokenAuthenticator extends AbstractAuthenticator
{
// On a remonté le nom du token dans une constante pour pouvoir réutiliser la valeur à plusieurs endroits
private const TOKEN_NAME = 'X-API-TOKEN';

public function supports(Request $request): ?bool
{
return $request->headers->has(self::TOKEN_NAME);
}

//...
}

Si notre méthode supports retourne true, alors le composant de sécurité va appeler authenticate. Sinon, il passe à l'authenticator suivant qu'il aurait enregistré préalablement.

authenticate​

C'est la méthode principale : on reçoit la requête entrante, et on doit l'utiliser pour construire et retourner un Passport.

On va donc extraire le token de l'en-tête de requête (on peut de nouveau s'assurer qu'elle est bien présente), puis créer un SelfValidatingPassport :

public function authenticate(Request $request): Passport
{
$token = $request->headers->get(self::TOKEN_NAME);

if ($token === null) {
throw new CustomUserMessageAuthenticationException('No token provided');
}

return new SelfValidatingPassport(new UserBadge($token));
}

Attardons-nous sur le Passport :

  • Nous utilisons un SelfValidatingPassport et non un Passport classique car un Passport attend normalement des Credentials (un mot de passe par exemple) en plus d'un UserBadge. Dans notre cas, nous n'avons pas de mot de passe : détecter et retrouver le token nous permet d'autoriser la requête. SelfValidatingPassport permet donc de passer un UserBadge sans aucun Credential
  • Un UserBadge attend un identifiant utilisateur, ici notre token. C'est pour ça qu'on a indiqué la colonne token comme champ d'identification lorsqu'on a créé la classe de sécurité : c'est la donnée qui permet de retrouver l'utilisateur.
  • Un UserBadge, de manière optionnelle, peut recevoir une méthode anonyme permettant de requêter et trouver l'utilisateur associé à l'identifiant. Ici, nous ne fournissons pas ce paramètre optionnel, car quand nous avons créé notre classe de sécurité, nous avons également ajouté un provider pour nos tokens. C'est ce provider qu'il utilisera puisqu'on l'a précisé dans la configuration de notre pare-feu
  • Ainsi, le UserBadge va être chargé de retrouver l'utilisateur avec le fournisseur approprié. De manière plus générale, un UserBadge essayera de construire une instance d'une classe implémentant UserInterface. C'est donc pour ça qu'on a réalisé une classe de sécurité : la classe ApiToken implémente UserInterface

onAuthenticationSuccess​

Cette méthode indique au composant de sécurité quoi faire si l'authentification a réussi.

Nous allons retourner null, ce qui permet de simplement laisser la requête continuer vers le contrôleur :

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
TokenInterface

L'instance d'objet implémentant TokenInterface peut permettre de récupérer l'utilisateur qui a été authentifié

onAuthenticationFailure​

En revanche, si l'authentification a échoué (Token incorrect), on voudra renvoyer une réponse au format JSON avec un message d'erreur et le code de réponse approprié :

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(
['message' => 'Unable to authenticate'],
Response::HTTP_UNAUTHORIZED
);
}

La configuration security.yaml​

Enfin, dans le fichier de configuration du composant Security, vu qu'on a choisi le pare-feu api à la création de l'authenticator, on le retrouve correctement configuré :

api:
pattern: /api/articles
provider: api_token_provider
custom_authenticator: App\Security\ApiTokenAuthenticator

Le contrôle d'accès​

Si on relance le serveur, à ce stade, on devrait pouvoir faire une requête depuis un client comme Postman sans avoir d'erreur.

Mais la requête fonctionne toujours, qu'on renseigne un token ou pas 🤔

Ce qui nous gêne, c'est surtout que la requête fonctionne quand on ne fournit aucun token.

En effet, repensons au fonctionnement des Authenticators : le composant de sécurité interroge dans un premier temps les différents authenticators au travers de leur méthode supports, pour déterminer s'il est pertinent ou non d'appeler leur méthode authenticate.

Mais si on ne fournit aucun token, alors notre ApiTokenAuthenticator indique simplement qu'il ne supporte pas l'authentification.

Donc si on ne fournit aucun token dans l'en-tête de requête, notre authenticator n'est même pas exécuté.

Si on y pense, c'est logique : s'il n'y a pas d'en-tête X-API-TOKEN, cela ne concerne probablement pas notre pare-feu api, et donc notre authenticator ApiTokenAuthenticator...

Nous allons donc devoir configurer un contrôle d'accès sur notre URL /api/articles, afin de forcer l'authentification sur cette URL.

L'ordre des pare-feux​

L'ordre des pare-feux est également important : quand le composant de sécurité identifie un pare-feu acceptant la requête, alors il ne va pas plus loin.

Dans notre cas, nous avons 3 pare-feux :

  • dev pour tout ce qui concerne le profiler, la web debug toolbar et les assets
  • api pour notre endpoint /api/articles
  • main pour le reste de notre application

Donc, comme nous le disions plus haut, si on n'a pas d'en-tête X-API-TOKEN, le composant de sécurité ignore le pare-feu api et passe à main. main, quant à lui, hérite sa méthode supports de la classe abstraite AbstractLoginFormAuthenticator :

public function supports(Request $request): bool
{
return $request->isMethod('POST') && $this->getLoginUrl($request) === $request->getBaseUrl().$request->getPathInfo();
}

Il laisse donc passer la requête également puisqu'il ne supporte pas l'authentification ! C'est pourquoi nous pouvons afficher nos articles au format JSON sans même fournir une en-tête X-API-TOKEN.

Ainsi, si on ne fournit pas de jeton d'API pour notre URL /api/articles, on veut retomber dans le contrôle d'accès global :

config/packages/security.yaml
#...
access_control:
- { path: /api/articles, roles: IS_AUTHENTICATED_FULLY }
L'attribut IsGranted

Au lieu de configurer le contrôle d'accès globalement dans le fichier de configuration du composant de sécurité, on aurait également pu définir le contrôle d'accès directement sur le contrôleur, de manière spécifique, avec l'attribut IsGranted :

use Symfony\Component\Security\Http\Attribute\IsGranted;

//...

#[Route('/api/articles', name: 'app_api_article')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function index(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository->findAll();
return $this->json($articles, context: ['groups' => 'articles:read']);
}

Si on essaye d'accéder à /api/articles sans fournir de jeton, on recevra à présent une erreur 401 Unathorized.

Stateful ? Stateless ?​

Si on fournit un jeton valide, la requête fonctionne, on accède bien à la liste de nos articles au format JSON.

Mais si on retente sans jeton, on accède toujours à la ressource...

Cela ne correspond pas au fonctionnement que l'on souhaite : on veut extraire et identifier notre jeton à chaque requête.

Pour cela, nous devons passer en mode stateless, car actuellement, si on arrive à effectuer une requête avec un jeton valide, une session PHP est démarrée.

Pour faire en sorte que la requête soit correctement authentifiée à chaque fois, on passera donc le pare-feu api en mode stateless :

firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: /api/articles
stateless: true
provider: api_token_provider
custom_authenticator: App\Security\ApiTokenAuthenticator
#...