Sécuriser une API avec JWT et API Platform

Publié le 31/12/2020 • Actualisé le 31/12/2020

Dans cet article nous allons voir comment sécuriser une API avec JWT et API Platform. Nous allons générer des jetons JWT à l'aide du bundle lexik/jwt-authentication et nous allons profiter du nouveau paramètre de sécurité des propriétés introduit dans API Platform 2.6. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 731" (du 28 décembre 2020 au 3 janvier 2021).

Prérequis

Je présumerai que vous avez au moins des connaissances basiques de Symfony et d'API Platform.

Configuration

  • PHP 8.3
  • Symfony 6.4.3
  • API Platform 3.1.7
  • lexik/jwt-authentication-bundle 2.10

Jetez un coup d'œil à mon fichier composer.json complet.

Introduction

JWT (JSON Web Tokens) est un standard ouvert défini dans la RFC 75191. Il permet l'échange sécurisé de jetons (tokens) entre plusieurs parties. Cette sécurité de l’échange se traduit par la vérification de l’intégrité des données à l’aide d’une signature numérique. (Wikipédia ). C'est actuellement l'un des moyens les plus pratiques et utilisé pour sécuriser l'accès à une API. En effet, comme chaque jeton à une durée de vie (ne générez pas de jetons n'expirant pas !). Même s'ils sont compromis, ces jetons seront considérés comme "expirés" après un certain temps. Bien sûr, la génération d'une nouvelle clé publique et privée invalide tous les jetons précédemment créés.

But

Dans cet article nous allons générer des jetons JWT pour différents types d'utilisateurs ; puis, nous allons les utiliser pour accéder à des ressources exposées par API Platform (des entités Doctrine). Ces ressources vont exposer des informations différentes selon les droits qu'embarquent les jetons.

Installation du bundle lexik/jwt-authentication-bundle

Tout d'abord, nous devons installer une librairie qui peut gérer les JWT. Je ne détaillerai pas la procédure complète. Veuillez scrupuleusement suivre les instructions de la page "démarrage rapide" du bundle.

Une fois installé, nous avons trois nouveaux paramètres dans le fichier .env de notre application :

###> 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="do_not_commit"
###< lexik/jwt-authentication-bundle ###
  • Le chemin de la clé privée et de la clé publique
  • La phrase secrète utilisée pour générer les clés avec l'exécutable OpenSSL

Comme vous le verrez, ce bundle est extra. C'est le genre de travail qui nous rappelle que l'open-source est super, et que nous devrions (plus) le supporter :

Créer et tester des jetons

Maintenant que le bundle est correctement installé, essayons-le. Nous pourrions utiliser Postman ou la ligne de commande. Ici, j'utilise mon Makefile pour avoir à ma disposition quelques raccourcis pratiques pour pouvoir rapidement tout tester. Voici ce que j'utilise :

## —— JWT 🕸 ———————————————————————————————————————————————————————————————————
BEARER    = `cat ./config/jwt/bearer.txt`
BASE_URL  = https://127.0.0.1#https://www.strangebuzz.com
PORT      = :8000

jwt-generate-keys: ## Generate the main JWT ket set (you can use the "lexik:jwt:generate-keypair" command now)
	@mkdir -p config/jwt
	@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

jwt-create-ok: ## Create a JWT for a valid test account (you can use the "lexik:jwt:generate-token" command now)
	@curl -s POST -H "Content-Type: application/json" ${BASE_URL}${PORT}/api/login_check -d '{"username":"reader","password":"test"}' | jq .token | sed "s/\"//g"

jwt-create-nok: ## Login attempt with wrong credentials
	@curl -s POST -H "Content-Type: application/json" ${BASE_URL}${PORT}/api/login_check -d '{"username":"foo","password":"bar"}' | jq

jwt-test: ./config/jwt/bearer.txt ## Test a JWT token to access an API Platform resource
	@curl -s GET -H 'Cache-Control: no-cache' -H "Content-Type: application/json" -H "Authorization: Bearer ${BEARER}" ${BASE_URL}${PORT}/api/books/1 | jq

Ces raccourcis me simplifient la vie. Si j'ai besoin de "tester" en production j'ai juste à changer l'URL et le port utilisés. Comme vous le savez peut-être, tous les articles incluent du "vrai" code que nous pouvons utiliser. Avant cela, laissez-moi vous présenter les données de "développement" que nous allons utiliser. Nous avons quatre profils correspondants à des rôles différents. J'utilise le (non moins excellent) bundle hautelook/alice-bundle, les voici :

App\Entity\User:
  user:
    email: user
    password: '\$argon2id\$v=19\$m=65536,t=4,p=1\$R7Yp2KvbpMryniA498C8hg\$KvwosmkhXg/Mi4ftiI7Ld6zb2BqZmH9FIpUzwbsuX50' # test
    roles: [] # It will have the ROLE_USER that is automatically assigned to each user

  reader:
    email: reader
    password: '\$argon2id\$v=19\$m=65536,t=4,p=1\$R7Yp2KvbpMryniA498C8hg\$KvwosmkhXg/Mi4ftiI7Ld6zb2BqZmH9FIpUzwbsuX50' # test
    roles: [ROLE_READER]

  admin:
    email: admin
    password: <{admin_password}>
    roles: [ROLE_ADMIN] # This role inherits from all other rights

Le premier correspond au cas où on n'est pas identifié du tout, nous l'appellerons "anonyme", il n'est donc pas dans les fixtures. Le deuxième profil "user" n'a pas de droits spéciaux ; il hérite du rôle par défaut ROLE_USER que chaque utilisateur possède une fois identifié. Le troisième a un rôle additionnel ROLE_READER. Le dernier a le rôle ROLE_ADMIN qui inclue tous les droits. Pour le deuxième et le troisième cas, le mot de passe est "test". Pour le dernier, le mot de passe est injecté à partir d'une variable d'environnement (un mot de passe très long et aléatoire généré par mon gestionnaire de mots de passe).

Maintenant que nous avons nos utilisateurs. Voyons comment obtenir des jetons. Utilisez le makefile que je vous ai proposé ou lancez la commande suivante dans votre console :

curl -X POST -H "Content-Type: application/json" https://www.strangebuzz.com/api/login_check -d '{"username":"user","password":"test"}'

Si tout c'est bien déroulé, vous devriez avoir une réponse de ce type :

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MDkwOTYwMTQsImV4cCI6MTYwOTA5OTYxNCwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjoiYW5vbnltb3VzIn0.hAGkGIL3R8Jn9BcqtSsB_Ffg76gxC_kFIF4rWgcKCdaUuodHG10zJorwp2X4pqcF0a59YDBlSmb7atJEBoBWYYLKb7EH7by1dQILDNr3YlhdQ2lsGrFEj7HXzuVP5cpqaVNVEefIsP9rS-ZFQDIi__l07Ly-JSBWAzGgTGHHzFuC1uOVa7klqnClJL8wyytBNR1X9WHbcRL9Vm2BWBpa7lyIA_L_hzz4oI35AQx-oT4fd2g5OYu_3dzq1dOl_H-tPJW6U7rT8632VQ3q0R0f4OM-02mcR5vmy2TLmdiAJsKwcz9MPImUPfuXdhQcRCbc6dgMgjkpHJ5a9IfXCgJCr687DIET38myKL0Z2iVmRxC7nnwPuy9K4LhoJ26pqlPRM6r8Lc1iD1po0poMiBJyXCBfUq6-bmXXYz0102hgzhgsg8vwvpHHMlEvJUoalThkneDINrY0zgDOWwTLp251ktY573lz9EJnl36N3ULNED_1gmsJKCGMcdG-58TUmwSpiB8C6IkzJjEkF9k0PhCjk5bZGv7JJZXRMvQtLpc5MPOsCwctPf3Yj_zAkOx1sVgx5lHXWcI2C38yt2G_hvrsnGZndY1hgC9sbYnPOtrDY_BtoYVDDnWkhycUDOnT1tESYBZcIbDdfAfj-cDuMVzr-CjDrlQvip216eC0qUxBAU4"}

Vérifions ce jeton. Copiez son contenu (ce qui est après "token":") dans votre presse-papier, allez à https://jwt.io, puis collez le jeton en dessous de "paste a token here". Si c'est OK, dans le panneau de droite, vous devriez voir les informations relatives à notre utilisateur ; son nom et ces droits. Comme prévu, il a uniquement le rôle ROLE_USER. Par commodité je stocke le jeton dans un fichier texte ignoré par Git (./config/jwt/bearer.txt).

{
    "iat": 1609096014,
    "exp": 1609099614,
    "roles": [
        "ROLE_USER"
    ],
    "username": "user"
}

Les informations stockées dans le jeton sont donc correctes. Maintenant, essayons de l'utiliser pour accéder à des données exposées par API Platform.

Accéder à des données avec un jeton JWT

Maintenant que nous avons un jeton JWT valide, nous pouvons l'utiliser pour accéder à l'API. Nous allons utiliser le paramètre "sécurité" de l'annotation @ApiProtperty qui a été introduit dans API Platform 2.6. Voici une entité factice, pour chaque propriété, nous allons solliciter certains droits. Ainsi, selon les droits qu'embarquent les jetons, nous allons voir cette information ou pas. Voici l'entité :

<?php

/**
 * @noinspection EfferentObjectCouplingInspection
 */

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    types: 'https://schema.org/Book',
    operations: [
        // collection
        new GetCollection(),
        new Post(
            security: "is_granted('".User::ROLE_ADMIN."')"
        ),
        // item
        new Get(),
        new Delete(
            security: "is_granted('".User::ROLE_ADMIN."')"
        ),
    ],
    normalizationContext: ['groups' => ['book:read']]
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(OrderFilter::class, properties: ['id', 'title', 'author', 'isbn', 'publicationDate'])]
#[ORM\Entity]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    public ?int $id = null;

    /**
     * The ISBN of the book.
     */
    #[ApiProperty(types: ['https://schema.org/isbn'])]
    #[Assert\Isbn]
    #[ORM\Column(nullable: true)]
    #[Groups(groups: 'book:read')]
    public ?string $isbn = null;

    /**
     * The title of the book.
     */
    #[ApiProperty(types: ['https://schema.org/name'])]
    #[ApiFilter(SearchFilter::class, strategy: 'ipartial')]
    #[Assert\NotBlank]
    #[ORM\Column]
    #[Groups(groups: ['book:read', 'review:read'])]
    public ?string $title = null;

    /**
     * A description of the item.
     */
    #[ApiProperty(security: 'is_granted("'.User::ROLE_USER.'")', types: ['https://schema.org/description'])]
    #[Assert\NotBlank]
    #[ORM\Column(type: 'text')]
    #[Groups(groups: 'book:read')]
    public ?string $description = null;

    /**
     * The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably.
     */
    #[ApiProperty(security: 'is_granted("'.User::ROLE_READER.'")', types: ['https://schema.org/author'])]
    #[ApiFilter(SearchFilter::class, strategy: 'ipartial')]
    #[Assert\NotBlank]
    #[ORM\Column]
    #[Groups(groups: 'book:read')]
    public ?string $author = null;

    /**
     * The date on which the CreativeWork was created or the item was added to a DataFeed.
     */
    #[ApiProperty(security: 'is_granted("'.User::ROLE_ADMIN.'")', types: ['https://schema.org/dateCreated'])]
    #[Assert\Type(type: \DateTimeInterface::class)]
    #[Assert\NotNull]
    #[ORM\Column(type: 'date')]
    #[Groups(groups: 'book:read')]
    public ?\DateTimeInterface $publicationDate = null;

    public function getId(): ?int
    {
        return $this->id;
    }
}

