Do you want to have a remember-me like behaviour while using JWTs for authentication? Then read on.
- Autor
- Michael Zangerle
- Datum
- 12. August 2021
- Lesedauer
- 7 Minuten
In a new project we are using Sylius headless and also their new api which uses JWT tokens to authenticate. Our customer wanted a remember-me like behaviour which people are used to. So the question is, how do we get such a behaviour with JWT tokens in Sylius?
Refresh tokens
As the tokens are self-contained and cannot be revoked it’s recommended that they are short-lived. To prevent a user from having to reauthenticate again and again there exists this markitosgv/JWTRefreshTokenBundle bundle which provides a refresh token in addition to the JWT token. This refresh token can be used to request a new valid JWT token. The refresh token itself can only be used for that process and can also be revoked if needed. For this to work the user has to be regularly online and the client has to implement a logic to get a new JWT token regularly. If that’s the case for your project then you should go with this approach.
What we were trying to achieve was the possibility to have two slightly different TTLs (Time-to-Live) for the JWT tokens (e.g. 1 day and 30 days). Some users might be online every day, some only every few weeks and some even less. The tokens shouldn’t be valid forever, but we wanted to provide a certain level of comfort by not having to re-authenticate every time the majority of users visits the shop. Revoking a token or disabling a user would also result in the same amount of work and we didn’t want to introduce all this complexity with refresh tokens just for that. So what to do?
Cookies
Sylius uses the lexik/LexikJWTAuthenticationBundle bundle for the whole JWT logic and this is pretty well documented. So we decided to switch to JWT via cookies as a first step. This removes the need to keep the token on the client side with easy access through JS and instead store it in a cookie. That’s still on the client obviously but now it’s possible to make it more secure by setting httpOnly and secure to true and samesite to lax. This done by a few lines of configuration:
lexik_jwt_authentication:
// ...
token_extractors:
cookie:
enabled: true
name: BEARER
set_cookies:
BEARER:
samesite: lax
secure: true
httpOnly: true
// ...
Remember-me
To get a remember-me like behaviour we would need to have two different TTLs for those tokens (and cookies) depending on the decision of the user. Did the user check the remember-me checkbox then the longer TTL should be used, otherwise the default.
So let’s create some class that provides the right TTL based on the request first and let’s call it TtlProvider. Nothing fancy there. The getTtl function returns the right TTL depending on the request body.
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\RequestStack;
final class TtlProvider implements TtlProviderInterface
{
public const REMEMBER_ME = 'rememberMe';
public const DEFAULT_TTL = 86400; // one day in seconds
public const REMEMBER_ME_TTL = 2592000; // one month in seconds
private RequestStack $requestStack;
public function __construct(RequestStack $requestStack)
{
$this->requestStack = $requestStack;
}
public function getTtl(): int
{
if ($this->isLoginRequestWithRememberMeEnabled()) {
return self::REMEMBER_ME_TTL;
}
return self::DEFAULT_TTL;
}
private function isLoginRequestWithRememberMeEnabled(): bool
{
$request = $this->requestStack->getMasterRequest();
if (!$request || !$request->getContent()) {
return false;
}
$content = json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR);
return is_array($content) && array_key_exists(self::REMEMBER_ME, $content)
&& true === $content[self::REMEMBER_ME];
}
}
Now let’s make use of the new TtlProvider and integrate it with the LexikBundle. The easiest way to integrate it seems to be to add an event subscriber and override it there (thanks @chalasr). This could look similar to this:
<?php
declare(strict_types=1);
namespace App\Security;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Cookie\JWTCookieProvider as BaseJWTCookieProvider;
use Symfony\Component\HttpFoundation\Cookie;
final class JWTCookieProvider
{
private BaseJWTCookieProvider $cookieProvider;
private TtlProviderInterface $ttlProvider;
public function __construct(BaseJWTCookieProvider $cookieProvider, TtlProviderInterface $ttlProvider)
{
$this->cookieProvider = $cookieProvider;
$this->ttlProvider = $ttlProvider;
}
/**
* The expiresAt parameter will be ignored here and is just kept to be compatible with the BaseJWTCookieProvider.
* The real ttl will be determined based on the request by the ttl provider.
*/
public function createCookie(
string $jwt,
?string $name = null,
?int $expiresAt = null,
?string $sameSite = null,
?string $path = null,
?string $domain = null,
?bool $secure = null,
?bool $httpOnly = null,
array $split = []
): Cookie {
return $this->cookieProvider->createCookie(
$jwt,
$name,
(time() + $this->ttlProvider->getTtl()),
$sameSite,
$path,
$domain,
$secure,
$httpOnly,
$split
);
}
}
In a last step we have to configure everything in our services.yaml:
services:
// ...
App\Security\EventSubscriber\JWTOnCreateEventSubscriber:
arguments:
- '@App\Security\TtlProviderInterface'
tags:
- { name: kernel.event_subscriber }
App\Security\JWTCookieProvider:
decorates: lexik_jwt_authentication.cookie_provider.BEARER
arguments:
- '@.inner'
- '@App\Security\TtlProvider'
That’s it. Now if you send a login request with rememberMe:true in the payload the longer TTL will be used and in all other cases the shorter one.