Skip to content
Permalink
Browse files

feature #33676 [Security] add "anonymous: lazy" mode to firewalls (ni…

…colas-grekas)

This PR was merged into the 4.4 branch.

Discussion
----------

[Security] add "anonymous: lazy" mode to firewalls

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fixes #26769 et al.
| License       | MIT
| Doc PR        | -

Contains #33663 until it is merged.

This PR allows defining a firewall as such:
```yaml
security:
    firewalls:
        main:
            anonymous: lazy
```

This means that the corresponding area should not start the session / load the user unless the application actively gets access to it. On pages that don't fetch the user at all, this means the session is not started, which means the corresponding token neither is. Lazily, when the user is accessed, e.g. via a call to `is_granted()`, the user is loaded, starting the session if needed.

See #27817 for previous explanations on the topic also.

Note that thanks to the logic in #33633, this PR doesn't have the drawback spotted in #27817: here, the profiler works as expected.

Recipe update pending at symfony/recipes#649

Commits
-------

5cd1d7b [Security] add "anonymous: lazy" mode to firewalls
  • Loading branch information...
chalasr committed Sep 27, 2019
2 parents f5d3d5f + 5cd1d7b commit 6fef3fb83ca3a69e08cd0c60785328e354e0641c
@@ -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
@@ -17,6 +17,7 @@
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 @@ public function collect(Request $request, Response $response, \Exception $except
$logoutUrl = null;
try {
if (null !== $this->logoutUrlGenerator) {
if (null !== $this->logoutUrlGenerator && !$token instanceof AnonymousToken) {
$logoutUrl = $this->logoutUrlGenerator->getLogoutPath();
}
} catch (\Exception $e) {
@@ -55,7 +55,12 @@ public function getKey()
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()
;
@@ -243,7 +243,8 @@ private function createFirewalls(array $config, ContainerBuilder $container)
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 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
}
// 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']));
@@ -151,6 +151,16 @@
<argument /> <!-- FirewallConfig -->
</service>

<service id="security.firewall.lazy_context" class="Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext" abstract="true">
<argument type="collection" />
<argument type="service" id="security.exception_listener" />
<argument /> <!-- LogoutListener -->
<argument /> <!-- FirewallConfig -->
<argument type="service" id="security.access_listener" />
<argument type="service" id="security.untracked_token_storage" />
<argument type="service" id="security.access_map" />
</service>

<service id="security.firewall.config" class="Symfony\Bundle\SecurityBundle\Security\FirewallConfig" abstract="true">
<argument /> <!-- name -->
<argument /> <!-- user_checker -->
@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*/
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());
}
}
}
@@ -59,6 +59,6 @@ public function profileAction()
public function homepageAction()
{
return new Response('<html><body>Homepage</body></html>');
return (new Response('<html><body>Homepage</body></html>'))->setPublic();
}
}
@@ -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);
@@ -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 }
@@ -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",
@@ -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);
@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*/
class LazyResponseException extends \Exception implements ExceptionInterface
{
private $response;
public function __construct(Response $response)
{
$this->response = $response;
}
public function getResponse(): Response
{
return $this->response;
}
}
@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*/
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();
}
}
@@ -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);

0 comments on commit 6fef3fb

Please sign in to comment.
You can’t perform that action at this time.