Dans cette entité, nous avons quatre cas différents qui correspondent à nos quatre types de profils utilisateurs :

  • Les propriétés qui ne sont pas du tout sécurisées : isbn et title
  • Les propriétés qui demandent le rôle ROLE_USER : description
  • Les propriétés qui demandent le rôle ROLE_READER : author
  • Les propriétés qui demandent le rôle ROLE_ADMIN : publicationDate

Regardons les annotations @ApiProperty de ces propriétés :

* @ApiProperty(
*      iri="http://schema.org/dateCreated",
*      security="is_granted('ROLE_USER')"
* )

Maintenant, vérifions si les règles de sécurité fonctionnent comme prévu. Nous avons créé un jeton avec le rôle ROLE_USER; donc vous ne devrions voir que les propriétés isbn, title et description. Lancez la commande suivante, remplacez {$jwt} par le jeton que vous avez obtenu précédemment.

curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer {$jwt}" https://www.strangebuzz.com/api/books/1

On a la réponse suivante :

{
  "@context": "/api/contexts/Book",
  "@id": "/api/books/1",
  "@type": "http://schema.org/Book",
  "isbn": "9790070863971",
  "title": "Recusandae asperiores accusamus nihil repellat vero omnis voluptates.",
  "description": "Et et suscipit qui recusandae. Nulla quam ipsam voluptatem cupiditate sed. Debitis voluptas aut laudantium sit repudiandae esse. Dignissimos error et itaque quibusdam tempora velit."
}

