Skip to content

Commit

Permalink
feature #35732 [FrameworkBundle][HttpKernel] Add session usage report…
Browse files Browse the repository at this point in the history
…ing 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
-------

bc48db2 [FrameworkBundle][HttpFoundation] Add `_stateless`
  • Loading branch information
fabpot committed Feb 26, 2020
2 parents dca77c4 + bc48db2 commit 7995fed
Show file tree
Hide file tree
Showing 7 changed files with 120 additions and 15 deletions.
Expand Up @@ -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 -->
Expand Down
Expand Up @@ -504,7 +504,7 @@ public function testNullSessionHandler()
$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()));
}

Expand Down Expand Up @@ -1301,7 +1301,7 @@ public function testSessionCookieSecureAuto()
{
$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()));
}

Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpKernel/CHANGELOG.md
Expand Up @@ -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
-----
Expand Down
Expand Up @@ -18,6 +18,7 @@
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;

/**
Expand All @@ -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)
Expand Down Expand Up @@ -82,16 +85,6 @@ public function onKernelResponse(ResponseEvent $event)
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.
Expand Down Expand Up @@ -120,6 +113,30 @@ public function onKernelResponse(ResponseEvent $event)
*/
$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)
Expand Down
Expand Up @@ -28,9 +28,9 @@
*/
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
Expand Down
@@ -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
{
}
Expand Up @@ -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;
Expand All @@ -24,6 +25,7 @@
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
Expand Down Expand Up @@ -178,4 +180,68 @@ public function testSurrogateMasterRequestIsPublic()
$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));
}
}

0 comments on commit 7995fed

Please sign in to comment.