feature #33676 [Security] add "anonymous: lazy" mode to firewalls (nicolas-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 https://github.com/symfony/recipes/pull/649
Commits
-------
5cd1d7b4cc
[Security] add "anonymous: lazy" mode to firewalls
This commit is contained in:
commit
6fef3fb83c
@ -8,6 +8,7 @@ CHANGELOG
|
|||||||
4.3.0
|
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`
|
* Added new encoder types: `auto` (recommended), `native` and `sodium`
|
||||||
* The normalization of the cookie names configured in the `logout.delete_cookies`
|
* 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
|
option is deprecated and will be disabled in Symfony 5.0. This affects to cookies
|
||||||
|
@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
|
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
|
||||||
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
|
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\Storage\TokenStorageInterface;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
|
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
|
||||||
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
|
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
|
||||||
@ -127,7 +128,7 @@ class SecurityDataCollector extends DataCollector implements LateDataCollectorIn
|
|||||||
|
|
||||||
$logoutUrl = null;
|
$logoutUrl = null;
|
||||||
try {
|
try {
|
||||||
if (null !== $this->logoutUrlGenerator) {
|
if (null !== $this->logoutUrlGenerator && !$token instanceof AnonymousToken) {
|
||||||
$logoutUrl = $this->logoutUrlGenerator->getLogoutPath();
|
$logoutUrl = $this->logoutUrlGenerator->getLogoutPath();
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
@ -55,7 +55,12 @@ class AnonymousFactory implements SecurityFactoryInterface
|
|||||||
public function addConfiguration(NodeDefinition $builder)
|
public function addConfiguration(NodeDefinition $builder)
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
|
->beforeNormalization()
|
||||||
|
->ifTrue(function ($v) { return 'lazy' === $v; })
|
||||||
|
->then(function ($v) { return ['lazy' => true]; })
|
||||||
|
->end()
|
||||||
->children()
|
->children()
|
||||||
|
->booleanNode('lazy')->defaultFalse()->end()
|
||||||
->scalarNode('secret')->defaultNull()->end()
|
->scalarNode('secret')->defaultNull()->end()
|
||||||
->end()
|
->end()
|
||||||
;
|
;
|
||||||
|
@ -243,7 +243,8 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
|
|||||||
list($matcher, $listeners, $exceptionListener, $logoutListener) = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $configId);
|
list($matcher, $listeners, $exceptionListener, $logoutListener) = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $configId);
|
||||||
|
|
||||||
$contextId = 'security.firewall.map.context.'.$name;
|
$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
|
$context
|
||||||
->replaceArgument(0, new IteratorArgument($listeners))
|
->replaceArgument(0, new IteratorArgument($listeners))
|
||||||
->replaceArgument(1, $exceptionListener)
|
->replaceArgument(1, $exceptionListener)
|
||||||
@ -409,7 +410,9 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Access listener
|
// Access listener
|
||||||
|
if ($firewall['stateless'] || empty($firewall['anonymous']['lazy'])) {
|
||||||
$listeners[] = new Reference('security.access_listener');
|
$listeners[] = new Reference('security.access_listener');
|
||||||
|
}
|
||||||
|
|
||||||
// Exception listener
|
// Exception listener
|
||||||
$exceptionListener = new Reference($this->createExceptionListener($container, $firewall, $id, $configuredEntryPoint ?: $defaultEntryPoint, $firewall['stateless']));
|
$exceptionListener = new Reference($this->createExceptionListener($container, $firewall, $id, $configuredEntryPoint ?: $defaultEntryPoint, $firewall['stateless']));
|
||||||
|
@ -151,6 +151,16 @@
|
|||||||
<argument /> <!-- FirewallConfig -->
|
<argument /> <!-- FirewallConfig -->
|
||||||
</service>
|
</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">
|
<service id="security.firewall.config" class="Symfony\Bundle\SecurityBundle\Security\FirewallConfig" abstract="true">
|
||||||
<argument /> <!-- name -->
|
<argument /> <!-- name -->
|
||||||
<argument /> <!-- user_checker -->
|
<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 @@ class LocalizedController implements ContainerAwareInterface
|
|||||||
|
|
||||||
public function homepageAction()
|
public function homepageAction()
|
||||||
{
|
{
|
||||||
return new Response('<html><body>Homepage</body></html>');
|
return (new Response('<html><body>Homepage</body></html>'))->setPublic();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -129,6 +129,16 @@ class SecurityRoutingIntegrationTest extends AbstractWebTestCase
|
|||||||
$client->request('GET', '/unprotected_resource');
|
$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)
|
private function assertAllowed($client, $path)
|
||||||
{
|
{
|
||||||
$client->request('GET', $path);
|
$client->request('GET', $path);
|
||||||
|
@ -27,7 +27,7 @@ security:
|
|||||||
check_path: /login_check
|
check_path: /login_check
|
||||||
default_target_path: /profile
|
default_target_path: /profile
|
||||||
logout: ~
|
logout: ~
|
||||||
anonymous: ~
|
anonymous: lazy
|
||||||
|
|
||||||
# This firewall is here just to check its the logout functionality
|
# This firewall is here just to check its the logout functionality
|
||||||
second_area:
|
second_area:
|
||||||
@ -38,6 +38,7 @@ security:
|
|||||||
path: /second/logout
|
path: /second/logout
|
||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
|
- { path: ^/en/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||||
- { path: ^/unprotected_resource$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
- { path: ^/unprotected_resource$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||||
- { path: ^/secure-but-not-covered-by-access-control$, 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 }
|
- { 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-core": "^4.4",
|
||||||
"symfony/security-csrf": "^4.2|^5.0",
|
"symfony/security-csrf": "^4.2|^5.0",
|
||||||
"symfony/security-guard": "^4.2|^5.0",
|
"symfony/security-guard": "^4.2|^5.0",
|
||||||
"symfony/security-http": "^4.3"
|
"symfony/security-http": "^4.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"symfony/asset": "^3.4|^4.0|^5.0",
|
"symfony/asset": "^3.4|^4.0|^5.0",
|
||||||
|
@ -25,12 +25,18 @@ use Symfony\Contracts\Service\ResetInterface;
|
|||||||
class TokenStorage implements TokenStorageInterface, ResetInterface
|
class TokenStorage implements TokenStorageInterface, ResetInterface
|
||||||
{
|
{
|
||||||
private $token;
|
private $token;
|
||||||
|
private $initializer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
public function getToken()
|
public function getToken()
|
||||||
{
|
{
|
||||||
|
if ($initializer = $this->initializer) {
|
||||||
|
$this->initializer = null;
|
||||||
|
$initializer();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->token;
|
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);
|
@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;
|
$this->token = $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setInitializer(?callable $initializer): void
|
||||||
|
{
|
||||||
|
$this->initializer = $initializer;
|
||||||
|
}
|
||||||
|
|
||||||
public function reset()
|
public function reset()
|
||||||
{
|
{
|
||||||
$this->setToken(null);
|
$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\AccessDeniedException;
|
|||||||
use Symfony\Component\Security\Core\Exception\AccountStatusException;
|
use Symfony\Component\Security\Core\Exception\AccountStatusException;
|
||||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
|
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\Exception\LogoutException;
|
||||||
use Symfony\Component\Security\Core\Security;
|
use Symfony\Component\Security\Core\Security;
|
||||||
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
|
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
|
||||||
@ -103,6 +104,12 @@ class ExceptionListener
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($exception instanceof LazyResponseException) {
|
||||||
|
$event->setResponse($exception->getResponse());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($exception instanceof LogoutException) {
|
if ($exception instanceof LogoutException) {
|
||||||
$this->handleLogoutException($exception);
|
$this->handleLogoutException($exception);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user