8000 feature #33676 [Security] add "anonymous: lazy" mode to firewalls (ni… · symfony/security-bundle@4bc0f68 · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit 4bc0f68

Browse files
committed
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 symfony/recipes#649 Commits ------- 5cd1d7b4cc [Security] add "anonymous: lazy" mode to firewalls
2 parents 8346c18 + 69f4648 commit 4bc0f68

File tree

10 files changed

+110
-6
lines changed

10 files changed

+110
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
4.3.0
99
-----
1010

11+
* Added `anonymous: lazy` mode to firewalls to make them (not) start the session as late as possible
1112
* Added new encoder types: `auto` (recommended), `native` and `sodium`
1213
* The normalization of the cookie names configured in the `logout.delete_cookies`
1314
option is deprecated and will be disabled in Symfony 5.0. This affects to cookies

DataCollector/SecurityDataCollector.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\HttpFoundation\Response;
1818
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
1919
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
20+
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
2021
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
2122
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
2223
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
@@ -127,7 +128,7 @@ public function collect(Request $request, Response $response, \Exception $except
127128

128129
$logoutUrl = null;
129130
try {
130-
if (null !== $this->logoutUrlGenerator) {
131+
if (null !== $this->logoutUrlGenerator && !$token instanceof AnonymousToken) {
131132
$logoutUrl = $this->logoutUrlGenerator->getLogoutPath();
132133
}
133134
} catch (\Exception $e) {

DependencyInjection/Security/Factory/AnonymousFactory.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ public function getKey()
5555
public function addConfiguration(NodeDefinition $builder)
5656
{
5757
$builder
58+
->beforeNormalization()
59+
->ifTrue(function ($v) { return 'lazy' === $v; })
60+
->then(function ($v) { return ['lazy' => true]; })
61+
->end()
5862
->children()
63+
->booleanNode('lazy')->defaultFalse()->end()
5964
->scalarNode('secret')->defaultNull()->end()
6065
->end()
6166
;

DependencyInjection/SecurityExtension.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ private function createFirewalls(array $config, ContainerBuilder $container)
243243
list($matcher, $listeners, $exceptionListener, $logoutListener) = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $configId);
244244

245245
$contextId = 'security.firewall.map.context.'.$name;
246-
$context = $container->setDefinition($contextId, new ChildDefinition('security.firewall.context'));
246+
$context = new ChildDefinition($firewall['stateless'] || empty($firewall['anonymous']['lazy']) ? 'security.firewall.context' : 'security.firewall.lazy_context');
247+
$context = $container->setDefinition($contextId, $context);
247248
$context
248249
->replaceArgument(0, new IteratorArgument($listeners))
249250
->replaceArgument(1, $exceptionListener)
@@ -409,7 +410,9 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
409410
}
410411

411412
// Access listener
412-
$listeners[] = new Reference('security.access_listener');
413+
if ($firewall['stateless'] || empty($firewall['anonymous']['lazy'])) {
414+
$listeners[] = new Reference('security.access_listener');
415+
}
413416

414417
// Exception listener
415418
$exceptionListener = new Reference($this->createExceptionListener($container, $firewall, $id, $configuredEntryPoint ?: $defaultEntryPoint, $firewall['stateless']));

Resources/config/security.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@
151151
<argument /> <!-- FirewallConfig -->
152152
</service>
153153

154+
<service id="security.firewall.lazy_context" class="Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext" abstract="true">
155+
<argument type="collection" />
156+
<argument type="service" id="security.exception_listener" />
157+
<argument /> <!-- LogoutListener -->
158+
<argument /> <!-- FirewallConfig -->
159+
<argument type="service" id="security.access_listener" />
160+
<argument type="service" id="security.untracked_token_storage" />
161+
<argument type="service" id="security.access_map" />
162+
</service>
163+
154164
<service id="security.firewall.config" class="Symfony\Bundle\SecurityBundle\Security\FirewallConfig" abstract="true">
155165
<argument /> <!-- name -->
156166
<argument /> <!-- user_checker -->

Security/LazyFirewallContext.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Security;
13+
14+
use Symfony\Component\HttpKernel\Event\RequestEvent;
15+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
16+
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
17+
use Symfony\Component\Security\Core\Exception\LazyResponseException;
18+
use Symfony\Component\Security\Http\AccessMapInterface;
19+
use Symfony\Component\Security\Http\Event\LazyResponseEvent;
20+
use Symfony\Component\Security\Http\Firewall\AccessListener;
21+
use Symfony\Component\Security\Http\Firewall\ExceptionListener;
22+
use Symfony\Component\Security\Http\Firewall\LogoutListener;
23+
24+
/**
25+
* Lazily calls authentication listeners when actually required by the access listener.
26+
*
27+
* @author Nicolas Grekas <p@tchwork.com>
28+
*/
29+
class LazyFirewallContext extends FirewallContext
30+
{
31+
private $accessListener;
32+
private $tokenStorage;
33+
private $map;
34+
35+
public function __construct(iterable $listeners, ?ExceptionListener $exceptionListener, ?LogoutListener $logoutListener, ?FirewallConfig $config, AccessListener $accessListener, TokenStorage $tokenStorage, AccessMapInterface $map)
36+
{
37+
parent::__construct($listeners, $exceptionListener, $logoutListener, $config);
38+
39+
$this->accessListener = $accessListener;
40+
$this->tokenStorage = $tokenStorage;
41+
$this->map = $map;
42+
}
43+
44+
public function getListeners(): iterable
45+
{
46+
return [$this];
47+
}
48+
49+
public function __invoke(RequestEvent $event)
50+
{
51+
$this->tokenStorage->setInitializer(function () use ($event) {
52+
$event = new LazyResponseEvent($event);
53+
foreach (parent::getListeners() as $listener) {
54+
if (\is_callable($listener)) {
55+
$listener($event);
56+
} else {
57+
@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);
58+
$listener->handle($event);
59+
}
60+
}
61+
});
62+
63+
try {
64+
[$attributes] = $this->map->getPatterns($event->getRequest());
65+
66+
if ($attributes && [AuthenticatedVoter::IS_AUTHENTICATED_ANONYMOUSLY] !== $attributes) {
67+
($this->accessListener)($event);
68+
}
69+
} catch (LazyResponseException $e) {
70+
$event->setResponse($e->getResponse());
71+
}
72+
}
73+
}

Tests/Functional/Bundle/FormLoginBundle/Controller/LocalizedController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,6 @@ public function profileAction()
5959

6060
public function homepageAction()
6161
{
62-
return new Response('<html><body>Homepage</body></html>');
62+
return (new Response('<html><body>Homepage</body></html>'))->setPublic();
6363
}
6464
}

Tests/Functional/SecurityRoutingIntegrationTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@ public function testInvalidIpsInAccessControl()
129129
$client->request('GET', '/unprotected_resource');
130130
}
131131

132+
public function testPublicHomepage()
133+
{
134+
$client = $this->createClient(['test_case' => 'StandardFormLogin', 'root_config' => 'config.yml']);
135+
$client->request('GET', '/en/');
136+
137+
$this->assertEquals(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse());
138+
$this->assertTrue($client->getResponse()->headers->getCacheControlDirective('public'));
139+
$this->assertSame(0, self::$container->get('session')->getUsageIndex());
140+
}
141+
132142
private function assertAllowed($client, $path)
133143
{
134144
$client->request('GET', $path);

Tests/Functional/app/StandardFormLogin/config.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ security:
2727
check_path: /login_check
2828
default_target_path: /profile
2929
logout: ~
30-
anonymous: ~
30+
anonymous: lazy
3131

3232
# This firewall is here just to check its the logout functionality
3333
second_area:
@@ -38,6 +38,7 @@ security:
3838
path: /second/logout
3939

4040
access_control:
41+
- { path: ^/en/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
4142
- { path: ^/unprotected_resource$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
4243
- { path: ^/secure-but-not-covered-by-access-control$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
4344
- { path: ^/secured-by-one-ip$, ip: 10.10.10.10, roles: IS_AUTHENTICATED_ANONYMOUSLY }

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"symfony/security-core": "^4.4",
2525
"symfony/security-csrf": "^4.2|^5.0",
2626
"symfony/security-guard": "^4.2|^5.0",
27-
"symfony/security-http": "^4.3"
27+
"symfony/security-http": "^4.4"
2828
},
2929
"require-dev": {
3030
"symfony/asset": "^3.4|^4.0|^5.0",

0 commit comments

Comments
 (0)
0