feature #35732 [FrameworkBundle][HttpKernel] Add session usage reporting in stateless mode (mtarld)

This PR was merged into the 5.1-dev branch.

Discussion
----------

[FrameworkBundle][HttpKernel] Add session usage reporting in stateless mode

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| License       | MIT
| Doc PR        | TODO

https://github.com/orgs/symfony/projects/1#card-30506005

Provide a `@Stateless` annotation that forbid session usage for annotated controllers (or classes).

## Implementations
**v1**
- ~~New session proxy that allows session to be marked as disabled~~
- ~~New default route attribute: `_stateless` (automatically set by `@Stateless`)~~
- ~~On kernel controller event, if `_stateless` is `true`, session is marked as disabled~~
- ~~Session listener is able to check if the session is disabled and prevent its creation~~

**v2**
- New default route attribute: `_stateless` (automatically set by `@Stateless`)
- On kernel response, check the session usage and if session was used when `_stateless` attribute is set to `true`: Either throw an exception (debug enabled) or log a warning (debug disabled)

Commits
-------

bc48db2424 [FrameworkBundle][HttpFoundation] Add `_stateless`
This commit is contained in:
Fabien Potencier 2020-02-26 11:40:28 +01:00
commit 7995fed10b
7 changed files with 120 additions and 15 deletions

View File

@ -66,7 +66,9 @@
<argument type="service_locator">
<argument key="session" type="service" id="session" on-invalid="ignore" />
<argument key="initialized_session" type="service" id="session" on-invalid="ignore_uninitialized" />
<argument key="logger" type="service" id="logger" on-invalid="ignore" />
</argument>
<argument>%kernel.debug%</argument>
</service>
<!-- for BC -->

View File

@ -504,7 +504,7 @@ abstract class FrameworkExtensionTest extends TestCase
$this->assertNull($container->getDefinition('session.storage.native')->getArgument(1));
$this->assertNull($container->getDefinition('session.storage.php_bridge')->getArgument(0));
$expected = ['session', 'initialized_session'];
$expected = ['session', 'initialized_session', 'logger'];
$this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues()));
}
@ -1301,7 +1301,7 @@ abstract class FrameworkExtensionTest extends TestCase
{
$container = $this->createContainerFromFile('session_cookie_secure_auto');
$expected = ['session', 'initialized_session', 'session_storage', 'request_stack'];
$expected = ['session', 'initialized_session', 'logger', 'session_storage', 'request_stack'];
$this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues()));
}

View File

@ -5,6 +5,7 @@ CHANGELOG
-----
* allowed using public aliases to reference controllers
* added session usage reporting when the `_stateless` attribute of the request is set to `true`
5.0.0
-----

View File

@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Exception\UnexpectedSessionUsageException;
use Symfony\Component\HttpKernel\KernelEvents;
/**
@ -41,10 +42,12 @@ abstract class AbstractSessionListener implements EventSubscriberInterface
protected $container;
private $sessionUsageStack = [];
private $debug;
public function __construct(ContainerInterface $container = null)
public function __construct(ContainerInterface $container = null, bool $debug = false)
{
$this->container = $container;
$this->debug = $debug;
}
public function onKernelRequest(RequestEvent $event)
@ -82,16 +85,6 @@ abstract class AbstractSessionListener implements EventSubscriberInterface
return;
}
if ($session instanceof Session ? $session->getUsageIndex() !== end($this->sessionUsageStack) : $session->isStarted()) {
if ($autoCacheControl) {
$response
->setExpires(new \DateTime())
->setPrivate()
->setMaxAge(0)
->headers->addCacheControlDirective('must-revalidate');
}
}
if ($session->isStarted()) {
/*
* Saves the session, in case it is still open, before sending the response/headers.
@ -120,6 +113,30 @@ abstract class AbstractSessionListener implements EventSubscriberInterface
*/
$session->save();
}
if ($session instanceof Session ? $session->getUsageIndex() === end($this->sessionUsageStack) : !$session->isStarted()) {
return;
}
if ($autoCacheControl) {
$response
->setExpires(new \DateTime())
->setPrivate()
->setMaxAge(0)
->headers->addCacheControlDirective('must-revalidate');
}
if (!$event->getRequest()->attributes->get('_stateless', false)) {
return;
}
if ($this->debug) {
throw new UnexpectedSessionUsageException('Session was used while the request was declared stateless.');
}
if ($this->container->has('logger')) {
$this->container->get('logger')->warning('Session was used while the request was declared stateless.');
}
}
public function onFinishRequest(FinishRequestEvent $event)

