Skip to content

Commit

Permalink
Allow setting multiple cookies
Browse files Browse the repository at this point in the history
  • Loading branch information
chalasr committed May 28, 2020
1 parent 0455f11 commit bec1244
Show file tree
Hide file tree
Showing 11 changed files with 128 additions and 65 deletions.
27 changes: 17 additions & 10 deletions DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\HttpFoundation\Cookie;

/**
* LexikJWTAuthenticationBundle Configuration.
Expand Down Expand Up @@ -75,16 +76,22 @@ public function getConfigTreeBuilder()
->info('If null, the user ID claim will have the same name as the one defined by the option "user_identity_field"')
->end()
->append($this->getTokenExtractorsNode())
->arrayNode('set_cookie')
->canBeEnabled()
->children()
->scalarNode('name')
->defaultNull()
->info('The cookie name. If null, it will be the same as the one defined by the "token_extractors.cookie.name" option.')
->end()
->scalarNode('lifetime')
->defaultNull()
->info('The cookie lifetime. If null, it will be the same as the one defined by the "token_ttl" option')
->arrayNode('set_cookies')
->fixXmlConfig('set_cookie')
->normalizeKeys(false)
->useAttributeAsKey('name')
->prototype('array')
->children()
->scalarNode('lifetime')
->defaultNull()
->info('The cookie lifetime. If null, the "token_ttl" option value will be used')
->end()
->enumNode('samesite')
->values([Cookie::SAMESITE_NONE, Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT])
->defaultValue(Cookie::SAMESITE_LAX)
->end()
->scalarNode('path')->defaultValue('/')->cannotBeEmpty()->end()
->scalarNode('domain')->defaultNull()->end()
->end()
->end()
->end()
Expand Down
22 changes: 16 additions & 6 deletions DependencyInjection/LexikJWTAuthenticationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Console\Application;
use Symfony\Component\DependencyInjection\Alias;
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
Expand Down Expand Up @@ -93,16 +95,24 @@ public function load(array $configs, ContainerBuilder $container)
->getDefinition('lexik_jwt_authentication.extractor.chain_extractor')
->replaceArgument(0, $tokenExtractors);

if ($this->isConfigEnabled($container, $config['set_cookie'])) {
if ($config['set_cookies']) {
$loader->load('cookie.xml');
$container
->getDefinition('lexik_jwt_authentication.cookie_provider')
->replaceArgument(0, $config['set_cookie']['name'] ?: $config['token_extractors']['cookie']['name'])
->replaceArgument(0, $config['set_cookie']['lifetime'] ?: ($config['token_ttl'] ?: 0));

$cookieProviders = [];
foreach ($config['set_cookies'] as $name => $attributes) {
$container
->setDefinition($id = "lexik_jwt_authentication.cookie_provider.$name", new ChildDefinition('lexik_jwt_authentication.cookie_provider'))
->replaceArgument(0, $name)
->replaceArgument(1, $attributes['lifetime'] ?: ($config['token_ttl'] ?: 0))
->replaceArgument(2, $attributes['samesite'])
->replaceArgument(3, $attributes['path'])
->replaceArgument(4, $attributes['domain']);
$cookieProviders[] = new Reference($id);
}

$container
->getDefinition('lexik_jwt_authentication.handler.authentication_success')
->replaceArgument(2, new Reference('lexik_jwt_authentication.cookie_provider'));
->replaceArgument(2, new IteratorArgument($cookieProviders));
}
}

Expand Down
10 changes: 6 additions & 4 deletions Resources/config/cookie.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<services>
<service id="lexik_jwt_authentication.cookie_provider" class="Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Cookie\JWTCookieProvider">
<argument>null</argument> <!-- Default cookie name -->
<argument>null</argument> <!-- Default cookie lifetime -->
<service id="lexik_jwt_authentication.cookie_provider" class="Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Cookie\JWTCookieProvider" abstract="true">
<argument>null</argument> <!-- Default name -->
<argument>null</argument> <!-- Default lifetime -->
<argument/> <!-- Default samesite -->
<argument/> <!-- Default path -->
<argument>null</argument> <!-- Default domain -->
</service>
<service id="Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Cookie\JWTCookieProvider" alias="lexik_jwt_authentication.cookie_provider" />
</services>
</container>
2 changes: 1 addition & 1 deletion Resources/config/response_interceptor.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<service id="lexik_jwt_authentication.handler.authentication_success" class="Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler">
<argument type="service" id="lexik_jwt_authentication.jwt_manager"/>
<argument type="service" id="event_dispatcher"/>
<argument type="service" id="lexik_jwt_authentication.cookie_provider" on-invalid="null"/>
<argument type="collection"/> <!-- Cookie providers -->
<tag name="monolog.logger" channel="security" />
</service>
<service id="Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler" alias="lexik_jwt_authentication.handler.authentication_success" />
Expand Down
13 changes: 8 additions & 5 deletions Response/JWTAuthenticationSuccessResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ final class JWTAuthenticationSuccessResponse extends JsonResponse
* @param string $token Json Web Token
* @param array $data Extra data passed to the response
*/
public function __construct($token, array $data = [], Cookie $jwtCookie = null)
public function __construct($token, array $data = [], array $jwtCookies = [])
{
if ($jwtCookie) {
parent::__construct($data);
$this->headers->setCookie($jwtCookie);
if (!$jwtCookies) {
parent::__construct(['token' => $token] + $data);

return;
}

parent::__construct(['token' => $token] + $data);
parent::__construct($data);

foreach ($jwtCookies as $cookie) {
$this->headers->setCookie($cookie);
}
}
}
20 changes: 11 additions & 9 deletions Security/Http/Authentication/AuthenticationSuccessHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@
*/
class AuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
private $cookieProvider;
private $cookieProviders;

