From 0a125fab9831be930f744071fe6744422dfb2e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Wed, 14 Aug 2019 16:53:18 +0200 Subject: [PATCH] Added CookieTokenStorage --- .../DependencyInjection/Configuration.php | 10 + .../FrameworkExtension.php | 35 +++- .../Resources/config/schema/symfony-1.0.xsd | 1 + .../Resources/config/security_csrf.xml | 10 +- .../DependencyInjection/ConfigurationTest.php | 3 +- .../Fixtures/php/csrf_fallback_to_cookie.php | 8 + .../Fixtures/php/csrf_needs_session.php | 2 +- .../Fixtures/xml/csrf_fallback_to_cookie.xml | 12 ++ .../Fixtures/xml/csrf_needs_session.xml | 2 +- .../Fixtures/yml/csrf_fallback_to_cookie.yml | 2 + .../Fixtures/yml/csrf_needs_session.yml | 3 +- .../FrameworkExtensionTest.php | 11 ++ src/Symfony/Component/Security/CHANGELOG.md | 1 + .../CookieTokenStorageListener.php | 48 +++++ .../Csrf/Exception/RuntimeException.php | 21 +++ .../Csrf/Exception/TokenNotFoundException.php | 2 - .../TokenStorage/CookieTokenStorageTest.php | 153 ++++++++++++++++ .../Csrf/TokenStorage/CookieTokenStorage.php | 171 ++++++++++++++++++ 18 files changed, 483 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/csrf_fallback_to_cookie.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/csrf_fallback_to_cookie.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/csrf_fallback_to_cookie.yml create mode 100644 src/Symfony/Component/Security/Csrf/EventListener/CookieTokenStorageListener.php create mode 100644 src/Symfony/Component/Security/Csrf/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/Security/Csrf/Tests/TokenStorage/CookieTokenStorageTest.php create mode 100644 src/Symfony/Component/Security/Csrf/TokenStorage/CookieTokenStorage.php 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); + } +}