View File

@ -28,9 +28,9 @@ use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
*/
class SessionListener extends AbstractSessionListener
{
public function __construct(ContainerInterface $container)
public function __construct(ContainerInterface $container, bool $debug = false)
{
$this->container = $container;
parent::__construct($container, $debug);
}
protected function getSession(): ?SessionInterface

View File

@ -0,0 +1,19 @@
<?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\HttpKernel\Exception;
/**
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
*/
class UnexpectedSessionUsageException extends \LogicException
{
}

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\HttpKernel\Tests\EventListener;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;
@ -24,6 +25,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
use Symfony\Component\HttpKernel\EventListener\SessionListener;
use Symfony\Component\HttpKernel\Exception\UnexpectedSessionUsageException;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class SessionListenerTest extends TestCase
@ -178,4 +180,68 @@ class SessionListenerTest extends TestCase
$this->assertTrue($response->headers->has('Expires'));
$this->assertLessThanOrEqual((new \DateTime('now', new \DateTimeZone('UTC'))), (new \DateTime($response->headers->get('Expires'))));
}
public function testSessionUsageExceptionIfStatelessAndSessionUsed()
{
$session = $this->getMockBuilder(Session::class)->disableOriginalConstructor()->getMock();
$session->expects($this->exactly(2))->method('getUsageIndex')->will($this->onConsecutiveCalls(0, 1));
$container = new Container();
$container->set('initialized_session', $session);
$listener = new SessionListener($container, true);
$kernel = $this->getMockBuilder(HttpKernelInterface::class)->disableOriginalConstructor()->getMock();
$request = new Request();
$request->attributes->set('_stateless', true);
$listener->onKernelRequest(new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST));
$this->expectException(UnexpectedSessionUsageException::class);
$listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST, new Response()));
}
public function testSessionUsageLogIfStatelessAndSessionUsed()
{
$session = $this->getMockBuilder(Session::class)->disableOriginalConstructor()->getMock();
$session->expects($this->exactly(2))->method('getUsageIndex')->will($this->onConsecutiveCalls(0, 1));
$logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
$logger->expects($this->exactly(1))->method('warning');
$container = new Container();
$container->set('initialized_session', $session);
$container->set('logger', $logger);
$listener = new SessionListener($container, false);
$kernel = $this->getMockBuilder(HttpKernelInterface::class)->disableOriginalConstructor()->getMock();
$request = new Request();
$request->attributes->set('_stateless', true);
$listener->onKernelRequest(new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST));
$listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST, new Response()));
}
public function testSessionIsSavedWhenUnexpectedSessionExceptionThrown()
{
$session = $this->getMockBuilder(Session::class)->disableOriginalConstructor()->getMock();
$session->method('isStarted')->willReturn(true);
$session->expects($this->exactly(2))->method('getUsageIndex')->will($this->onConsecutiveCalls(0, 1));
$session->expects($this->exactly(1))->method('save');
$container = new Container();
$container->set('initialized_session', $session);
$listener = new SessionListener($container, true);
$kernel = $this->getMockBuilder(HttpKernelInterface::class)->disableOriginalConstructor()->getMock();
$request = new Request();
$request->attributes->set('_stateless', true);
$listener->onKernelRequest(new RequestEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST));
$response = new Response();
$this->expectException(UnexpectedSessionUsageException::class);
$listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST, $response));
}
}