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 ! 😎
» 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.12
- 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 :
#LexikJWTAuthenticationBundle reached 10million installs via #Composer last week!
— Robin Chalas (@chalas_r) December 20, 2020
I'm pretty sure some of them come from your #Symfony enterprise projects.
And you know what? It's part of the @tidelift subscription so you can help making it sustainable. https://t.co/D5JQXqjf94
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 ?
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 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 ! 😉
[🇫🇷] Dernier article de l'année : "Sécuriser une API avec JWT et API Platform" https://t.co/JHc51ydPVi Relectures, retours, likes et retweets sont les bienvenus ! 😉 Objectif annuel : 8/8 (100%) #apiplatform #symfony #jwt #security #api #php
— COil #StaySafe 🏡 #OnEstLaTech ✊ (@C0il) January 1, 2021