diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index b533bed..fe8664d 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -15,7 +15,7 @@ jobs: - name: "installing PHP" uses: "shivammathur/setup-php@v2" with: - php-version: "7.4" + php-version: "8.0" ini-values: memory_limit=-1 tools: composer:v2, phpstan, cs2pr diff --git a/.gitignore b/.gitignore index bd8b4f4..ac3bd04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -/.php_cs.cache -/.php_cs +/.php-cs-fixer.cache +/.php-cs-fixer.php /.phpunit.result.cache /composer.phar /composer.lock diff --git a/.php_cs.dist b/.php-cs-fixer.dist.php similarity index 100% rename from .php_cs.dist rename to .php-cs-fixer.dist.php diff --git a/composer.json b/composer.json index a63ea60..8b7f290 100644 --- a/composer.json +++ b/composer.json @@ -46,8 +46,11 @@ }, "require-dev": { "lcobucci/jwt": "^3.4|^4.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0", "symfony/phpunit-bridge": "^5.2|^6.0", - "symfony/stopwatch": "^4.4|^5.0|^6.0" + "symfony/stopwatch": "^4.4|^5.0|^6.0", + "twig/twig": "^2.0|^3.0" }, "minimum-stability": "dev" } diff --git a/src/Authorization.php b/src/Authorization.php index 6e1956d..5519e0d 100644 --- a/src/Authorization.php +++ b/src/Authorization.php @@ -15,10 +15,12 @@ use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mercure\Exception\InvalidArgumentException; use Symfony\Component\Mercure\Exception\RuntimeException; +/** + * Manages the "mercureAuthorization" cookies. + */ final class Authorization { private const MERCURE_AUTHORIZATION_COOKIE_NAME = 'mercureAuthorization'; @@ -36,7 +38,30 @@ public function __construct(HubRegistry $registry, ?int $cookieLifetime = null) } /** - * Create Authorization cookie for the given hub. + * Sets mercureAuthorization cookie for the given hub. + * + * @param string[] $subscribe a list of topics that the authorization cookie will allow subscribing to + * @param string[] $publish a list of topics that the authorization cookie will allow publishing to + * @param mixed[] $additionalClaims an array of additional claims for the JWT + * @param string|null $hub the hub to generate the cookie for + */ + public function setCookie(Request $request, array $subscribe = [], array $publish = [], array $additionalClaims = [], ?string $hub = null): void + { + $this->updateCookies($request, $hub, $this->createCookie($request, $subscribe, $publish, $additionalClaims, $hub)); + } + + /** + * Clears the mercureAuthorization cookie for the given hub. + * + * @param string|null $hub the hub to clear the cookie for + */ + public function clearCookie(Request $request, ?string $hub = null): void + { + $this->updateCookies($request, $hub, $this->createClearCookie($request, $hub)); + } + + /** + * Creates mercureAuthorization cookie for the given hub. * * @param string[] $subscribe a list of topics that the authorization cookie will allow subscribing to * @param string[] $publish a list of topics that the authorization cookie will allow publishing to @@ -48,7 +73,7 @@ public function createCookie(Request $request, array $subscribe = [], array $pub $hubInstance = $this->registry->getHub($hub); $tokenFactory = $hubInstance->getFactory(); if (null === $tokenFactory) { - throw new InvalidArgumentException(sprintf('The "%s" hub does not contain a token factory.', $hub ? '"'.$hub.'"' : 'default')); + throw new InvalidArgumentException(sprintf('The %s hub does not contain a token factory.', $hub ? "\"$hub\"" : 'default')); } $cookieLifetime = $this->cookieLifetime; @@ -82,18 +107,26 @@ public function createCookie(Request $request, array $subscribe = [], array $pub ); } - public function clearCookie(Request $request, Response $response, ?string $hub = null): void + /** + * Clears the mercureAuthorization cookie for the given hub. + * + * @param string|null $hub the hub to clear the cookie for + */ + public function createClearCookie(Request $request, ?string $hub = null): Cookie { $hubInstance = $this->registry->getHub($hub); /** @var array $urlComponents */ $urlComponents = parse_url($hubInstance->getPublicUrl()); - $response->headers->clearCookie( + return Cookie::create( self::MERCURE_AUTHORIZATION_COOKIE_NAME, + null, + 1, $urlComponents['path'] ?? '/', $this->getCookieDomain($request, $urlComponents), 'http' !== strtolower($urlComponents['scheme'] ?? 'https'), true, + false, Cookie::SAMESITE_STRICT ); } @@ -119,4 +152,15 @@ private function getCookieDomain(Request $request, array $urlComponents): ?strin return $cookieDomain; } + + private function updateCookies(Request $request, ?string $hub, Cookie $cookie): void + { + $cookies = $request->attributes->get('_mercure_authorization_cookies', []); + if (\array_key_exists($hub, $cookies)) { + throw new RuntimeException(sprintf('The "mercureAuthorization" cookie for the %s has already been set. You cannot set it two times during the same request.', $hub ? "\"$hub\" hub" : 'default hub')); + } + + $cookies[$hub] = $cookie; + $request->attributes->set('_mercure_authorization_cookies', $cookies); + } } diff --git a/src/EventSubscriber/SetCookieSubscriber.php b/src/EventSubscriber/SetCookieSubscriber.php new file mode 100644 index 0000000..c763b6e --- /dev/null +++ b/src/EventSubscriber/SetCookieSubscriber.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Mercure\EventSubscriber; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Sets the cookies created by the Authorization helper class. + * + * @author Kévin Dunglas + */ +final class SetCookieSubscriber implements EventSubscriberInterface +{ + public function onKernelResponse(ResponseEvent $event): void + { + $mainRequest = method_exists($event, 'isMainRequest') ? $event->isMainRequest() : $event->isMasterRequest(); + if ( + !($mainRequest) || + null === $cookies = ($request = $event->getRequest())->attributes->get('_mercure_authorization_cookies')) { + return; + } + + $request->attributes->remove('_mercure_authorization_cookies'); + + $response = $event->getResponse(); + foreach ($cookies as $cookie) { + $response->headers->setCookie($cookie); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::RESPONSE => 'onKernelResponse']; + } +} diff --git a/src/Publisher.php b/src/Publisher.php index e4c5e1d..3c6a383 100644 --- a/src/Publisher.php +++ b/src/Publisher.php @@ -82,7 +82,7 @@ public function __invoke(Update $update): string private function validateJwt(string $jwt): void { if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/', $jwt)) { - throw new Exception\InvalidArgumentException('The provided JWT is not valid'); + throw new Exception\InvalidArgumentException('The provided JWT is not valid.'); } } } diff --git a/src/Twig/MercureExtension.php b/src/Twig/MercureExtension.php new file mode 100644 index 0000000..aabf31a --- /dev/null +++ b/src/Twig/MercureExtension.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Mercure\Twig; + +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Mercure\Authorization; +use Symfony\Component\Mercure\HubRegistry; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * Registers the Twig helper function. + * + * @author Kévin Dunglas + */ +final class MercureExtension extends AbstractExtension +{ + private $hubRegistry; + private $authorization; + private $requestStack; + + public function __construct(HubRegistry $hubRegistry, ?Authorization $authorization = null, ?RequestStack $requestStack = null) + { + $this->hubRegistry = $hubRegistry; + $this->authorization = $authorization; + $this->requestStack = $requestStack; + } + + public function getFunctions(): array + { + return [new TwigFunction('mercure', [$this, 'mercure'])]; + } + + /** + * @param string|string[]|null $topics A topic or an array of topics to subscribe for. If this parameter is omitted or `null` is passed, the URL of the hub will be returned (useful for publishing in JavaScript). + * + * @return string The URL of the hub with the appropriate "topic" query parameters (if any) + */ + public function mercure($topics = null, array $options = []): string + { + $hub = $options['hub'] ?? null; + $url = $this->hubRegistry->getHub($hub)->getPublicUrl(); + if (null !== $topics) { + // We cannot use http_build_query() because this method doesn't support generating multiple query parameters with the same name without the [] suffix + $separator = '?'; + foreach ((array) $topics as $topic) { + $url .= $separator.'topic='.rawurlencode($topic); + if ('?' === $separator) { + $separator = '&'; + } + } + } + + if ( + null === $this->authorization || + null === $this->requestStack || + (!isset($options['subscribe']) && !isset($options['publish']) && !isset($options['additionalClaims'])) || + /* @phpstan-ignore-next-line */ + null === $request = method_exists($this->requestStack, 'getMainRequest') ? $this->requestStack->getMainRequest() : $this->requestStack->getMasterRequest() + ) { + return $url; + } + + $this->authorization->setCookie($request, $options['subscribe'] ?? [], $options['publish'] ?? [], $options['additionalClaims'] ?? [], $hub); + + return $url; + } +} diff --git a/src/Update.php b/src/Update.php index cd1916d..1b1e0ba 100644 --- a/src/Update.php +++ b/src/Update.php @@ -33,12 +33,12 @@ final class Update private $retry; /** - * @param array|string $topics + * @param string|string[] $topics */ public function __construct($topics, string $data = '', bool $private = false, string $id = null, string $type = null, int $retry = null) { if (!\is_array($topics) && !\is_string($topics)) { - throw new \InvalidArgumentException('$topics must be an array of strings or a string'); + throw new \InvalidArgumentException('$topics must be an array of strings or a string.'); } $this->topics = (array) $topics; diff --git a/tests/AuthorizationTest.php b/tests/AuthorizationTest.php index 4d05ee1..5a6401b 100644 --- a/tests/AuthorizationTest.php +++ b/tests/AuthorizationTest.php @@ -16,7 +16,6 @@ use Lcobucci\JWT\Signer\Key\InMemory; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mercure\Authorization; use Symfony\Component\Mercure\Exception\RuntimeException; use Symfony\Component\Mercure\HubRegistry; @@ -52,6 +51,31 @@ function (Update $u): string { return 'dummy'; }, $this->assertIsNumeric($payload['exp']); } + public function testSetCookie(): void + { + $tokenFactory = $this->createMock(TokenFactoryInterface::class); + $tokenFactory + ->expects($this->once()) + ->method('create') + ->with($this->equalTo(['foo']), $this->equalTo(['bar']), $this->arrayHasKey('x-foo')) + ; + + $registry = new HubRegistry(new MockHub( + 'https://example.com/.well-known/mercure', + new StaticTokenProvider('foo.bar.baz'), + function (Update $u): string { return 'dummy'; }, + $tokenFactory + )); + + $request = Request::create('https://example.com'); + $authorization = new Authorization($registry, 0); + $authorization->setCookie($request, ['foo'], ['bar'], ['x-foo' => 'bar']); + + $cookie = $request->attributes->get('_mercure_authorization_cookies')[null]; + $this->assertNotNull($cookie->getValue()); + $this->assertSame(0, $cookie->getExpiresTime()); + } + public function testClearCookie(): void { $registry = new HubRegistry(new MockHub( @@ -67,15 +91,12 @@ public function create(array $subscribe = [], array $publish = [], array $additi )); $authorization = new Authorization($registry); - $cookie = $authorization->createCookie($request = Request::create('https://example.com')); - - $response = new Response(); - $response->headers->setCookie($cookie); + $request = Request::create('https://example.com'); + $authorization->clearCookie($request); - $authorization->clearCookie($request, $response); - - $this->assertNull($response->headers->getCookies()[0]->getValue()); - $this->assertSame(1, $response->headers->getCookies()[0]->getExpiresTime()); + $cookie = $request->attributes->get('_mercure_authorization_cookies')[null]; + $this->assertNull($cookie->getValue()); + $this->assertSame(1, $cookie->getExpiresTime()); } /** @@ -137,4 +158,26 @@ public function provideNonApplicableCookieDomains(): iterable yield ['https://demo.mercure.com', 'https://example.com']; yield ['https://mercure.internal.com', 'https://external.com']; } + + public function testSetMultipleCookies(): void + { + $this->expectException(RuntimeException::class); + + $registry = new HubRegistry(new MockHub( + 'https://example.com/.well-known/mercure', + new StaticTokenProvider('foo.bar.baz'), + function (Update $u): string { return 'dummy'; }, + new class() implements TokenFactoryInterface { + public function create(array $subscribe = [], array $publish = [], array $additionalClaims = []): string + { + return ''; + } + } + )); + + $authorization = new Authorization($registry); + $request = Request::create('https://example.com'); + $authorization->setCookie($request); + $authorization->clearCookie($request); + } } diff --git a/tests/EventSubscriber/SetCookieSubscriberTest.php b/tests/EventSubscriber/SetCookieSubscriberTest.php new file mode 100644 index 0000000..a8e1ace --- /dev/null +++ b/tests/EventSubscriber/SetCookieSubscriberTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Mercure\Tests\EventSubscriber; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Mercure\EventSubscriber\SetCookieSubscriber; + +/** + * @author Kévin Dunglas + */ +class SetCookieSubscriberTest extends TestCase +{ + public function testOnKernelResponse(): void + { + $subscriber = new SetCookieSubscriber(); + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/'); + $cookies = ['' => Cookie::create('mercureAuthorization')]; + $request->attributes->set('_mercure_authorization_cookies', $cookies); + $response = new Response(); + $event = new ResponseEvent($kernel, $request, 1 /*HttpKernelInterface::MAIN_REQUEST*/, $response); + + $subscriber->onKernelResponse($event); + + $this->assertFalse($request->attributes->has('_mercure_authorization_cookies')); + $this->assertSame(array_values($cookies), $response->headers->getCookies()); + } + + public function testWiring(): void + { + $this->assertInstanceOf(EventSubscriberInterface::class, new SetCookieSubscriber()); + $this->assertArrayHasKey(KernelEvents::RESPONSE, SetCookieSubscriber::getSubscribedEvents()); + } +} diff --git a/tests/Twig/MercureExtensionTest.php b/tests/Twig/MercureExtensionTest.php new file mode 100644 index 0000000..f824a5e --- /dev/null +++ b/tests/Twig/MercureExtensionTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Mercure\Tests\Twig; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Mercure\Authorization; +use Symfony\Component\Mercure\HubRegistry; +use Symfony\Component\Mercure\Jwt\StaticTokenProvider; +use Symfony\Component\Mercure\Jwt\TokenFactoryInterface; +use Symfony\Component\Mercure\MockHub; +use Symfony\Component\Mercure\Twig\MercureExtension; +use Symfony\Component\Mercure\Update; + +/** + * @author Kévin Dunglas + */ +class MercureExtensionTest extends TestCase +{ + public function testMercure(): void + { + $registry = new HubRegistry(new MockHub( + 'https://example.com/.well-known/mercure', + new StaticTokenProvider('foo.bar.baz'), + function (Update $u): string { return 'dummy'; }, + $this->createMock(TokenFactoryInterface::class) + )); + + $requestStack = new RequestStack(); + $request = Request::create('https://example.com/'); + $requestStack->push($request); + + $extension = new MercureExtension($registry, new Authorization($registry), $requestStack); + + $url = $extension->mercure(['https://foo/bar'], ['subscribe' => ['https://foo/{id}']]); + + $this->assertSame('https://example.com/.well-known/mercure?topic=https%3A%2F%2Ffoo%2Fbar', $url); + $this->assertInstanceOf(Cookie::class, $request->attributes->get('_mercure_authorization_cookies')['']); + } +}