protected $jwtManager;
protected $dispatcher;

public function __construct(JWTTokenManagerInterface $jwtManager, EventDispatcherInterface $dispatcher, JWTCookieProvider $cookieProvider = null)
/**
* @param iterable|JWTCookieProvider[] $cookieProviders
*/
public function __construct(JWTTokenManagerInterface $jwtManager, EventDispatcherInterface $dispatcher, $cookieProviders = [])
{
$this->jwtManager = $jwtManager;
$this->dispatcher = $dispatcher;
$this->cookieProvider = $cookieProvider;
$this->cookieProviders = $cookieProviders;
}

/**
Expand All @@ -50,13 +53,12 @@ public function handleAuthenticationSuccess(UserInterface $user, $jwt = null)
$jwt = $this->jwtManager->create($user);
}

$cookie = null;

if ($this->cookieProvider) {
$cookie = $this->cookieProvider->createCookie($jwt);
$jwtCookies = [];
foreach ($this->cookieProviders as $cookieProvider) {
$jwtCookies[] = $cookieProvider->createCookie($jwt);
}

$response = new JWTAuthenticationSuccessResponse($jwt, [], $cookie);
$response = new JWTAuthenticationSuccessResponse($jwt, [], $jwtCookies);
$event = new AuthenticationSuccessEvent(['token' => $jwt], $user, $response);

if ($this->dispatcher instanceof ContractsEventDispatcherInterface) {
Expand All @@ -67,7 +69,7 @@ public function handleAuthenticationSuccess(UserInterface $user, $jwt = null)

$responseData = $event->getData();

if ($cookie) {
if ($jwtCookies) {
unset($responseData['token']);
}

Expand Down
53 changes: 34 additions & 19 deletions Security/Http/Cookie/JWTCookieProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,48 +9,63 @@
*/
final class JWTCookieProvider
{
private $defaultCookieName;
private $defaultCookieLifetime;
private $defaultName;
private $defaultLifetime;
private $defaultSameSite;
private $defaultPath;
private $defaultDomain;

/**
* @param string|null $defaultCookieName
* @param int|null $defaultCookieLifetime
* @param string|null $defaultName
* @param int|null $defaultLifetime
* @param string $defaultPath
* @param string|null $defaultDomain
* @param string $defaultSameSite
*/
public function __construct($defaultCookieName = null, $defaultCookieLifetime = 0)
public function __construct($defaultName = null, $defaultLifetime = 0, $defaultSameSite = Cookie::SAMESITE_LAX, $defaultPath = '/', $defaultDomain = null)
{
$this->defaultCookieName = $defaultCookieName;
$this->defaultCookieLifetime = $defaultCookieLifetime;
$this->defaultName = $defaultName;
$this->defaultLifetime = $defaultLifetime;
$this->defaultSameSite = $defaultSameSite;
$this->defaultPath = $defaultPath;
$this->defaultDomain = $defaultDomain;
}

/**
* Creates a secure cookie containing the passed JWT.
*
* For each argument (all args except $jwt), if it is omitted or set to null then the
* default value defined via the constructor will be used.
*
* @param string $jwt
* @param string|null $name The cookie name. If null, the default will be used.
* @param int|string|\DateTimeInterface|null $expiresAt The cookie expiration datetime. If null, the default one will be used
* @param null $domain
* @param null $path
* @param string|null $name
* @param int|string|\DateTimeInterface|null $expiresAt
* @param string|null $sameSite
* @param string|null $path
* @param string|null $domain
*
* @return Cookie
*/
public function createCookie($jwt, $name = null, $expiresAt = null, $sameSite = Cookie::SAMESITE_LAX, $domain = null, $path = '/')
public function createCookie($jwt, $name = null, $expiresAt = null, $sameSite = null, $path = null, $domain = null)
{
if (!$name && !$this->defaultCookieName) {
if (!$name && !$this->defaultName) {
throw new \LogicException(sprintf('The cookie name must be provided, either pass it as 2nd argument of %s or set a default name via the constructor.', __METHOD__));
}

if (!$expiresAt && !$this->defaultCookieLifetime) {
if (!$expiresAt && !$this->defaultLifetime) {
throw new \LogicException(sprintf('The cookie expiration time must be provided, either pass it as 3rd argument of %s or set a default lifetime via the constructor.', __METHOD__));
}

return new Cookie(
$name ?: $this->defaultCookieName,
$name ?: $this->defaultName,
$jwt,
null === $expiresAt ? (time() + $this->defaultCookieLifetime) : $expiresAt,
$path,
$domain,
null === $expiresAt ? (time() + $this->defaultLifetime) : $expiresAt,
$path ?: $this->defaultPath,
$domain ?: $this->defaultDomain,
true,
true,
false,
$sameSite
$sameSite ?: $this->defaultSameSite
);
}
}
33 changes: 24 additions & 9 deletions Tests/Functional/GetTokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Events;
use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationSuccessResponse;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;

class GetTokenTest extends TestCase
{
Expand All @@ -21,9 +23,7 @@ public function testGetToken()
$this->assertInstanceOf(JWTAuthenticationSuccessResponse::class, $response);
$this->assertTrue($response->isSuccessful());

$body = json_decode($response->getContent(), true);

$this->assertArrayHasKey('token', $body, 'The response should have a "token" key containing a JWT Token.');
$this->getToken($response);
}

public function testGetTokenWithListener()
Expand All @@ -44,9 +44,7 @@ public function testGetTokenWithListener()
});

