Securing an API with JWT and API Platform

Published on 2020-12-31 • Modified on 2020-12-31

In this post, we will see how to secure an API with JWT and API Platform. We will generate JWT security tokens thanks to the lexik/jwt-authentication-bundle, and we will take advantage of the new property security parameter introduced in API Platform 2.6. Let's go! 😎

» Published in "A week of Symfony 731" (28 December 2020 - 3 January 2021).

Prerequisite

I will assume you have at least a basic knowledge of Symfony and API Platform.

Configuration

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

Check-out my full composer.json file here.

Introduction

JWT (JSON Web Tokens) is an Internet standard for creating data with optional signature and optional encryption whose payload holds JSON that asserts some number of claims (Wikipedia ). This is currently one of the most used and practical ways to protect the access of an API. Indeed, as every token as a lifetime (don't generate non-expiring tokens!), even when leaked these tokens will be considered "expired" after a given amount of time. Of course, generating a new private and public key invalidates the previously generated ones.

Goal

In this article we will create JWT tokens for different users; then, we will use them to access API PLatform resources (Doctrine entities). These resources expose different information depending on the rights the tokens carry with them.

Installing the lexik/jwt-authentication-bundle

First, we have to install a library that can handle JWT. I will not detail the full procedure. Carefully follow the instruction of the getting started page.

When installed, we have three new parameters in the .env file:

###> 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 ###
  • The paths to the private and the public key.
  • The passphrase used to generate the keys with the OpenSSL executable.

As you will see, this bundle is very nice. It's the kind of work that reminds us how great open source is, and that we should support it:

Creating and testing tokens

Now that the bundle is correctly installed let's try it. We could use Postman or the command line. Here I use my Makefile to have at my disposal some handy shortcuts to test all this quickly. Here is what I use:

## —— 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

These shortcuts make my life easier. If I need to "test" in production, I have to change the URL and the port. As you may know, all my posts run "real" code, so let's test this. Before doing that, let me introduce the "development" fixtures we are going to use. We have four profiles corresponding to various roles. I use the (also excellent) hautelook/alice-bundle for this, here they are:

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

The first one is when we aren't identified, we will call it "anonymous", it isn't in the fixtures. The second profile "user" doesn't have special rights; it will inherit the default ROLE_USER that every user has when authenticated. The third has an additional ROLE_READER right. And the last one is the admin one which has the ROLE_ADMIN right that includes every rights. For the second and third case, the password is "test". For the last one the password is injected as an environment variable (a very long password randomly generated by my password manager).

Now that we have our users. Let's try to require tokens. Create a makefile or run the following command in your terminal:

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

If things went well, you should have a response like the following:

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

Let's verify this token. Copy its content (after "token":") in your clipboard and go to https://jwt.io, then paste it below "paste a token here". If it is OK, at the right you should see the information related to our user, its name and its rights. As expected, he has only the ROLE_USER. For convenience, I store the token I want to test in a file that is ignored by Git (./config/jwt/bearer.txt).

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

The information stored in the JWT token is correct. Let's try to use it to access some data exposed by API Platform.

Requesting data with the JWT token

Now that we have a valid JWT, we can use it to access the API. We will use the security parameter of the @ApiProtperty annotation that was introduced in API Platform 2.6. Here is a fake entity, for each property we will require given rights or not. Therefore, depending on the right the JWT carries, we will see or not this information. Here is the entity:

<?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;
    }
}

In this entity, we have four different cases regarding the rights, these cases correspond to four profiles we introduced earlier:

  • The properties that are not secured at all: isbn and title.
  • The properties that require the ROLE_USER right: description
  • The properties that require the ROLE_READER right: author
  • The properties that require the ROLE_ADMIN right: publicationDate

Take a look at the @ApiProperty annotation of each of these properties:

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

Now let's verify if the security checks work as expected. We have created a JWT with the ROLE_USER role, so we should only view the isbn, title and description properties. Run the following command, replace {$jwt} by the token you got before:

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

We have the following output:

{
  "@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."
}

As expected, we can only see the reachable properties for this user. Now try to remove the Bearer header from the CURL call:

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."
}

We don't see the description property anymore because the ROLE_USER is required. As the API was accessed without any authentication, the user associated to the request doesn't have this role. Now, repeat the operation but for the "reader" user and verify that you can see all fields except the one that needs the ROLE_ADMIN right ($publicationDate).

Preventing brute-force attacks

I have exposed security information publicly. Therefore one could request many tokens and perhaps harm the server because it would consume quite a lot of resources. To prevent this, we can use the new Login Throttling feature that was introduced in Symfony 5.2. Here is the configuration (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

Now, try to require a token more than five times in a short time, you should have the following 401 response (HTTP_UNAUTHORIZED):

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

As you can see, it does the job! You can't require a new token for 1 minute 😁. Note that this feature also works for failed login attempts, you can only fail five times before being banned.

Conclusion

We saw how we could expose different information to different users thanks to the @ApiProperty annotation of API Platform and its security parameter. Of course, once again, these are simples cases, but there is a whole chapter dealing with security in the API Platform documentation (link below). Whatever your use case is, you will find a nice way to implement it.

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  What's new in API Platform 2.6 ?

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! 😉

COil