diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
index 84dd91b0ef061..55e0c754c6159 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
@@ -126,9 +126,19 @@ private function addCsrfSection(ArrayNodeDefinition $rootNode)
->treatTrueLike(['enabled' => true])
->treatNullLike(['enabled' => true])
->addDefaultsIfNotSet()
+ ->beforeNormalization()
+ ->ifArray()
+ ->then(function ($v) {
+ $v['enabled'] = isset($v['enabled']) ? $v['enabled'] : true;
+
+ return $v;
+ })
+ ->end()
->children()
// defaults to framework.session.enabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class)
->booleanNode('enabled')->defaultNull()->end()
+ // defaults to session if framework.session.enabled, cookie otherwise
+ ->scalarNode('storage')->defaultNull()->end()
->end()
->end()
->end()
diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
index 0fa3211e33fb7..23ed78b0eae66 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -107,6 +107,9 @@
use Symfony\Component\Routing\Matcher\Dumper\PhpMatcherDumper;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
+use Symfony\Component\Security\Csrf\EventListener\CookieTokenStorageListener;
+use Symfony\Component\Security\Csrf\TokenStorage\CookieTokenStorage;
+use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata;
@@ -257,8 +260,11 @@ public function load(array $configs, ContainerBuilder $container)
$this->registerRequestConfiguration($config['request'], $container, $loader);
}
+ if (null === $config['csrf_protection']['storage']) {
+ $config['csrf_protection']['storage'] = $this->sessionConfigEnabled || !class_exists(CookieTokenStorage::class) ? 'session' : 'cookie';
+ }
if (null === $config['csrf_protection']['enabled']) {
- $config['csrf_protection']['enabled'] = $this->sessionConfigEnabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class);
+ $config['csrf_protection']['enabled'] = ($this->sessionConfigEnabled || 'session' !== $config['csrf_protection']['storage']) && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class);
}
$this->registerSecurityCsrfConfiguration($config['csrf_protection'], $container, $loader);
@@ -1450,12 +1456,31 @@ private function registerSecurityCsrfConfiguration(array $config, ContainerBuild
throw new LogicException('CSRF support cannot be enabled as the Security CSRF component is not installed. Try running "composer require symfony/security-csrf".');
}
- if (!$this->sessionConfigEnabled) {
- throw new \LogicException('CSRF protection needs sessions to be enabled.');
- }
-
// Enable services for CSRF protection (even without forms)
$loader->load('security_csrf.xml');
+ switch ($config['storage']) {
+ case 'session':
+ if (!$this->sessionConfigEnabled) {
+ throw new \LogicException('CSRF protection needs sessions to be enabled.');
+ }
+
+ $container->setAlias('security.csrf.token_storage', SessionTokenStorage::class);
+ break;
+ case 'cookie':
+ if (!class_exists(CookieTokenStorage::class)) {
+ throw new LogicException('CSRF support with Cookie Storage is not installed. Try running "composer require symfony/security-csrf:^4.4".');
+ }
+
+ $container->setAlias('security.csrf.token_storage', CookieTokenStorage::class);
+ break;
+ default:
+ $container->setAlias('security.csrf.token_storage', $config['storage']);
+ break;
+ }
+
+ if ('cookie' !== $config['storage']) {
+ $container->removeDefinition(CookieTokenStorageListener::class);
+ }
if (!class_exists(CsrfExtension::class)) {
$container->removeDefinition('twig.extension.security_csrf');
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
index f1dae61035fc4..30590e895d7eb 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
@@ -57,6 +57,7 @@
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.xml
index eefe6ad73601f..e6a7b5b9e1c96 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.xml
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.xml
@@ -10,9 +10,17 @@
-
+
+
+
+ %kernel.secret%
+
+
+
+
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
index efead383b11cf..2485c1b6aba8b 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php
@@ -215,7 +215,8 @@ protected static function getBundleDefaultConfig()
'ide' => null,
'default_locale' => 'en',
'csrf_protection' => [
- 'enabled' => false,
+ 'enabled' => null,
+ 'storage' => null,
],
'form' => [
'enabled' => !class_exists(FullStack::class),
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_fallback_to_cookie.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_fallback_to_cookie.php
new file mode 100644
index 0000000000000..875a9fda4af74
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_fallback_to_cookie.php
@@ -0,0 +1,8 @@
+loadFromExtension('framework', [
+ 'session' => false,
+ 'csrf_protection' => [
+ 'enabled' => true,
+ ],
+]);
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_needs_session.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_needs_session.php
index 34fdb4c1f9931..80cf53abfc5b2 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_needs_session.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_needs_session.php
@@ -2,6 +2,6 @@
$container->loadFromExtension('framework', [
'csrf_protection' => [
- 'enabled' => true,
+ 'storage' => 'session',
],
]);
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_fallback_to_cookie.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_fallback_to_cookie.xml
new file mode 100644
index 0000000000000..4ba3f1e41d113
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_fallback_to_cookie.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_needs_session.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_needs_session.xml
index a9e168638df31..b21297a4786aa 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_needs_session.xml
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_needs_session.xml
@@ -7,6 +7,6 @@
http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
-
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_fallback_to_cookie.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_fallback_to_cookie.yml
new file mode 100644
index 0000000000000..b8065b6fb678b
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_fallback_to_cookie.yml
@@ -0,0 +1,2 @@
+framework:
+ csrf_protection: ~
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_needs_session.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_needs_session.yml
index b8065b6fb678b..1ad93d4383abe 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_needs_session.yml
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_needs_session.yml
@@ -1,2 +1,3 @@
framework:
- csrf_protection: ~
+ csrf_protection:
+ storage: session
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php
index 80c73bca4155a..3efab82d1a8de 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php
@@ -41,6 +41,7 @@
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
use Symfony\Component\Messenger\Transport\TransportFactory;
use Symfony\Component\PropertyAccess\PropertyAccessor;
+use Symfony\Component\Security\Csrf\TokenStorage\CookieTokenStorage;
use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader;
use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader;
use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader;
@@ -131,6 +132,16 @@ public function testCsrfProtectionNeedsSessionToBeEnabled()
$this->createContainerFromFile('csrf_needs_session');
}
+ public function testCsrfProtectionFallbackToCookie()
+ {
+ if (!class_exists(CookieTokenStorage::class)) {
+ $this->markTestSkipped('Cookie storage requires symfony/security 4.4+');
+ }
+ $container = $this->createContainerFromFile('csrf_fallback_to_cookie');
+
+ $this->assertSame(CookieTokenStorage::class, (string) $container->getAlias('security.csrf.token_storage'));
+ }
+
public function testCsrfProtectionForFormsEnablesCsrfProtectionAutomatically()
{
$container = $this->createContainerFromFile('csrf');
diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md
index d4db73958ef7f..72ddea9f79c93 100644
--- a/src/Symfony/Component/Security/CHANGELOG.md
+++ b/src/Symfony/Component/Security/CHANGELOG.md
@@ -12,6 +12,7 @@ CHANGELOG
for "guard" authenticators that deal with user passwords
* Marked all dispatched event classes as `@final`
* Deprecated returning a non-boolean value when implementing `Guard\AuthenticatorInterface::checkCredentials()`.
+ * Added `CookieTokenStorage`
4.3.0
-----
diff --git a/src/Symfony/Component/Security/Csrf/EventListener/CookieTokenStorageListener.php b/src/Symfony/Component/Security/Csrf/EventListener/CookieTokenStorageListener.php
new file mode 100644
index 0000000000000..c804a3f2e9eec
--- /dev/null
+++ b/src/Symfony/Component/Security/Csrf/EventListener/CookieTokenStorageListener.php
@@ -0,0 +1,48 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Csrf\EventListener;
+
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpKernel\Event\ResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Security\Csrf\TokenStorage\CookieTokenStorage;
+
+/**
+ * Inject transient cookies in the response.
+ *
+ * @author Oliver Hoff
+ * @author Jérémy Derussé
+ */
+class CookieTokenStorageListener implements EventSubscriberInterface
+{
+ private $cookieTokenStorage;
+
+ public function __construct(CookieTokenStorage $cookieTokenStorage)
+ {
+ $this->cookieTokenStorage = $cookieTokenStorage;
+ }
+
+ public function onKernelResponse(ResponseEvent $event)
+ {
+ $this->cookieTokenStorage->sendCookies($event->getResponse());
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents()
+ {
+ return [
+ KernelEvents::RESPONSE => 'onKernelResponse',
+ ];
+ }
+}
diff --git a/src/Symfony/Component/Security/Csrf/Exception/RuntimeException.php b/src/Symfony/Component/Security/Csrf/Exception/RuntimeException.php
new file mode 100644
index 0000000000000..1a7a9f7d36da7
--- /dev/null
+++ b/src/Symfony/Component/Security/Csrf/Exception/RuntimeException.php
@@ -0,0 +1,21 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Csrf\Exception;
+
+use Symfony\Component\Security\Core\Exception\RuntimeException as CoreRuntimeException;
+
+/**
+ * @author Jérémy Derussé
+ */
+class RuntimeException extends CoreRuntimeException
+{
+}
diff --git a/src/Symfony/Component/Security/Csrf/Exception/TokenNotFoundException.php b/src/Symfony/Component/Security/Csrf/Exception/TokenNotFoundException.php
index 936afdeb113e4..4c5831550dc6f 100644
--- a/src/Symfony/Component/Security/Csrf/Exception/TokenNotFoundException.php
+++ b/src/Symfony/Component/Security/Csrf/Exception/TokenNotFoundException.php
@@ -11,8 +11,6 @@
namespace Symfony\Component\Security\Csrf\Exception;
-use Symfony\Component\Security\Core\Exception\RuntimeException;
-
/**
* @author Bernhard Schussek
*/
diff --git a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/CookieTokenStorageTest.php b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/CookieTokenStorageTest.php
new file mode 100644
index 0000000000000..bdd03fe55ea30
--- /dev/null
+++ b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/CookieTokenStorageTest.php
@@ -0,0 +1,153 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Csrf\Tests\TokenStorage;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException;
+use Symfony\Component\Security\Csrf\TokenStorage\CookieTokenStorage;
+
+/**
+ * @author Jérémy Derussé
+ */
+class CookieTokenStorageTest extends TestCase
+{
+ const COOKIE_NAMESPACE = 'foobar';
+
+ /**
+ * @var RequestStack
+ */
+ private $requestStack;
+
+ /**
+ * @var CookieTokenStorage
+ */
+ private $storage;
+
+ protected function setUp(): void
+ {
+ $this->requestStack = new RequestStack();
+ $this->requestStack->push(new Request());
+
+ $this->storage = new CookieTokenStorage($this->requestStack, 's3cr3t', self::COOKIE_NAMESPACE);
+ }
+
+ public function testStoreTokenAddsCookies()
+ {
+ $this->storage->setToken('token_id', 'TOKEN');
+ $this->storage->sendCookies($response = new Response(), $this->requestStack->getMasterRequest());
+
+ $cookies = $response->headers->getCookies();
+ $this->assertCount(1, $cookies);
+ $this->assertLessThan(time() + 3601, $cookies[0]->getExpiresTime());
+ }
+
+ public function testCheckTokenInTransientStorage()
+ {
+ $this->storage->setToken('token_id', 'TOKEN');
+
+ $this->assertTrue($this->storage->hasToken('token_id'));
+ }
+
+ public function testGetExistingToken()
+ {
+ $response = $this->generateCookieResponse('token_id', 'TOKEN');
+ $cookies = $response->headers->getCookies();
+ $this->requestStack->getMasterRequest()->cookies->set($cookies[0]->getName(), $cookies[0]->getValue());
+
+ $this->assertSame('TOKEN', $this->storage->getToken('token_id'));
+ }
+
+ public function testGetNonExistingToken()
+ {
+ $this->expectException(TokenNotFoundException::class);
+ $this->storage->getToken('token_id');
+ }
+
+ public function testInvalidToken()
+ {
+ $this->expectException(TokenNotFoundException::class);
+
+ $response = $this->generateCookieResponse('token_id', 'TOKEN');
+ $cookies = $response->headers->getCookies();
+ $this->requestStack->getMasterRequest()->cookies->set($cookies[0]->getName(), $cookies[0]->getValue());
+
+ $response = $this->generateCookieResponse('token_id', 'TOKEN');
+ $cookies = $response->headers->getCookies();
+ $this->requestStack->getMasterRequest()->cookies->set($cookies[0]->getName(), $cookies[0]->getValue().'--');
+
+ $this->storage->getToken('token_id');
+ }
+
+ public function testExpiredToken()
+ {
+ $this->expectException(TokenNotFoundException::class);
+
+ $previousRequestStack = new RequestStack();
+ $previousRequestStack->push($previousRequest = new Request());
+ $previousStorage = new CookieTokenStorage($previousRequestStack, 's3cr3t', self::COOKIE_NAMESPACE, -1);
+ $previousStorage->setToken('token_id', 'TOKEN');
+ $previousStorage->sendCookies($response = new Response(), $previousRequest);
+
+ $cookies = $response->headers->getCookies();
+ $this->requestStack->getMasterRequest()->cookies->set($cookies[0]->getName(), $cookies[0]->getValue());
+
+ $this->storage->getToken('token_id');
+ }
+
+ public function testRemoveNonExistingToken()
+ {
+ $this->assertNull($this->storage->removeToken('token_id'));
+ }
+
+ public function testRemoveExistingToken()
+ {
+ $previousResponse = $this->generateCookieResponse('token_id', 'TOKEN');
+ $cookies = $previousResponse->headers->getCookies();
+ $this->requestStack->getMasterRequest()->cookies->set($cookies[0]->getName(), $cookies[0]->getValue());
+
+ $deletedToken = $this->storage->removeToken('token_id');
+ $this->storage->sendCookies($response = new Response(), $this->requestStack->getMasterRequest());
+
+ $cookies = $response->headers->getCookies();
+ $this->assertCount(1, $cookies);
+ $this->assertNull($cookies[0]->getValue());
+ $this->assertSame('TOKEN', $deletedToken);
+ }
+
+ public function testClearRemovesAllTokensFromTheConfiguredNamespace()
+ {
+ $response = $this->generateCookieResponse('token_id', 'TOKEN');
+ $cookies = $response->headers->getCookies();
+ $this->requestStack->getMasterRequest()->cookies->set($cookies[0]->getName(), $cookies[0]->getValue());
+
+ $this->storage->clear();
+ $this->storage->sendCookies($response = new Response(), $this->requestStack->getMasterRequest());
+
+ $cookies = $response->headers->getCookies();
+ $this->assertCount(1, $cookies);
+ $this->assertNull($cookies[0]->getValue());
+ }
+
+ private function generateCookieResponse(string $tokenId, string $token): Response
+ {
+ $previousRequestStack = new RequestStack();
+ $previousRequestStack->push($previousRequest = new Request());
+ $previousStorage = new CookieTokenStorage($previousRequestStack, 's3cr3t', self::COOKIE_NAMESPACE);
+ $previousStorage->setToken($tokenId, $token);
+ $previousStorage->sendCookies($response = new Response(), $previousRequest);
+
+ return $response;
+ }
+}
diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php
new file mode 100644
index 0000000000000..0fe4da0596c5f
--- /dev/null
+++ b/src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php
@@ -0,0 +1,171 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Csrf\TokenStorage;
+
+use Symfony\Component\HttpFoundation\Cookie;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Csrf\Exception\RuntimeException;
+use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException;
+
+/**
+ * Token storage that uses a Cookie object.
+ *
+ * @author Oliver Hoff
+ * @author Jérémy Derussé
+ */
+class CookieTokenStorage implements ClearableTokenStorageInterface
+{
+ const COOKIE_NAMESPACE = '_csrf_';
+ const TRANSIENT_ATTRIBUTE_NAME = '_csrf_tokens';
+
+ private $requestStack;
+ private $secret;
+ private $ttl;
+ private $namespace;
+
+ public function __construct(RequestStack $requestStack, string $secret, string $namespace = self::COOKIE_NAMESPACE, int $ttl = 3600)
+ {
+ $this->requestStack = $requestStack;
+ $this->secret = $secret;
+ $this->namespace = $namespace;
+ $this->ttl = $ttl;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getToken($tokenId)
+ {
+ if (null === $request = $this->requestStack->getMasterRequest()) {
+ throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' cannot exist outside a request.');
+ }
+
+ $transientTokens = $request->attributes->get(self::TRANSIENT_ATTRIBUTE_NAME, []);
+ if (isset($transientTokens[$tokenId])) {
+ return $transientTokens[$tokenId];
+ }
+
+ if (!$cookie = $request->cookies->get($cookieName = $this->getCookieName($tokenId))) {
+ throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' does not exist.');
+ }
+
+ $parts = explode('/', (string) $cookie, 4);
+ if (4 != \count($parts)) {
+ throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' is invalid.');
+ }
+ list($expires, $nonce, $signature, $token) = $parts;
+
+ // expired token
+ if ((int) $expires < time()) {
+ throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' is expired.');
+ }
+
+ // invalid signature
+ if (!hash_equals($this->getSignature($tokenId, $token, $nonce, $expires), $signature)) {
+ throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' has an invalid signature.');
+ }
+
+ // reschedule the token to refresh it TTL
+ $transientTokens[$tokenId] = $token;
+ $request->attributes->set(self::TRANSIENT_ATTRIBUTE_NAME, $transientTokens);
+
+ return $token;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setToken($tokenId, $token)
+ {
+ if (null === $request = $this->requestStack->getMasterRequest()) {
+ throw new RuntimeException('The Cookie CSRF token cannot exist outside a request.');
+ }
+
+ $request->attributes->set(self::TRANSIENT_ATTRIBUTE_NAME, [$tokenId => $token] + $request->attributes->get(self::TRANSIENT_ATTRIBUTE_NAME, []));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hasToken($tokenId)
+ {
+ try {
+ $this->getToken($tokenId);
+
+ return true;
+ } catch (TokenNotFoundException $e) {
+ return false;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeToken($tokenId)
+ {
+ try {
+ $token = $this->getToken($tokenId);
+ } catch (TokenNotFoundException $e) {
+ $token = null;
+ }
+ $this->setToken($tokenId, '');
+
+ return $token;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function clear()
+ {
+ if (null === $request = $this->requestStack->getMasterRequest()) {
+ return;
+ }
+
+ $request->attributes->set(self::TRANSIENT_ATTRIBUTE_NAME, []);
+ foreach ($request->cookies->keys() as $key) {
+ if (0 === strpos($key, $this->namespace.'/')) {
+ $tokenId = substr($key, strrpos($key, '/'));
+ $this->removeToken($tokenId);
+ }
+ }
+ }
+
+ public function sendCookies(Response $response): void
+ {
+ if (null === $request = $this->requestStack->getMasterRequest()) {
+ return;
+ }
+
+ $isSecure = $request->isSecure();
+ foreach ($request->attributes->get(self::TRANSIENT_ATTRIBUTE_NAME, []) as $tokenId => $token) {
+ $value = '' === $token ? null : sprintf('%d/%s/%s/%s', $expires = time() + $this->ttl, $nonce = strtr(base64_encode(random_bytes(6)), '/', '_'), $this->getSignature($tokenId, $token, $nonce, $expires), $token);
+ $response->headers->setCookie(new Cookie($cookieName = $this->getCookieName($tokenId), $value, $expires ?? 1, null, null, $isSecure, true, false, Cookie::SAMESITE_LAX));
+ }
+ }
+
+ private function getCookieName(string $tokenId): string
+ {
+ if (null === $request = $this->requestStack->getMasterRequest()) {
+ throw new RuntimeException('The Cookie CSRF token cannot exist outside a request.');
+ }
+
+ // The cookie name contains the host to allows subdomain using the same tokenId
+ return sprintf('%s/%s', $this->namespace, substr(hash_hmac('sha256', $tokenId.$request->getHost(), $this->secret), 0, 9));
+ }
+
+ private function getSignature(string $tokenId, string $token, string $nonce, int $expires): string
+ {
+ return hash_hmac('sha256', $tokenId.$token.$nonce.$expires, $this->secret);
+ }
+}