+ */ +class LazyFirewallContext extends FirewallContext +{ + private $accessListener; + private $tokenStorage; + private $map; + + public function __construct(iterable $listeners, ?ExceptionListener $exceptionListener, ?LogoutListener $logoutListener, ?FirewallConfig $config, AccessListener $accessListener, TokenStorage $tokenStorage, AccessMapInterface $map) + { + parent::__construct($listeners, $exceptionListener, $logoutListener, $config); + + $this->accessListener = $accessListener; + $this->tokenStorage = $tokenStorage; + $this->map = $map; + } + + public function getListeners(): iterable + { + return [$this]; + } + + public function __invoke(RequestEvent $event) + { + $this->tokenStorage->setInitializer(function () use ($event) { + $event = new LazyResponseEvent($event); + foreach (parent::getListeners() as $listener) { + if (\is_callable($listener)) { + $listener($event); + } else { + @trigger_error(sprintf('Calling the "%s::handle()" method from the firewall is deprecated since Symfony 4.3, implement "__invoke()" instead.', \get_class($listener)), E_USER_DEPRECATED); + $listener->handle($event); + } + } + }); + + try { + [$attributes] = $this->map->getPatterns($event->getRequest()); + + if ($attributes && [AuthenticatedVoter::IS_AUTHENTICATED_ANONYMOUSLY] !== $attributes) { + ($this->accessListener)($event); + } + } catch (LazyResponseException $e) { + $event->setResponse($e->getResponse()); + } + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Controller/LocalizedController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Controller/LocalizedController.php index 269827e2df5f2..cf0e1150aff9a 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Controller/LocalizedController.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/FormLoginBundle/Controller/LocalizedController.php @@ -59,6 +59,6 @@ public function profileAction() public function homepageAction() { - return new Response('
Homepage'); + return (new Response('Homepage'))->setPublic(); } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php index 06260c1bed042..0303f1b4eeff9 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php @@ -129,6 +129,16 @@ public function testInvalidIpsInAccessControl() $client->request('GET', '/unprotected_resource'); } + public function testPublicHomepage() + { + $client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml']); + $client->request('GET', '/en/'); + + $this->assertEquals(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()); + $this->assertTrue($client->getResponse()->headers->getCacheControlDirective('public')); + $this->assertSame(0, self::$container->get('session')->getUsageIndex()); + } + private function assertAllowed($client, $path) { $client->request('GET', $path); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml index 4e2ac1e11b9d6..ad8beee94c2e0 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/config.yml @@ -27,7 +27,7 @@ security: check_path: /login_check default_target_path: /profile logout: ~ - anonymous: ~ + anonymous: lazy # This firewall is here just to check its the logout functionality second_area: @@ -38,6 +38,7 @@ security: path: /second/logout access_control: + - { path: ^/en/$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/unprotected_resource$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/secure-but-not-covered-by-access-control$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/secured-by-one-ip$, ip: 10.10.10.10, roles: IS_AUTHENTICATED_ANONYMOUSLY } diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 4bd0816938662..77bd4a0cfb76f 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -24,7 +24,7 @@ "symfony/security-core": "^4.4", "symfony/security-csrf": "^4.2|^5.0", "symfony/security-guard": "^4.2|^5.0", - "symfony/security-http": "^4.3" + "symfony/security-http": "^4.4" }, "require-dev": { "symfony/asset": "^3.4|^4.0|^5.0", diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/Storage/TokenStorage.php b/src/Symfony/Component/Security/Core/Authentication/Token/Storage/TokenStorage.php index 8a02802d9c98f..bf491797aa25d 100644 --- a/src/Symfony/Component/Security/Core/Authentication/Token/Storage/TokenStorage.php +++ b/src/Symfony/Component/Security/Core/Authentication/Token/Storage/TokenStorage.php @@ -25,12 +25,18 @@ class TokenStorage implements TokenStorageInterface, ResetInterface { private $token; + private $initializer; /** * {@inheritdoc} */ public function getToken() { + if ($initializer = $this->initializer) { + $this->initializer = null; + $initializer(); + } + return $this->token; } @@ -43,9 +49,15 @@ public function setToken(TokenInterface $token = null) @trigger_error(sprintf('Not implementing the "%s::getRoleNames()" method in "%s" is deprecated since Symfony 4.3.', TokenInterface::class, \get_class($token)), E_USER_DEPRECATED); } + $this->initializer = null; $this->token = $token; } + public function setInitializer(?callable $initializer): void + { + $this->initializer = $initializer; + } + public function reset() { $this->setToken(null); diff --git a/src/Symfony/Component/Security/Core/Exception/LazyResponseException.php b/src/Symfony/Component/Security/Core/Exception/LazyResponseException.php new file mode 100644 index 0000000000000..8edc248a0415d --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/LazyResponseException.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Exception; + +use Symfony\Component\HttpFoundation\Response; + +/** + * A signaling exception that wraps a lazily computed response. + * + * @author Nicolas Grekas+ */ +class LazyResponseException extends \Exception implements ExceptionInterface +{ + private $response; + + public function __construct(Response $response) + { + $this->response = $response; + } + + public function getResponse(): Response + { + return $this->response; + } +} diff --git a/src/Symfony/Component/Security/Http/Event/LazyResponseEvent.php b/src/Symfony/Component/Security/Http/Event/LazyResponseEvent.php new file mode 100644 index 0000000000000..aa473bc0aa2da --- /dev/null +++ b/src/Symfony/Component/Security/Http/Event/LazyResponseEvent.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Event; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Exception\LazyResponseException; + +/** + * Wraps a lazily computed response in a signaling exception. + * + * @author Nicolas Grekas
+ */ +final class LazyResponseEvent extends RequestEvent +{ + private $event; + + public function __construct(parent $event) + { + $this->event = $event; + } + + /** + * {@inheritdoc} + */ + public function setResponse(Response $response) + { + $this->stopPropagation(); + $this->event->stopPropagation(); + + throw new LazyResponseException($response); + } + + /** + * {@inheritdoc} + */ + public function getKernel(): HttpKernelInterface + { + return $this->event->getKernel(); + } + + /** + * {@inheritdoc} + */ + public function getRequest(): Request + { + return $this->event->getRequest(); + } + + /** + * {@inheritdoc} + */ + public function getRequestType(): int + { + return $this->event->getRequestType(); + } + + /** + * {@inheritdoc} + */ + public function isMasterRequest(): bool + { + return $this->event->isMasterRequest(); + } +} diff --git a/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php b/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php index c97a051024f6e..549543e3efb17 100644 --- a/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php @@ -26,6 +26,7 @@ use Symfony\Component\Security\Core\Exception\AccountStatusException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException; +use Symfony\Component\Security\Core\Exception\LazyResponseException; use Symfony\Component\Security\Core\Exception\LogoutException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface; @@ -103,6 +104,12 @@ public function onKernelException(GetResponseForExceptionEvent $event) return; } + if ($exception instanceof LazyResponseException) { + $event->setResponse($exception->getResponse()); + + return; + } + if ($exception instanceof LogoutException) { $this->handleLogoutException($exception);