From 5cd1d7b4cc03796c0f709776aa7e8e4c25f786ed Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 2 Jul 2018 13:54:43 +0200 Subject: [PATCH] [Security] add "anonymous: lazy" mode to firewalls --- .../Bundle/SecurityBundle/CHANGELOG.md | 1 + .../DataCollector/SecurityDataCollector.php | 3 +- .../Security/Factory/AnonymousFactory.php | 5 ++ .../DependencyInjection/SecurityExtension.php | 7 +- .../Resources/config/security.xml | 10 +++ .../Security/LazyFirewallContext.php | 73 ++++++++++++++++++ .../Controller/LocalizedController.php | 2 +- .../SecurityRoutingIntegrationTest.php | 10 +++ .../app/StandardFormLogin/config.yml | 3 +- .../Bundle/SecurityBundle/composer.json | 2 +- .../Token/Storage/TokenStorage.php | 12 +++ .../Core/Exception/LazyResponseException.php | 34 +++++++++ .../Security/Http/Event/LazyResponseEvent.php | 76 +++++++++++++++++++ .../Http/Firewall/ExceptionListener.php | 7 ++ 14 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/Security/LazyFirewallContext.php create mode 100644 src/Symfony/Component/Security/Core/Exception/LazyResponseException.php create mode 100644 src/Symfony/Component/Security/Http/Event/LazyResponseEvent.php diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 272cff93b0..5d00820859 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG 4.3.0 ----- + * Added `anonymous: lazy` mode to firewalls to make them (not) start the session as late as possible * Added new encoder types: `auto` (recommended), `native` and `sodium` * The normalization of the cookie names configured in the `logout.delete_cookies` option is deprecated and will be disabled in Symfony 5.0. This affects to cookies diff --git a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php index 29fa33b3f3..86805abf61 100644 --- a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php +++ b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; @@ -127,7 +128,7 @@ class SecurityDataCollector extends DataCollector implements LateDataCollectorIn $logoutUrl = null; try { - if (null !== $this->logoutUrlGenerator) { + if (null !== $this->logoutUrlGenerator && !$token instanceof AnonymousToken) { $logoutUrl = $this->logoutUrlGenerator->getLogoutPath(); } } catch (\Exception $e) { diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php index dc8e4a9ba3..a889edea00 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AnonymousFactory.php @@ -55,7 +55,12 @@ class AnonymousFactory implements SecurityFactoryInterface public function addConfiguration(NodeDefinition $builder) { $builder + ->beforeNormalization() + ->ifTrue(function ($v) { return 'lazy' === $v; }) + ->then(function ($v) { return ['lazy' => true]; }) + ->end() ->children() + ->booleanNode('lazy')->defaultFalse()->end() ->scalarNode('secret')->defaultNull()->end() ->end() ; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index b828dfd67a..4eca9ecef4 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -243,7 +243,8 @@ class SecurityExtension extends Extension implements PrependExtensionInterface list($matcher, $listeners, $exceptionListener, $logoutListener) = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $configId); $contextId = 'security.firewall.map.context.'.$name; - $context = $container->setDefinition($contextId, new ChildDefinition('security.firewall.context')); + $context = new ChildDefinition($firewall['stateless'] || empty($firewall['anonymous']['lazy']) ? 'security.firewall.context' : 'security.firewall.lazy_context'); + $context = $container->setDefinition($contextId, $context); $context ->replaceArgument(0, new IteratorArgument($listeners)) ->replaceArgument(1, $exceptionListener) @@ -409,7 +410,9 @@ class SecurityExtension extends Extension implements PrependExtensionInterface } // Access listener - $listeners[] = new Reference('security.access_listener'); + if ($firewall['stateless'] || empty($firewall['anonymous']['lazy'])) { + $listeners[] = new Reference('security.access_listener'); + } // Exception listener $exceptionListener = new Reference($this->createExceptionListener($container, $firewall, $id, $configuredEntryPoint ?: $defaultEntryPoint, $firewall['stateless'])); diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml index 86bf11869b..bcb35370a5 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml @@ -151,6 +151,16 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Security/LazyFirewallContext.php b/src/Symfony/Bundle/SecurityBundle/Security/LazyFirewallContext.php new file mode 100644 index 0000000000..ef9b1e217c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Security/LazyFirewallContext.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Security; + +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\Exception\LazyResponseException; +use Symfony\Component\Security\Http\AccessMapInterface; +use Symfony\Component\Security\Http\Event\LazyResponseEvent; +use Symfony\Component\Security\Http\Firewall\AccessListener; +use Symfony\Component\Security\Http\Firewall\ExceptionListener; +use Symfony\Component\Security\Http\Firewall\LogoutListener; + +/** + * Lazily calls authentication listeners when actually required by the access listener. + * + * @author Nicolas Grekas + */ +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 269827e2df..cf0e1150af 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 @@ class LocalizedController implements ContainerAwareInterface 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 06260c1bed..0303f1b4ee 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityRoutingIntegrationTest.php @@ -129,6 +129,16 @@ class SecurityRoutingIntegrationTest extends AbstractWebTestCase $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 4e2ac1e11b..ad8beee94c 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 4bd0816938..77bd4a0cfb 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 8a02802d9c..bf491797aa 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 @@ use Symfony\Contracts\Service\ResetInterface; 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 @@ class TokenStorage implements TokenStorageInterface, ResetInterface @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 0000000000..8edc248a04 --- /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 0000000000..aa473bc0aa --- /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 c97a051024..549543e3ef 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\AccessDeniedException; 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 @@ class ExceptionListener return; } + if ($exception instanceof LazyResponseException) { + $event->setResponse($exception->getResponse()); + + return; + } + if ($exception instanceof LogoutException) { $this->handleLogoutException($exception);