static::$client->request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'dummy']);
$body = json_decode(static::$client->getResponse()->getContent(), true);

static::$client->request('GET', '/api/secured', [], [], [ 'HTTP_AUTHORIZATION' => "Bearer ".$body['token'] ]);
static::$client->request('GET', '/api/secured', [], [], [ 'HTTP_AUTHORIZATION' => "Bearer ".$this->getToken(static::$client->getResponse()) ]);

$this->assertArrayHasKey('added_data', $payloadTested->payload, 'The payload should contains a "added_data" claim.');
$this->assertSame('still visible after the event', $payloadTested->payload['added_data'], 'The "added_data" claim should be equal to "still visible after the event".');
Expand All @@ -64,14 +62,13 @@ public function testGetTokenWithCustomClaim()

static::$client->request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'dummy']);

$body = json_decode(static::$client->getResponse()->getContent(), true);
$decoder = static::$kernel->getContainer()->get('lexik_jwt_authentication.encoder');
$payload = $decoder->decode($body['token']);
$payload = $decoder->decode($token = $this->getToken(static::$client->getResponse()));

$this->assertArrayHasKey('custom', $payload, 'The payload should contains a "custom" claim.');
$this->assertSame('dummy', $payload['custom'], 'The "custom" claim should be equal to "dummy".');

$jws = (new Parser())->parse((string) $body['token']);
$jws = (new Parser())->parse($token);
$this->assertArrayHasKey('foo', $jws->getHeaders(), 'The payload should contains a custom "foo" header.');
}

Expand All @@ -93,4 +90,22 @@ public function testGetTokenFromInvalidCredentials()
$this->assertSame('Invalid credentials.', $body['message']);
$this->assertSame(401, $body['code']);
}

private function getToken(Response $response)
{
if (204 === $response->getStatusCode()) {
$cookies = $response->headers->getCookies();
if (isset($cookies[0]) && 'token' === $cookies[0]->getName()) {
$this->assertSame(Cookie::SAMESITE_STRICT, $cookies[0]->getSameSite());
return $cookies[0]->getValue();
}

$this->fail('No token found in response.');
}

$body = json_decode($response->getContent(), true);
$this->assertArrayHasKey('token', $body, 'The response should have a "token" key containing a JWT Token.');

return $body['token'];
}
}
8 changes: 7 additions & 1 deletion Tests/Functional/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,15 @@ protected static function getAuthenticatedToken()
$client = static::$client ?: static::$kernel->getContainer()->get('test.client');

$client->request('POST', '/login_check', ['_username' => 'lexik', '_password' => 'dummy']);
$responseBody = json_decode($client->getResponse()->getContent(), true);
$response = $client->getResponse();
$responseBody = json_decode($response->getContent(), true);

if (!isset($responseBody['token'])) {
$cookies = $response->headers->getCookies();
if (isset($cookies[0]) && 'token' === $cookies[0]->getName()) {
return $cookies[0]->getValue();
}

throw new \LogicException('Unable to get a JWT Token through the "/login_check" route.');
}

Expand Down
3 changes: 3 additions & 0 deletions Tests/Functional/app/config/config_default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ lexik_jwt_authentication:
secret_key: '%kernel.root_dir%/../config/jwt/private.pem'
public_key: '%kernel.root_dir%/../config/jwt/public.pem'
pass_phrase: testing
set_cookies:
token:
samesite: strict
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public function testOnAuthenticationSuccessSetCookie()

$cookieProvider = new JWTCookieProvider('access_token', 60);

$response = (new AuthenticationSuccessHandler($this->getJWTManager('secrettoken'), $this->getDispatcher(), $cookieProvider))
$response = (new AuthenticationSuccessHandler($this->getJWTManager('secrettoken'), $this->getDispatcher(), [$cookieProvider]))
->onAuthenticationSuccess($request, $token);

$this->assertInstanceOf(JsonResponse::class, $response);
Expand Down

0 comments on commit bec1244

Please sign in to comment.