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:
commit
7995fed10b
@ -66,7 +66,9 @@
|
|||||||
<argument type="service_locator">
|
<argument type="service_locator">
|
||||||
<argument key="session" type="service" id="session" on-invalid="ignore" />
|
<argument key="session" type="service" id="session" on-invalid="ignore" />
|
||||||
<argument key="initialized_session" type="service" id="session" on-invalid="ignore_uninitialized" />
|
<argument key="initialized_session" type="service" id="session" on-invalid="ignore_uninitialized" />
|
||||||
|
<argument key="logger" type="service" id="logger" on-invalid="ignore" />
|
||||||
</argument>
|
</argument>
|
||||||
|
<argument>%kernel.debug%</argument>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- for BC -->
|
<!-- for BC -->
|
||||||
|
@ -504,7 +504,7 @@ abstract class FrameworkExtensionTest extends TestCase
|
|||||||
$this->assertNull($container->getDefinition('session.storage.native')->getArgument(1));
|
$this->assertNull($container->getDefinition('session.storage.native')->getArgument(1));
|
||||||
$this->assertNull($container->getDefinition('session.storage.php_bridge')->getArgument(0));
|
$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()));
|
$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');
|
$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()));
|
$this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ CHANGELOG
|
|||||||
-----
|
-----
|
||||||
|
|
||||||
* allowed using public aliases to reference controllers
|
* 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
|
5.0.0
|
||||||
-----
|
-----
|
||||||
|
@ -18,6 +18,7 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|||||||
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
|
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnexpectedSessionUsageException;
|
||||||
use Symfony\Component\HttpKernel\KernelEvents;
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,10 +42,12 @@ abstract class AbstractSessionListener implements EventSubscriberInterface
|
|||||||
|
|
||||||
protected $container;
|
protected $container;
|
||||||
private $sessionUsageStack = [];
|
private $sessionUsageStack = [];
|
||||||
|
private $debug;
|
||||||
|
|
||||||
public function __construct(ContainerInterface $container = null)
|
public function __construct(ContainerInterface $container = null, bool $debug = false)
|
||||||
{
|
{
|
||||||
$this->container = $container;
|
$this->container = $container;
|
||||||
|
$this->debug = $debug;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onKernelRequest(RequestEvent $event)
|
public function onKernelRequest(RequestEvent $event)
|
||||||
@ -82,16 +85,6 @@ abstract class AbstractSessionListener implements EventSubscriberInterface
|
|||||||
return;
|
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()) {
|
if ($session->isStarted()) {
|
||||||
/*
|
/*
|
||||||
* Saves the session, in case it is still open, before sending the response/headers.
|
* 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();
|
$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)
|
public function onFinishRequest(FinishRequestEvent $event)
|
||||||
|
@ -28,9 +28,9 @@ use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
|
|||||||
*/
|
*/
|
||||||
class SessionListener extends AbstractSessionListener
|
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
|
protected function getSession(): ?SessionInterface
|
||||||
|
@ -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
|
||||||
|
{
|
||||||
|
}
|
@ -12,6 +12,7 @@
|
|||||||
namespace Symfony\Component\HttpKernel\Tests\EventListener;
|
namespace Symfony\Component\HttpKernel\Tests\EventListener;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Container;
|
use Symfony\Component\DependencyInjection\Container;
|
||||||
use Symfony\Component\DependencyInjection\ServiceLocator;
|
use Symfony\Component\DependencyInjection\ServiceLocator;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
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\Event\ResponseEvent;
|
||||||
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
|
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
|
||||||
use Symfony\Component\HttpKernel\EventListener\SessionListener;
|
use Symfony\Component\HttpKernel\EventListener\SessionListener;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnexpectedSessionUsageException;
|
||||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
class SessionListenerTest extends TestCase
|
class SessionListenerTest extends TestCase
|
||||||
@ -178,4 +180,68 @@ class SessionListenerTest extends TestCase
|
|||||||
$this->assertTrue($response->headers->has('Expires'));
|
$this->assertTrue($response->headers->has('Expires'));
|
||||||
$this->assertLessThanOrEqual((new \DateTime('now', new \DateTimeZone('UTC'))), (new \DateTime($response->headers->get('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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user