Skip to content

Commit

Permalink
feature #33676 [Security] add "anonymous: lazy" mode to firewalls (ni…
Browse files Browse the repository at this point in the history
…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 6fef3fb
Show file tree
Hide file tree
Showing 14 changed files with 239 additions and 6 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Bundle/SecurityBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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']));
Expand Down
10 changes: 10 additions & 0 deletions src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->
Expand Down
73 changes: 73 additions & 0 deletions src/Symfony/Bundle/SecurityBundle/Security/LazyFirewallContext.php
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/SecurityBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
76 changes: 76 additions & 0 deletions src/Symfony/Component/Security/Http/Event/LazyResponseEvent.php
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down

0 comments on commit 6fef3fb

Please sign in to comment.