Comme prévu, on ne voit que les propriétés qui nous sont accessibles. Maintenant essayons de supprimer l'entête Bearer de l'appel CURL :

curl -X GET -H "Content-Type: application/json" https://www.strangebuzz.com/api/books/1
{
  "@context": "/api/contexts/Book",
  "@id": "/api/books/1",
  "@type": "http://schema.org/Book",
  "isbn": "9790070863971",
  "title": "Recusandae asperiores accusamus nihil repellat vero omnis voluptates."
}

Nous ne voyons plus la propriété description, car le rôle ROLE_USER est requis. Comme on accède à l'API sans identification, l'utilisateur associé à la requête ne possède pas ce rôle. Maintenant, répétez l'opération, mais pour l'utilisateur "reader" et vérifiez que nous voyons toutes les propriétés à l'exception de celle ayant besoin du rôle ROLE_ADMIN ($publicationDate).

Empêcher les attaques de type brute-force

J'ai exposé des informations de sécurité publiquement. Donc, on pourrait créer de nombreux jetons de manière automatisée et cela pourrait porter préjudice au serveur, car il consommerait trop de ressources inutilement pour cette tâche. Pour empêcher cela, nous pouvons utiliser la nouvelle fonctionnalité Login Throttling qui a été introduite dans Symfony 5.2. Voici la configuration relative (config/packages/security.yaml) :

        login:
            pattern:  ^/api/login
            stateless: true
            login_throttling:
                max_attempts: 5
            json_login:
                provider:        app_user_provider
                check_path:      /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

Essayons de créer plus de cinq jetons en un temps très court, vous devriez avoir la réponse 401 (HTTP_UNAUTHORIZED) suivante :

{
    "code":401,
    "message":"Too many failed login attempts, please try again in 1 minute."
}

Comme vous pouvez le voir, ça fonctionne bien ! Vous ne pouvez plus créer de jeton avant 1 minutes 😁. Veuillez noter que ceci fonctionne également pour les échecs d'identification, on ne peut échouer que cinq fois avant d'être banni.

Conclusion

Nous avons vu comment nous pouvons exposer différentes informations à différents utilisateurs grâce à l'annotation @ApiProperty d'API Platform et de son paramètre security. Bien sûr, encore une fois, ce sont des cas simples, mais il y a un chapitre entier traitant de la sécurité dans la documentation API Platform (cf lien c-dessous). Quel que soit votre cas d'utilisation, vous trouverez une manière intelligente de l'implémenter.

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc  Quoi de neuf dans API Platform 2.6 ?

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil