In APIs, user verification is often done by using generated keys which are returned in a response after a valid login process. One way of identifying registered users during the requests is by the JWT tokens, which are often sent in the request headers. Once the token is successfully decoded, the application is able to recognize the identity of a user.
In this post I'm going to show you how to easily create a user verification system based on the aforementioned tokens in Symfony 6.
What is JWT
Json Web Token (JWT) is a standard (RFC 7519) which defines how to securely exchange data in JSON format. Token can be encoded with a secret key using HMAC algorithm. Asymmetric algorithms, like RSA or ECDSA, are also often used.
Token contains three parts which are separated by dots - a.b.c
. The parts of the token are:
- Header - contains info about the algorithm which has been used to encrypt the data,
- Payload - this part contains data which we want to encode in the token - it can be, e.g. user id, their role in the system or the expiration date of the token,
- Signature - it's a digital signature which confirms that the data in the token has not been changed.
JWT in Symfony
Symfony, with its components and a few external libraries, allows us to set up authentication and authorization in just a few simple steps. To secure our API we are going to use:
- SecurityBundle
- LexikJWTAuthenticationBundle
To begin with, let's install SecurityBundle:
composer require symfony/security-bundle
LexikJWTAuthenticationBundle
will be used to handle log in, token generation and validation
composer require lexik/jwt-authentication-bundle
In this example, to encrypt the tokens, I'm going to use a pair of private/public keys. To generate them, we only need to run below command:
php bin/console lexik:jwt:generate-keypair
The keys will be generated in config/jwt
directory.
The configuration of LexikJWTAuthenticationBundle
is located in config/packages/lexik_jwt_authentication.yaml
. By default, it contains paths to the keys and a passphrase, which are read from environment variables. We need to add these values to the .env
file if they are missing, but usually they will be added automatically during installation of the bundle:
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pub
JWT_PASSPHRASE=secretkey
Token is valid for one hour by default. It can be changed by setting token_ttl
param to the given amount of seconds in the configuration file.
Securing the application
SecurityBundle
is a very powerful tool which allows us to configure applications' security. It provides several ways of controlling user access and gives us the ability to create own ones.
Below example shows how to configure in-memory user, with login and password stored in the configuration file. Configuring a user in this way is very simple and requires adding the following code to the config/packages/security.yml
file
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
my_in_memory_users:
memory:
users:
admin: { password: $2y$13$hA/l.ZYwcmAnxoZbfJtPdeFrWhrpmbnkYeWafMZR7vb1ilo5wOt5., roles: [ 'ROLE_ADMIN' ] }
In the password_hashers
section, we choose a password hashing algorithm (auto
selects the best available hasher). In the providers
we declare a user and their password, which is being hashed by running the command:
bin/console security:hash-password
Once the user has been added, we have to determine which resources will be protected from unauthorized access. To handle it, we will use the next sections in the security.yaml
file - firewalls
and access_control
Application can have several secured areas, e.g. admin panel or API. We can use a different provider for each of them.
In this case, we are going to configure two values in the firewall section:
- authentication:
api_login:
pattern: ^/api/login
stateless: true
json_login:
provider: my_in_memory_users
check_path: /api/login_check #same as the route configured in config/routes.yaml
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
In the above configuration, in the pattern
parameter we specify which resources are going to be protected by the firewall.
- resource for authenticated user:
We add a new resource in the firewall, which will be accessible for logged-in users
api:
pattern: ^/api
stateless: true
jwt: ~
Here we specify a pattern indicating which resource will be protected - in this case, all urls starting with /api
. The jwt
parameter gives us the control over the authentication process. We are going to use the default service provided by LexikJWTAuthenticationBundle
- JWTAuthenticator
. It decodes the token and authenticates it. We can also provide our own authenticator by creating a service which implements AuthenticatorInterface
.
Now we have to add routing which was used in api_login
section:
# config/routes.yaml
api_login_check:
path: /api/login_check
In the access_control
section we have to specify two values:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
To test if logging in works as expected, we make a request to the login page:
curl -X POST -H "Content-Type: application/json" https://localhost/api/login_check -d '{"username":"admin","password":"admin1"}'
In the response we receive a token:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1ODQyNzY1ODEsImV4cCI6MTU4NDI4MDE4MSwicm9sZXMiOlsiUk9MRV9BRE1JTiJdLCJ1c2VybmFtZSI6ImFkbWluIn0.u6VbYVV_fqSd9Y1_8wUPlzD20cET79EgnEsk19iqVG48db0kx9UFRIzjb62SyhdqPnfWXXsfXGapKS70XMHaxSaOpe7_P2f_bCAkiYJKgwGj6PhjDq8dFhx1AAXPKFWzWBj5mvjMaF0gCQEy00iNzoFhuqBEaK4fl6SqHsM4Nd1TigUsmNg1Kxrz4-G0W8Iz9vGiuRPjMVJzKYMh4iAemvIx8pB3XLYlmgvVvYQd5mMlxIhm4YsVTyXkwijSJPisK-RhORyFrpJfY7pzOTHi4R-bRYKKVx0Sg0vHkIfpNY9dW2ZV5tvtnfo02R7_yhHv2puaII_pdqjNklsQY-9fE4fy1fP_GXmQytmEYPseaISET5wRrLXABftjV_FXWnkt4rlYBI8_RVB8Dl6dGg1wjd0zGWoPoINdG7Y1hihJ2cNg96tirXKBPiKvU65y-rd6jNbxtgDX1vB6nu4pdEOgcIg49bG9kcWde19MUpCVTvQL291opDQcl7JveuscROU64-iYW2hx8BGsBoLVzWtOvUjcHV4Y7AU_oBNWdMblf8eywdDjsxAHYMHrSbPEpxq_wuOki5QVIFEpdrWvVekM7EzVRVoCVp4MKyhq4y1zJdn7sbFT-TYEULNNe9tIrA37YkFFMXY7KglSrOBlI-KKDlljpkOzNlA90lwM34dVj5Q"
}
We can go to jwt.io to check what data the token contains.
To access a secured resource, we have to pass the token in the header of each request.
Firstly, let's create a simple test controller:
class TestController
{
#[Route('/api/test')]
public function __invoke(): Response
{
return new JsonResponse('ok');
}
}
And make a request with the JWT token
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer [jwt token]" http://localhost/api/test
Refresh Token
Generated JWT tokens have their expiry time. In case the token expires, one of the option is to log in again. There is also another solution - using a refresh token. It's generated together with the regular token and is used to create a new JWT token when the old one expires. A request with a refresh token has to be sent to a specific address. In the response we will receive a new JWT token and new refresh token.
We will use JWTRefreshTokenBundle
to handle Refresh Tokens. As this tool uses database to store tokens, we will have to install Symfony ORM Pack
as well.
composer require symfony/orm-pack
composer require gesdinet/jwt-refresh-token-bundle
Now we have to configure the refresh token entity which will be used to store this kind of values:
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Table;
use Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken;
#[Entity]
#[Table(name: "refresh_tokens")]
class RefreshToken extends BaseRefreshToken
{
//...
}
After that we have to generate a migration:
bin/console doctrine:migrations:diff
And add the table to the database:
bin/console doctrine:migrations:migrate
We can configure JWTRefreshTokenBundle
in config/packages/gesdinet_jwt_refresh_token.yaml
file:
gesdinet_jwt_refresh_token:
ttl: 2592000
firewall: api
token_parameter_name: refresh_token
single_use: true
refresh_token_class: App\Entity\RefreshToken
We also need a new routing for refreshing the token. We can add it to config/routes/gesdinet_jwt_refresh_token.yaml
file.
gesdinet_jwt_refresh_token:
path: /api/token/refresh
At the end, let's add a new line to our api firewall so that it now looks like this:
api:
pattern: ^/api
stateless: true
entry_point: jwt
jwt: ~
refresh_jwt:
check_path: /api/token/refresh
We also have to modify the first entry in the access_control
section to allow unauthenticated users to refresh their tokens:
access_control:
- { path: ^/api/(login|token/refresh), roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
After successfully logging in we will receive a pair of JWT and refresh tokens in the response:
{
"token":"...",
"refresh_token":"..."
}
To generate a new token based on a refresh token, we have to make a request which will return the same data as during the above process.
curl -X POST -d refresh_token="..." http://localhost/api/token/refresh
There are multiple ways of storing returned tokens on the client side. One of them can be localStorage if we are using a browser.
Summary
As you can see, setting up a simple authentication and authorisation system using JWT keys is not complicated and allows us to secure access to an application in a few simple steps.
Top comments (3)
Thanks for the article @jszutkowski.
I have a problem with "api: ... jwt: ", because naturally just only ~ doesnt work in my project, but, I put provider I had created in start of that text and works after.
I will implements more that feature in my structure developed to my university project and probally I continue using that. Again, thanks for the help!
Thanks mate. What about how to build DDD architecture in Symfony app?
There are plans to do so :)