From f609a6a37a82d94be04a83921c6b5321df9fe43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 2 Dec 2014 10:34:09 +0100 Subject: [PATCH 1/5] New session expiration firewall to block access for idle sessions. --- .../DependencyInjection/MainConfiguration.php | 7 + .../DependencyInjection/SecurityExtension.php | 16 ++ .../Resources/config/security_listeners.xml | 11 + .../Functional/SessionExpirationTest.php | 43 ++++ .../app/SessionExpiration/bundles.php | 13 + .../app/SessionExpiration/config.yml | 32 +++ .../app/SessionExpiration/routing.yml | 3 + .../Exception/SessionExpiredException.php | 28 +++ .../Firewall/SessionExpirationListener.php | 91 +++++++ .../SessionExpirationListenerTest.php | 229 ++++++++++++++++++ 10 files changed, 473 insertions(+) create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionExpirationTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/bundles.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/config.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/routing.yml create mode 100644 src/Symfony/Component/Security/Core/Exception/SessionExpiredException.php create mode 100644 src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 3f25c3da03e4a..043e8d373753b 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -288,6 +288,13 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end() ->end() ->end() + ->arrayNode('session_expiration') + ->canBeUnset() + ->children() + ->integerNode('max_idle_time')->defaultValue(ini_get('session.gc_maxlifetime'))->min(1)->end() + ->scalarNode('expiration_url')->defaultNull()->end() + ->end() + ->end() ; $abstractFactoryKeys = array(); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 1f20fc7596414..00169863b2a8d 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -361,6 +361,11 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a $listeners[] = new Reference($this->createSwitchUserListener($container, $id, $firewall['switch_user'], $defaultProvider)); } + // Session expiration listener + if (isset($firewall['session_expiration'])) { + $listeners[] = new Reference($this->createSessionExpirationListener($container, $id, $firewall)); + } + // Access listener $listeners[] = new Reference('security.access_listener'); @@ -611,6 +616,17 @@ private function createSwitchUserListener($container, $id, $config, $defaultProv return $switchUserListenerId; } + private function createSessionExpirationListener($container, $id, $config) + { + $expiredSessionListenerId = 'security.authentication.sessionexpiration_listener.' . $id; + $listener = $container->setDefinition($expiredSessionListenerId, new DefinitionDecorator('security.authentication.sessionexpiration_listener')); + + $listener->replaceArgument(2, $config['session_expiration']['max_idle_time']); + $listener->replaceArgument(3, $config['session_expiration']['expiration_url']); + + return $expiredSessionListenerId; + } + private function createExpression($container, $expression) { if (isset($this->expressions[$id = 'security.expression.'.sha1($expression)])) { diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 7d3ba1a6f322c..c8d6423adae3f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -28,6 +28,8 @@ Symfony\Component\Security\Http\Firewall\SwitchUserListener + Symfony\Component\Security\Http\Firewall\SessionExpirationListener + Symfony\Component\Security\Http\Firewall\LogoutListener Symfony\Component\Security\Http\Logout\SessionLogoutHandler Symfony\Component\Security\Http\Logout\CookieClearingLogoutHandler @@ -257,6 +259,15 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionExpirationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionExpirationTest.php new file mode 100644 index 0000000000000..4558ee0e72de9 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionExpirationTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional; + +/** + * @author Antonio J. García Lagar + * @group functional + */ +class SessionExpirationTest extends WebTestCase +{ + public function testExpiredExceptionRedirectsToTargetUrl() + { + $client = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'config.yml')); + $form = $client->request('GET', '/login')->selectButton('login')->form(); + $form['_username'] = 'antonio'; + $form['_password'] = 'secret'; + $client->submit($form); + $this->assertRedirect($client->getResponse(), '/profile'); + + $client->request('GET', '/protected_resource'); + $this->assertEquals(200, $client->getResponse()->getStatusCode()); + + sleep(3); //Wait for session to expire + $client->request('GET', '/protected_resource'); + $this->assertRedirect($client->getResponse(), '/expired'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('SessionExpiration'); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/bundles.php new file mode 100644 index 0000000000000..e4bbc08f73ff4 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/bundles.php @@ -0,0 +1,13 @@ + + * + * 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; + +/** + * SessionExpiredException is thrown when session has been idle for a long time. + * + * @author Antonio J. García Lagar + */ +class SessionExpiredException extends AuthenticationException +{ + /** + * {@inheritdoc} + */ + public function getMessageKey() + { + return 'Session has expired.'; + } +} diff --git a/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php b/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php new file mode 100644 index 0000000000000..bb8754810bada --- /dev/null +++ b/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Firewall; + +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\Log\LoggerInterface; +use Symfony\Component\Security\Core\Exception\SessionExpiredException; +use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; + +/** + * SessionExpirationListener controls idle sessions + * + * @author Antonio J. García Lagar + */ +class SessionExpirationListener implements ListenerInterface +{ + private $tokenStorage; + private $httpUtils; + private $maxIdleTime; + private $targetUrl; + private $logger; + + public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, $maxIdleTime, $targetUrl = null, LoggerInterface $logger = null) + { + $this->tokenStorage = $tokenStorage; + $this->httpUtils = $httpUtils; + $this->maxIdleTime = $maxIdleTime; + $this->targetUrl = $targetUrl; + $this->logger = $logger; + } + + /** + * Handles expired sessions. + * + * @param GetResponseEvent $event A GetResponseEvent instance + * @throws SessionExpiredException If the session has expired + */ + public function handle(GetResponseEvent $event) + { + $request = $event->getRequest(); + $session = $request->getSession(); + + if (null === $session || null === $token = $this->tokenStorage->getToken()) { + return; + } + + if (!$this->hasSessionExpired($session)) { + return; + } + + if (null !== $this->logger) { + $this->logger->info(sprintf("Expired session detected for user named '%s'", $token->getUsername())); + } + + $this->tokenStorage->setToken(null); + $session->invalidate(); + + if (null === $this->targetUrl) { + throw new SessionExpiredException(); + } + + $response = $this->httpUtils->createRedirectResponse($request, $this->targetUrl); + $event->setResponse($response); + } + + /** + * Checks if the given session has expired. + * + * @param SessionInterface $session + * @return bool + */ + private function hasSessionExpired(SessionInterface $session) + { + if (time() - $session->getMetadataBag()->getLastUsed() > $this->maxIdleTime) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php new file mode 100644 index 0000000000000..4bd6a26bbac90 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Firewall; + +use Symfony\Component\Security\Http\Firewall\SessionExpirationListener; + +/** + * @author Antonio J. García Lagar + */ +class SessionExpirationListenerTest extends \PHPUnit_Framework_TestCase +{ + public function testHandleWhenNoSession() + { + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->once()) + ->method('getSession') + ->will($this->returnValue(false)); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + + $listener = new SessionExpirationListener( + $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'), + $this->getHttpUtils(), + 1440 + ); + + $this->assertNull($listener->handle($event)); + } + + public function testHandleWhenNoToken() + { + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue(null)); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + + $listener = new SessionExpirationListener( + $securityContext, + $this->getHttpUtils(), + 1440 + ); + + $this->assertNull($listener->handle($event)); + } + + public function testHandleWhenSessionHasNotExpired() + { + $metadataBag = $this->getMock('\Symfony\Component\HttpFoundation\Session\Storage\MetadataBag'); + $metadataBag + ->expects($this->once()) + ->method('getLastUsed') + ->will($this->returnValue(time())); + + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session + ->expects($this->any()) + ->method('getMetadataBag') + ->will($this->returnValue($metadataBag)); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + + $listener = new SessionExpirationListener( + $securityContext, + $this->getHttpUtils(), + 1440 + ); + + $this->assertNull($listener->handle($event)); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\SessionExpiredException + */ + public function testHandleWhenSessionHasExpiredAndNoTargetUrl() + { + $metadataBag = $this->getMock('\Symfony\Component\HttpFoundation\Session\Storage\MetadataBag'); + $metadataBag + ->expects($this->once()) + ->method('getLastUsed') + ->will($this->returnValue(time()-2)); + + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session + ->expects($this->any()) + ->method('getMetadataBag') + ->will($this->returnValue($metadataBag)); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + + $listener = new SessionExpirationListener( + $securityContext, + $this->getHttpUtils(), + 1 + ); + + $listener->handle($event); + } + + public function testHandleWhenSessionHasExpiredAndTargetUrl() + { + $metadataBag = $this->getMock('\Symfony\Component\HttpFoundation\Session\Storage\MetadataBag'); + $metadataBag + ->expects($this->once()) + ->method('getLastUsed') + ->will($this->returnValue(time()-2)); + + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session + ->expects($this->any()) + ->method('getMetadataBag') + ->will($this->returnValue($metadataBag)); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $response = $this->getMock('Symfony\Component\HttpFoundation\Response'); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + $event + ->expects($this->once()) + ->method('setResponse') + ->with($this->identicalTo($response)); + + $httpUtils = $this->getHttpUtils(); + $httpUtils + ->expects($this->once()) + ->method('createRedirectResponse') + ->with($this->identicalTo($request), $this->equalTo('/expired')) + ->will($this->returnValue($response)); + + $listener = new SessionExpirationListener( + $securityContext, + $httpUtils, + 1, + '/expired' + ); + + $listener->handle($event); + + } + + private function getHttpUtils() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\HttpUtils') + ->disableOriginalConstructor() + ->getMock(); + } + +} From 749cc23ec63af8af0f61767743f922fd08e42f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 2 Dec 2014 16:56:04 +0100 Subject: [PATCH 2/5] CS fixes suggested by fabbot --- .../SecurityBundle/DependencyInjection/SecurityExtension.php | 2 +- .../Security/Http/Firewall/SessionExpirationListener.php | 2 +- .../Http/Tests/Firewall/SessionExpirationListenerTest.php | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 00169863b2a8d..86234887face6 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -618,7 +618,7 @@ private function createSwitchUserListener($container, $id, $config, $defaultProv private function createSessionExpirationListener($container, $id, $config) { - $expiredSessionListenerId = 'security.authentication.sessionexpiration_listener.' . $id; + $expiredSessionListenerId = 'security.authentication.sessionexpiration_listener.'.$id; $listener = $container->setDefinition($expiredSessionListenerId, new DefinitionDecorator('security.authentication.sessionexpiration_listener')); $listener->replaceArgument(2, $config['session_expiration']['max_idle_time']); diff --git a/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php b/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php index bb8754810bada..608d591170ff2 100644 --- a/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php @@ -88,4 +88,4 @@ private function hasSessionExpired(SessionInterface $session) return false; } -} \ No newline at end of file +} diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php index 4bd6a26bbac90..527e559cb78ef 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/SessionExpirationListenerTest.php @@ -216,7 +216,6 @@ public function testHandleWhenSessionHasExpiredAndTargetUrl() ); $listener->handle($event); - } private function getHttpUtils() @@ -225,5 +224,4 @@ private function getHttpUtils() ->disableOriginalConstructor() ->getMock(); } - } From 4c52391dc7814c45a6ac28bb21cdbf2466f180eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 2 Dec 2014 10:35:18 +0100 Subject: [PATCH 3/5] Adds session concurrency support --- .../DependencyInjection/MainConfiguration.php | 18 +++ .../Security/Factory/AbstractFactory.php | 9 +- .../DependencyInjection/SecurityExtension.php | 64 ++++++++ .../Resources/config/security_listeners.xml | 1 + .../config/security_session_concurrency.xml | 40 +++++ .../Functional/SessionConcurrencyTest.php | 81 ++++++++++ .../SessionExpiration/session_concurrency.yml | 12 ++ .../session_concurrency_expiration.yml | 13 ++ .../MaxSessionsExceededException.php | 21 +++ .../Firewall/SessionExpirationListener.php | 25 ++- ...CompositeSessionAuthenticationStrategy.php | 54 +++++++ ...ntSessionControlAuthenticationStrategy.php | 120 ++++++++++++++ .../Session/FileSessionRegistryStorage.php | 105 ++++++++++++ .../RegisterSessionAuthenticationStrategy.php | 44 ++++++ .../Http/Session/SessionInformation.php | 95 +++++++++++ .../Security/Http/Session/SessionRegistry.php | 144 +++++++++++++++++ .../SessionRegistryStorageInterface.php | 55 +++++++ ...ositeSessionAuthenticationStrategyTest.php | 52 ++++++ ...ssionControlAuthenticationStrategyTest.php | 149 ++++++++++++++++++ ...isterSessionAuthenticationStrategyTest.php | 58 +++++++ .../Tests/Session/SessionInformationTest.php | 64 ++++++++ .../Tests/Session/SessionRegistryTest.php | 115 ++++++++++++++ 22 files changed, 1333 insertions(+), 6 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionConcurrencyTest.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency.yml create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency_expiration.yml create mode 100644 src/Symfony/Component/Security/Core/Exception/MaxSessionsExceededException.php create mode 100644 src/Symfony/Component/Security/Http/Session/CompositeSessionAuthenticationStrategy.php create mode 100644 src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlAuthenticationStrategy.php create mode 100644 src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php create mode 100644 src/Symfony/Component/Security/Http/Session/RegisterSessionAuthenticationStrategy.php create mode 100644 src/Symfony/Component/Security/Http/Session/SessionInformation.php create mode 100644 src/Symfony/Component/Security/Http/Session/SessionRegistry.php create mode 100644 src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Session/CompositeSessionAuthenticationStrategyTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Session/ConcurrentSessionControlAuthenticationStrategyTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Session/RegisterSessionAuthenticationStrategyTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Session/SessionInformationTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 043e8d373753b..dd29dc57b1429 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -79,6 +79,7 @@ public function getConfigTreeBuilder() $this->addFirewallsSection($rootNode, $this->factories); $this->addAccessControlSection($rootNode); $this->addRoleHierarchySection($rootNode); + $this->addSessionRegistrySection($rootNode); return $tb; } @@ -295,6 +296,14 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->scalarNode('expiration_url')->defaultNull()->end() ->end() ->end() + ->arrayNode('session_concurrency') + ->canBeUnset() + ->children() + ->integerNode('max_sessions')->min(0)->end() + ->booleanNode('error_if_maximum_exceeded')->defaultTrue()->end() + ->booleanNode('register_new_sessions')->defaultNull()->end() + ->end() + ->end() ; $abstractFactoryKeys = array(); @@ -437,4 +446,13 @@ private function addEncodersSection(ArrayNodeDefinition $rootNode) ->end() ; } + + private function addSessionRegistrySection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->scalarNode('session_registry_storage')->defaultValue('security.session_registry.storage.file')->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php index 00f0c3a0e1dcc..a324836a4ea25 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php @@ -146,7 +146,7 @@ protected function createEntryPoint($container, $id, $config, $defaultEntryPoint * * @param array $config * - * @return bool Whether a possibly configured RememberMeServices should be set for this listener + * @return bool Whether a possibly configured RememberMeServices should be set for this listener */ protected function isRememberMeAware($config) { @@ -157,6 +157,13 @@ protected function createListener($container, $id, $config, $userProvider) { $listenerId = $this->getListenerId(); $listener = new DefinitionDecorator($listenerId); + + //Check for custom session authentication strategy + $sessionAuthenticationStrategyId = 'security.authentication.session_strategy.'.$id; + if ($container->hasDefinition($sessionAuthenticationStrategyId) || $container->hasAlias($sessionAuthenticationStrategyId)) { + $listener->replaceArgument(2, new Reference($sessionAuthenticationStrategyId)); + } + $listener->replaceArgument(4, $id); $listener->replaceArgument(5, new Reference($this->createAuthenticationSuccessHandler($container, $id, $config))); $listener->replaceArgument(6, new Reference($this->createAuthenticationFailureHandler($container, $id, $config))); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 86234887face6..52b382133db1f 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -71,6 +71,10 @@ public function load(array $configs, ContainerBuilder $container) $container->removeDefinition('security.access.expression_voter'); } + if (isset($config['session_registry_storage'])) { + $this->loadSessionRegistry($config, $container, $loader); + } + // set some global scalars $container->setParameter('security.access.denied_url', $config['access_denied_url']); $container->setParameter('security.authentication.manager.erase_credentials', $config['erase_credentials']); @@ -399,6 +403,10 @@ private function createAuthenticationListeners($container, $id, $firewall, &$aut $hasListeners = false; $defaultEntryPoint = null; + if (isset($firewall['session_concurrency'])) { + $this->createConcurrentSessionAuthenticationStrategy($container, $id, $firewall); + } + foreach ($this->listenerPositions as $position) { foreach ($this->factories[$position] as $factory) { $key = str_replace('-', '_', $factory->getKey()); @@ -713,4 +721,60 @@ private function getExpressionLanguage() return $this->expressionLanguage; } + + private function loadSessionRegistry($config, ContainerBuilder $container, $loader) + { + $container->setAlias('security.session_registry.storage', $config['session_registry_storage']); + $loader->load('security_session_concurrency.xml'); + } + + private function createConcurrentSessionAuthenticationStrategy($container, $id, $config) + { + $authenticationStrategies = array(); + $sessionStrategyId = 'security.authentication.session_strategy.'.$id; + + if (isset($config['session_concurrency']['max_sessions']) && $config['session_concurrency']['max_sessions'] > 0) { + $concurrentSessionControlStrategyId = 'security.authentication.session_strategy.concurrent_control.'.$id; + $container + ->setDefinition( + $concurrentSessionControlStrategyId, new DefinitionDecorator( + 'security.authentication.session_strategy.concurrent_control' + ) + ) + ->replaceArgument(1, $config['session_concurrency']['max_sessions']) + ->replaceArgument(2, $config['session_concurrency']['error_if_maximum_exceeded']) + ; + + $authenticationStrategies[] = new Reference($concurrentSessionControlStrategyId); + } + + $fixationSessionStrategyId = 'security.authentication.session_strategy.fixation.'.$id; + $container->setAlias( + $fixationSessionStrategyId, 'security.authentication.session_strategy' + ); + $authenticationStrategies[] = new Reference($fixationSessionStrategyId); + + if ( + (!isset($config['register_new_sessions']) && $config['stateless'] == false) || (isset($config['register_new_sessions']) && $config['register_new_sessions'] == true) + ) { + $registerSessionStrategyId = 'security.authentication.session_strategy.register.'.$id; + $container->setDefinition( + $registerSessionStrategyId, new DefinitionDecorator( + 'security.authentication.session_strategy.register' + ) + ); + $authenticationStrategies[] = new Reference($registerSessionStrategyId); + } + + $container + ->setDefinition( + $sessionStrategyId, new DefinitionDecorator( + 'security.authentication.session_strategy.composite' + ) + ) + ->replaceArgument(0, $authenticationStrategies) + ; + + return $sessionStrategyId; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index c8d6423adae3f..fa12c1903d434 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -265,6 +265,7 @@ + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml new file mode 100644 index 0000000000000..adb1f524c3d5c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml @@ -0,0 +1,40 @@ + + + + + + Symfony\Component\Security\Http\Session\ConcurrentSessionControlAuthenticationStrategy + Symfony\Component\Security\Http\Session\RegisterSessionAuthenticationStrategy + Symfony\Component\Security\Http\Session\CompositeSessionAuthenticationStrategy + Symfony\Component\Security\Http\Session\SessionRegistry + Symfony\Bundle\SecurityBundle\EventListener\SessionRegistryGarbageCollectorListener + Symfony\Component\Security\Http\Session\FileSessionRegistryStorage + + + + + + + + + + + + + + + + + + + + + + + + %kernel.cache_dir%/session_registry + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionConcurrencyTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionConcurrencyTest.php new file mode 100644 index 0000000000000..1ea1ea4f7747c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionConcurrencyTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional; + +/** + * @author Antonio J. García Lagar + * @group functional + */ +class SessionConcurrencyTest extends WebTestCase +{ + public function testLoginWorksWhenConcurrentSessionsLesserThanMaximun() + { + $client = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'session_concurrency.yml')); + $form = $client->request('GET', '/login')->selectButton('login')->form(); + $form['_username'] = 'antonio'; + $form['_password'] = 'secret'; + $client->submit($form); + + $this->assertRedirect($client->getResponse(), '/profile'); + } + + public function testLoginFailsWhenConcurrentSessionsGreaterOrEqualThanMaximun() + { + $client1 = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'session_concurrency.yml')); + $client1->insulate(); + $form1 = $client1->request('GET', '/login')->selectButton('login')->form(); + $form1['_username'] = 'antonio'; + $form1['_password'] = 'secret'; + $client1->submit($form1); + + $client2 = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'session_concurrency.yml')); + $client2->insulate(); + $form2 = $client2->request('GET', '/login')->selectButton('login')->form(); + $form2['_username'] = 'antonio'; + $form2['_password'] = 'secret'; + $client2->submit($form2); + + $this->assertRedirect($client2->getResponse(), '/login'); + } + + public function testOldSessionExpiresConcurrentSessionsGreaterOrEqualThanMaximun() + { + $client1 = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'session_concurrency_expiration.yml')); + $form1 = $client1->request('GET', '/login')->selectButton('login')->form(); + $form1['_username'] = 'antonio'; + $form1['_password'] = 'secret'; + $client1->submit($form1); + $this->assertRedirect($client1->getResponse(), '/profile'); + + $client2 = $this->createClient(array('test_case' => 'SessionExpiration', 'root_config' => 'session_concurrency_expiration.yml')); + $client2->insulate(); + $form2 = $client2->request('GET', '/login')->selectButton('login')->form(); + $form2['_username'] = 'antonio'; + $form2['_password'] = 'secret'; + $client2->submit($form2); + $this->assertRedirect($client2->getResponse(), '/profile'); + + $client1->request('GET', '/profile'); + $this->assertEquals(200, $client1->getResponse()->getStatusCode()); + $sessionRegistry = $client1->getContainer()->get('security.session_registry'); + $session1Infomation = $sessionRegistry->getSessionInformation($client1->getRequest()->getSession()->getId()); + sleep(1); //Waiting for the session to expire + $this->assertTrue($session1Infomation->isExpired()); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('SessionExpiration'); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency.yml new file mode 100644 index 0000000000000..fe9ebd4175440 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency.yml @@ -0,0 +1,12 @@ +imports: + - { resource: ./config.yml } + +security: + firewalls: + default: + form_login: + check_path: /login_check + default_target_path: /profile + anonymous: ~ + session_concurrency: + max_sessions: 1 diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency_expiration.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency_expiration.yml new file mode 100644 index 0000000000000..195addd8fbb86 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/SessionExpiration/session_concurrency_expiration.yml @@ -0,0 +1,13 @@ +imports: + - { resource: ./session_concurrency.yml } + +security: + firewalls: + default: + form_login: + check_path: /login_check + default_target_path: /profile + anonymous: ~ + session_concurrency: + max_sessions: 1 + error_if_maximum_exceeded: false diff --git a/src/Symfony/Component/Security/Core/Exception/MaxSessionsExceededException.php b/src/Symfony/Component/Security/Core/Exception/MaxSessionsExceededException.php new file mode 100644 index 0000000000000..570b6717d31c6 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/MaxSessionsExceededException.php @@ -0,0 +1,21 @@ + +* +* 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; + +/** +* This exception is thrown when the user has exceeded the allowed number of +* sessions, and the ConcurrentSessionControlStrategy is set to limit the number +* by disallowing opening new sessions. +* +* @author Stefan Paschke +*/ +class MaxSessionsExceededException extends AuthenticationException +{ +} diff --git a/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php b/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php index 608d591170ff2..c99f196de2317 100644 --- a/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/SessionExpirationListener.php @@ -17,6 +17,8 @@ use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Http\Session\SessionRegistry; +use Symfony\Component\Security\Http\Session\SessionInformation; /** * SessionExpirationListener controls idle sessions @@ -29,21 +31,23 @@ class SessionExpirationListener implements ListenerInterface private $httpUtils; private $maxIdleTime; private $targetUrl; + private $sessionRegistry; private $logger; - public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, $maxIdleTime, $targetUrl = null, LoggerInterface $logger = null) + public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, $maxIdleTime, $targetUrl = null, SessionRegistry $sessionRegistry = null, LoggerInterface $logger = null) { $this->tokenStorage = $tokenStorage; $this->httpUtils = $httpUtils; $this->maxIdleTime = $maxIdleTime; $this->targetUrl = $targetUrl; + $this->sessionRegistry = $sessionRegistry; $this->logger = $logger; } /** * Handles expired sessions. * - * @param GetResponseEvent $event A GetResponseEvent instance + * @param GetResponseEvent $event A GetResponseEvent instance * @throws SessionExpiredException If the session has expired */ public function handle(GetResponseEvent $event) @@ -55,7 +59,9 @@ public function handle(GetResponseEvent $event) return; } - if (!$this->hasSessionExpired($session)) { + $sessionInformation = null !== $this->sessionRegistry ? $this->sessionRegistry->getSessionInformation($session->getId()) : null; + + if (!$this->hasSessionExpired($session, $sessionInformation)) { return; } @@ -66,6 +72,10 @@ public function handle(GetResponseEvent $event) $this->tokenStorage->setToken(null); $session->invalidate(); + if (null !== $this->sessionRegistry && null !== $sessionInformation) { + $this->sessionRegistry->removeSessionInformation($sessionInformation->getSessionId()); + } + if (null === $this->targetUrl) { throw new SessionExpiredException(); } @@ -77,15 +87,20 @@ public function handle(GetResponseEvent $event) /** * Checks if the given session has expired. * - * @param SessionInterface $session + * @param SessionInterface $session + * @param SessionInformation $sessionInformation * @return bool */ - private function hasSessionExpired(SessionInterface $session) + private function hasSessionExpired(SessionInterface $session, SessionInformation $sessionInformation = null) { if (time() - $session->getMetadataBag()->getLastUsed() > $this->maxIdleTime) { return true; } + if (null !== $sessionInformation) { + return $sessionInformation->isExpired(); + } + return false; } } diff --git a/src/Symfony/Component/Security/Http/Session/CompositeSessionAuthenticationStrategy.php b/src/Symfony/Component/Security/Http/Session/CompositeSessionAuthenticationStrategy.php new file mode 100644 index 0000000000000..fc3ac355cedeb --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/CompositeSessionAuthenticationStrategy.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * A session authentication strategy that accepts multiple + * SessionAuthenticationStrategyInterface implementations to delegate to. + * + * Each SessionAuthenticationStrategyInterface is invoked in turn. The + * invocations are short circuited if any exception is thrown. + * + * @author Antonio J. García Lagar + */ +class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategyInterface +{ + /** + * @var SessionAuthenticationStrategyInterface[] + */ + private $delegateStrategies = array(); + + public function __construct(array $delegateStrategies) + { + foreach ($delegateStrategies as $strategy) { + $this->addDelegateStrategy($strategy); + } + } + + /** + * {@inheritdoc} + */ + public function onAuthentication(Request $request, TokenInterface $token) + { + foreach ($this->delegateStrategies as $strategy) { + $strategy->onAuthentication($request, $token); + } + } + + private function addDelegateStrategy(SessionAuthenticationStrategyInterface $strategy) + { + $this->delegateStrategies[] = $strategy; + } +} diff --git a/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlAuthenticationStrategy.php b/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlAuthenticationStrategy.php new file mode 100644 index 0000000000000..927a623c14732 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlAuthenticationStrategy.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\MaxSessionsExceededException; + +/** + * Strategy which handles concurrent session-control. + * + * When invoked following an authentication, it will check whether the user in + * question should be allowed to proceed, by comparing the number of sessions + * they already have active with the configured maximumSessions value. + * The SessionRegistry is used as the source of data on authenticated users and + * session data. + * + * @author Stefan Paschke + * @author Antonio J. García Lagar + */ +class ConcurrentSessionControlAuthenticationStrategy implements SessionAuthenticationStrategyInterface +{ + protected $registry; + protected $errorIfMaximumExceeded; + protected $maximumSessions; + + public function __construct(SessionRegistry $registry, $maximumSessions, $errorIfMaximumExceeded = true) + { + $this->registry = $registry; + $this->setMaximumSessions($maximumSessions); + $this->setErrorIfMaximumExceeded($errorIfMaximumExceeded); + } + + /** + * {@inheritdoc} + */ + public function onAuthentication(Request $request, TokenInterface $token) + { + $username = $token->getUsername(); + + $sessions = $this->registry->getAllSessionsInformation($username); + $sessionCount = count($sessions); + $maxSessions = $this->getMaximumSessionsForThisUser($username); + + if ($sessionCount < $maxSessions) { + return; + } + + if ($sessionCount == $maxSessions) { + foreach ($sessions as $sessionInfo) { + /* @var $sessionInfo SessionInformation */ + if ($sessionInfo->getSessionId() == $request->getSession()->getId()) { + return; + } + } + } + + $this->allowedSessionsExceeded($sessions, $maxSessions); + } + + /** + * Sets a boolean flag that causes a RuntimeException to be thrown if the number of sessions is exceeded. + * + * @param bool $errorIfMaximumExceeded + */ + public function setErrorIfMaximumExceeded($errorIfMaximumExceeded) + { + $this->errorIfMaximumExceeded = (bool) $errorIfMaximumExceeded; + } + + /** + * Sets the maxSessions property. + * + * @param $maximumSessions + */ + public function setMaximumSessions($maximumSessions) + { + $this->maximumSessions = (int) $maximumSessions; + } + + /** + * Allows subclasses to customize behavior when too many sessions are detected. + * + * @param array $orderedSessions Array of SessionInformation ordered from + * newest to oldest + * @param int $allowableSessions + */ + protected function allowedSessionsExceeded($orderedSessions, $allowableSessions) + { + if ($this->errorIfMaximumExceeded) { + throw new MaxSessionsExceededException(sprintf('Maximum number of sessions (%s) exceeded', $allowableSessions)); + } + + // Expire oldest session + $orderedSessionsVector = array_values($orderedSessions); + for ($i = $allowableSessions - 1, $countSessions = count($orderedSessionsVector); $i < $countSessions; $i++) { + $this->registry->expireNow($orderedSessionsVector[$i]->getSessionId()); + } + } + + /** + * Method intended for use by subclasses to override the maximum number of sessions that are permitted for a particular authentication. + * + * @param string $username + * @return int + */ + protected function getMaximumSessionsForThisUser($username) + { + return $this->maximumSessions; + } +} diff --git a/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php b/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php new file mode 100644 index 0000000000000..ed72f866f2bb4 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +/** + * FileSessionRegistryStorage stores the session information in the filesystem. + * + * Useful for functional tests. + * + * @author Antonio J. García Lagar + */ +class FileSessionRegistryStorage implements SessionRegistryStorageInterface +{ + private $savePath; + + /** + * @param string $savePath + */ + public function __construct($savePath = null) + { + if (null === $savePath) { + $savePath = sys_get_temp_dir(); + } + + if (!is_dir($savePath)) { + mkdir($savePath, 0777, true); + } + + $this->savePath = $savePath; + } + + /** + * {@inheritdoc} + */ + public function getSessionInformation($sessionId) + { + $filename = $this->getFilePath($sessionId); + if (file_exists($filename)) { + return $this->fileToSessionInfo($filename); + } + } + + /** + * {@inheritdoc} + */ + public function getAllSessionsInformation($username, $includeExpiredSessions = false) + { + $result = array(); + + foreach (glob($this->getFilePath('*')) as $filename) { + $sessionInfo = $this->fileToSessionInfo($filename); + if ($sessionInfo->getUsername() == $username && ($includeExpiredSessions || !$sessionInfo->isExpired())) { + $result[] = $sessionInfo; + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function setSessionInformation(SessionInformation $sessionInformation) + { + file_put_contents($this->getFilePath($sessionInformation->getSessionId()), serialize($sessionInformation)); + } + + /** + * {@inheritdoc} + */ + public function removeSessionInformation($sessionId) + { + $filename = $this->getFilePath($sessionId); + if (file_exists($filename)) { + unlink($filename); + } + } + + /** + * @param string $sessionId + * @return string + */ + private function getFilePath($sessionId) + { + return $this->savePath.'/'.$sessionId.'.mocksessinfo'; + } + + /** + * @param string $filename + * @return SessionInformation + */ + private function fileToSessionInfo($filename) + { + return unserialize(file_get_contents($filename)); + } +} diff --git a/src/Symfony/Component/Security/Http/Session/RegisterSessionAuthenticationStrategy.php b/src/Symfony/Component/Security/Http/Session/RegisterSessionAuthenticationStrategy.php new file mode 100644 index 0000000000000..560d47bcf5020 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/RegisterSessionAuthenticationStrategy.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Strategy used to register a user with the SessionRegistry after + * successful authentication. + * + * @author Antonio J. García Lagar + */ +class RegisterSessionAuthenticationStrategy implements SessionAuthenticationStrategyInterface +{ + /** + * @var SessionRegistry + */ + private $registry; + + public function __construct(SessionRegistry $registry) + { + $this->registry = $registry; + } + + /** + * {@inheritdoc} + */ + public function onAuthentication(Request $request, TokenInterface $token) + { + if ($session = $request->getSession()) { + $this->registry->registerNewSession($token->getUsername(), $session); + } + } +} diff --git a/src/Symfony/Component/Security/Http/Session/SessionInformation.php b/src/Symfony/Component/Security/Http/Session/SessionInformation.php new file mode 100644 index 0000000000000..aba379564da92 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionInformation.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +/** + * SessionInformation. + * + * Represents a record of a session. This is primarily used for concurrent session support. + * + * @author Stefan Paschke + * @author Antonio J. García Lagar + */ +class SessionInformation +{ + private $username; + private $sessionId; + private $expired; + private $lastUsed; + + public function __construct($username, $sessionId, $lastUsed, $expired = null) + { + $this->sessionId = (string) $sessionId; + $this->username = (string) $username; + $this->lastUsed = (int) $lastUsed; + + if (null !== $expired) { + $this->expired = (int) $expired; + } + } + + /** + * Sets the session expiration timestamp. + */ + public function expireAt($expired) + { + $this->expired = (int) $expired; + } + + /** + * Set the last used timestamp. + */ + public function updateLastUsed($lastUsed) + { + $this->lastUsed = (int) $lastUsed; + } + + /** + * Obtain the last used timestamp. + * + * @return int the last request timestamp. + */ + public function getLastUsed() + { + return $this->lastUsed; + } + + /** + * Gets the username. + * + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * Gets the session identifier key. + * + * @return string $sessionId the session identifier key. + */ + public function getSessionId() + { + return $this->sessionId; + } + + /** + * Return whether this session is expired. + * + * @return bool + */ + public function isExpired() + { + return null !== $this->expired && $this->expired < time(); + } +} diff --git a/src/Symfony/Component/Security/Http/Session/SessionRegistry.php b/src/Symfony/Component/Security/Http/Session/SessionRegistry.php new file mode 100644 index 0000000000000..b593378e3a91f --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistry.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +use Symfony\Component\HttpFoundation\Session\SessionInterface; + +/** + * SessionRegistry. + * + * Stores a registry of SessionInformation instances. + * + * @author Stefan Paschke + * @author Antonio J. García Lagar + */ +class SessionRegistry +{ + private $sessionRegistryStorage; + + public function __construct(SessionRegistryStorageInterface $sessionRegistryStorage) + { + $this->sessionRegistryStorage = $sessionRegistryStorage; + } + + /** + * Registers a new session for the given user. + * + * @param string $username the given user. + * @param SessionInterface $session the session. + */ + public function registerNewSession($username, SessionInterface $session) + { + $sessionInformation = new SessionInformation($username, $session->getId(), $session->getMetadataBag()->getLastUsed()); + $this->setSessionInformation($sessionInformation); + } + + /** + * Registers session information for the given user. + * + * @param string $username the given user. + * @param string $sessionId the session identifier key. + * @param int $lastUsed + */ + public function registerNewSessionInformation($username, $sessionId, $lastUsed = null) + { + $lastUsed = $lastUsed ?: time(); + $sessionInformation = new SessionInformation($username, $sessionId, $lastUsed); + $this->setSessionInformation($sessionInformation); + } + + /** + * Returns all the sessions stored for the given user ordered from + * MRU (most recently used) to LRU (least recently used). + * + * @param string $username the given user. + * @param bool $includeExpiredSessions + * @return SessionInformation[] An array of SessionInformation objects. + */ + public function getAllSessionsInformation($username, $includeExpiredSessions = false) + { + return $this->sessionRegistryStorage->getAllSessionsInformation($username, $includeExpiredSessions); + } + + /** + * Obtains the session information for the given sessionId. + * + * @param string $sessionId the session identifier key. + * @return SessionInformation|null $sessionInformation + */ + public function getSessionInformation($sessionId) + { + return $this->sessionRegistryStorage->getSessionInformation($sessionId); + } + + /** + * Updates the last used timestamp for the given session Id. + * + * @param string $sessionId the session identifier key. + * @parma int $lastUsed if null, current timestamp will be set. + */ + public function updateLastUsed($sessionId, $lastUsed = null) + { + if ($sessionInformation = $this->getSessionInformation($sessionId)) { + $lastUsed = $lastUsed ?: time(); + //prevents too many trips to session storage + if ($lastUsed != $sessionInformation->getLastUsed()) { + $sessionInformation->updateLastUsed($lastUsed); + $this->setSessionInformation($sessionInformation); + } + } + } + + /** + * Expires the given sessionId at the give timestamp. + * + * @param string $sessionId the session identifier key. + * @parma int $expired the expiration timestamp. + */ + public function expireAt($sessionId, $expired) + { + if ($sessionInformation = $this->getSessionInformation($sessionId)) { + $sessionInformation->expireAt($expired); + $this->setSessionInformation($sessionInformation); + } + } + + /** + * Expires the given sessionId. + * + * @param string $sessionId the session identifier key. + */ + public function expireNow($sessionId) + { + $this->expireAt($sessionId, time()); + } + + /** + * Deletes the stored information of one session. + * + * @param string $sessionId the session identifier key. + */ + public function removeSessionInformation($sessionId) + { + $this->sessionRegistryStorage->removeSessionInformation($sessionId); + } + + /** + * Sets a SessionInformation object. + * + * @param SessionInformation $sessionInformation + */ + private function setSessionInformation(SessionInformation $sessionInformation) + { + $this->sessionRegistryStorage->setSessionInformation($sessionInformation); + } +} diff --git a/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php b/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php new file mode 100644 index 0000000000000..7156f83e2a205 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Session; + +/** + * SessionRegistryStorageInterface. + * + * Stores the SessionInformation instances maintained in the SessionRegistry. + * + * @author Stefan Paschke + * @author Antonio J. García Lagar + */ +interface SessionRegistryStorageInterface +{ + /** + * Obtains the session information for the specified sessionId. + * + * @param string $sessionId the session identifier key. + * @return SessionInformation|null $sessionInformation + */ + public function getSessionInformation($sessionId); + + /** + * Returns all the sessions stored for the given user ordered from + * MRU (most recently used) to LRU (least recently used). + * + * @param string $username The user identifier. + * @param bool $includeExpiredSessions + * @return SessionInformation[] An array of SessionInformation objects. + */ + public function getAllSessionsInformation($username, $includeExpiredSessions = false); + + /** + * Sets a SessionInformation object. + * + * @param SessionInformation $sessionInformation + */ + public function setSessionInformation(SessionInformation $sessionInformation); + + /** + * Deletes the maintained information of one session. + * + * @param string $sessionId the session identifier key. + */ + public function removeSessionInformation($sessionId); +} diff --git a/src/Symfony/Component/Security/Http/Tests/Session/CompositeSessionAuthenticationStrategyTest.php b/src/Symfony/Component/Security/Http/Tests/Session/CompositeSessionAuthenticationStrategyTest.php new file mode 100644 index 0000000000000..15d7ac3ecf71b --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/CompositeSessionAuthenticationStrategyTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Session; + +use Symfony\Component\Security\Http\Session\CompositeSessionAuthenticationStrategy; + +/** + * @author Antonio J. García Lagar + */ +class CompositeSessionAuthenticationStrategyTest extends \PHPUnit_Framework_TestCase +{ + public function testAuthenticationDelegation() + { + $strategies = array( + $this->getDelegateAuthenticationStrategy(), + $this->getDelegateAuthenticationStrategy(), + $this->getDelegateAuthenticationStrategy(), + ); + + $request = $this->getRequest(); + + $strategy = new CompositeSessionAuthenticationStrategy($strategies); + $strategy->onAuthentication($request, $this->getToken()); + } + + private function getRequest() + { + return $this->getMock('Symfony\Component\HttpFoundation\Request'); + } + + private function getToken() + { + return $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + } + + private function getDelegateAuthenticationStrategy() + { + $strategy = $this->getMock('Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface'); + $strategy->expects($this->once())->method('onAuthentication'); + + return $strategy; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Session/ConcurrentSessionControlAuthenticationStrategyTest.php b/src/Symfony/Component/Security/Http/Tests/Session/ConcurrentSessionControlAuthenticationStrategyTest.php new file mode 100644 index 0000000000000..fd1ad5f7f5c11 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/ConcurrentSessionControlAuthenticationStrategyTest.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Session; + +use Symfony\Component\Security\Http\Session\ConcurrentSessionControlAuthenticationStrategy; + +/** + * @author Antonio J. García Lagar + */ +class ConcurrentSessionControlAuthenticationStrategyTest extends \PHPUnit_Framework_TestCase +{ + public function testSessionsCountLesserThanAllowed() + { + $request = $this->getRequest($this->getSession()); + + $registry = $this->getSessionRegistry(); + $registry->expects($this->once()) + ->method('getAllSessionsInformation') + ->will($this->returnValue(array( + $this->getSessionInformation(), + $this->getSessionInformation(), + ))); + + $strategy = new ConcurrentSessionControlAuthenticationStrategy($registry, 3); + $this->assertNull($strategy->onAuthentication($request, $this->getToken())); + } + + public function testSessionsCountEqualsThanAllowedWithRegisteredSession() + { + $request = $this->getRequest($this->getSession('bar')); + + $registry = $this->getSessionRegistry(); + $registry->expects($this->once()) + ->method('getAllSessionsInformation') + ->will($this->returnValue(array( + $this->getSessionInformation('bar'), + $this->getSessionInformation('foo'), + ))); + + $strategy = new ConcurrentSessionControlAuthenticationStrategy($registry, 2); + $this->assertNull($strategy->onAuthentication($request, $this->getToken())); + } + + /** + * @expectedException Symfony\Component\Security\Core\Exception\MaxSessionsExceededException + * @expectedExceptionMessage Maximum number of sessions (2) exceeded + */ + public function testSessionsCountEqualsThanAllowedWithUnregisteredSession() + { + $request = $this->getRequest($this->getSession('foobar')); + + $registry = $this->getSessionRegistry(); + $registry->expects($this->once()) + ->method('getAllSessionsInformation') + ->will($this->returnValue(array( + $this->getSessionInformation('bar'), + $this->getSessionInformation('foo'), + ))); + + $strategy = new ConcurrentSessionControlAuthenticationStrategy($registry, 2); + $this->assertNull($strategy->onAuthentication($request, $this->getToken())); + } + + public function testExpiresOldSessionsWhenNoExceptionIsThrownIfMaximunExceeded() + { + $request = $this->getRequest($this->getSession('foobar')); + + $registry = $this->getSessionRegistry(); + $registry->expects($this->once()) + ->method('getAllSessionsInformation') + ->will($this->returnValue(array( + $this->getSessionInformation('foo'), + $this->getSessionInformation('bar'), + $this->getSessionInformation('barfoo'), + ))); + + $registry->expects($this->at(1)) + ->method('expireNow') + ->with($this->equalTo('bar')); + $registry->expects($this->at(2)) + ->method('expireNow') + ->with($this->equalTo('barfoo')); + + $strategy = new ConcurrentSessionControlAuthenticationStrategy($registry, 2, false); + $this->assertNull($strategy->onAuthentication($request, $this->getToken())); + } + + private function getSession($sessionId = null) + { + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + if (null !== $sessionId) { + $session->expects($this->any())->method('getId')->will($this->returnValue($sessionId)); + } + + return $session; + } + + private function getRequest($session = null) + { + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + + if (null !== $session) { + $request->expects($this->any())->method('getSession')->will($this->returnValue($session)); + } + + return $request; + } + + private function getToken() + { + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $token->expects($this->any())->method('getUsername')->will($this->returnValue('foo')); + + return $token; + } + + private function getSessionRegistry() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\Session\SessionRegistry') + ->disableOriginalConstructor() + ->getMock(); + } + + private function getSessionInformation($sessionId = null, $username = null) + { + $sessionInfo = $this->getMockBuilder('Symfony\Component\Security\Http\Session\SessionInformation') + ->disableOriginalConstructor() + ->getMock(); + + if (null !== $sessionId) { + $sessionInfo->expects($this->any())->method('getSessionId')->will($this->returnValue($sessionId)); + } + + if (null !== $username) { + $sessionInfo->expects($this->any())->method('getUsername')->will($this->returnValue($username)); + } + + return $sessionInfo; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Session/RegisterSessionAuthenticationStrategyTest.php b/src/Symfony/Component/Security/Http/Tests/Session/RegisterSessionAuthenticationStrategyTest.php new file mode 100644 index 0000000000000..4ff158ca007ad --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/RegisterSessionAuthenticationStrategyTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Session; + +use Symfony\Component\Security\Http\Session\RegisterSessionAuthenticationStrategy; + +/** + * @author Antonio J. García Lagar + */ +class RegisterSessionAuthenticationStrategyTest extends \PHPUnit_Framework_TestCase +{ + public function testRegisterSession() + { + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $request = $this->getRequest($session); + + $registry = $this->getSessionRegistry(); + $registry->expects($this->once())->method('registerNewSession')->with($this->equalTo('foo'), $this->identicalTo($session)); + + $strategy = new RegisterSessionAuthenticationStrategy($registry); + $strategy->onAuthentication($request, $this->getToken()); + } + + private function getRequest($session = null) + { + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + + if (null !== $session) { + $request->expects($this->any())->method('getSession')->will($this->returnValue($session)); + } + + return $request; + } + + private function getToken() + { + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $token->expects($this->any())->method('getUsername')->will($this->returnValue('foo')); + + return $token; + } + + private function getSessionRegistry() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\Session\SessionRegistry') + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Session/SessionInformationTest.php b/src/Symfony/Component/Security/Http/Tests/Session/SessionInformationTest.php new file mode 100644 index 0000000000000..1bf9c7dd2aa5a --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/SessionInformationTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Session; + +use Symfony\Component\Security\Http\Session\SessionInformation; + +/** + * @author Antonio J. García Lagar + */ +class SessionInformationTest extends \PHPUnit_Framework_TestCase +{ + public function testExpiration() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertFalse($sessionInfo->isExpired()); + $sessionInfo->expireAt(time() - 1); + + $this->assertTrue($sessionInfo->isExpired()); + } + + public function testUpdateLastUsed() + { + $now = time() - 5; + $sessionInfo = $this->getSessionInformation(); + $this->assertNotEquals($now, $sessionInfo->getLastUsed()); + $sessionInfo->updateLastUsed($now); + $this->assertEquals($now, $sessionInfo->getLastUsed()); + } + + public function testGetLastUsed() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertLessThan(microtime(true), $sessionInfo->getLastUsed()); + } + + public function testGetSessionId() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertEquals('bar', $sessionInfo->getSessionId()); + } + + public function testGetUsername() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertEquals('foo', $sessionInfo->getUsername()); + } + + /** + * @return \Symfony\Component\Security\Http\Session\SessionInformation + */ + private function getSessionInformation() + { + return new SessionInformation('foo', 'bar', time()); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php b/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php new file mode 100644 index 0000000000000..f5dca5904c2d8 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Session; + +use Symfony\Component\Security\Http\Session\SessionRegistry; + +/** + * @author Antonio J. García Lagar + */ +class SessionRegistryTest extends \PHPUnit_Framework_TestCase +{ + public function testGetAllSessionsInformation() + { + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('getAllSessionsInformation')->with('foo', true); + $registry = $this->getSessionRegistry($storage); + $registry->getAllSessionsInformation('foo', true); + } + + public function testGetSessionInformation() + { + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('getSessionInformation')->with('foobar'); + $registry = $this->getSessionRegistry($storage); + $registry->getSessionInformation('foobar'); + } + + public function testUpdateLastUsed() + { + $sessionInformation = $this->getSessionInformation(); + $sessionInformation->expects($this->once())->method('updateLastUsed'); + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->any())->method('getSessionInformation')->with('foobar')->will($this->returnValue($sessionInformation)); + $storage->expects($this->once())->method('setSessionInformation')->with($sessionInformation); + $registry = $this->getSessionRegistry($storage); + $registry->updateLastUsed('foobar', time() - 5); + } + + public function testExpireAt() + { + $sessionInformation = $this->getSessionInformation(); + $sessionInformation->expects($this->once())->method('expireAt'); + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->any())->method('getSessionInformation')->with('foobar')->will($this->returnValue($sessionInformation)); + $storage->expects($this->once())->method('setSessionInformation')->with($this->identicalTo($sessionInformation)); + $registry = $this->getSessionRegistry($storage); + $registry->expireAt('foobar', time() - 5); + } + + public function testExpireNow() + { + $sessionInformation = $this->getSessionInformation(); + $sessionInformation->expects($this->once())->method('expireAt'); + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->any())->method('getSessionInformation')->with('foobar')->will($this->returnValue($sessionInformation)); + $storage->expects($this->once())->method('setSessionInformation')->with($this->identicalTo($sessionInformation)); + $registry = $this->getSessionRegistry($storage); + $registry->expireNow('foobar'); + } + + public function testRegisterNewSessionInformation() + { + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('setSessionInformation')->with($this->isInstanceOf('Symfony\Component\Security\Http\Session\SessionInformation')); + $registry = $this->getSessionRegistry($storage); + $registry->registerNewSessionInformation('foo', 'bar', time()); + } + + public function testRegisterNewSession() + { + $metadata = $this->getMock('Symfony\Component\HttpFoundation\Session\Storage\MetadataBag'); + $metadata->expects($this->once())->method('getLastUsed')->will($this->returnValue(time() - 5)); + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session->expects($this->any())->method('getId')->will($this->returnValue('foobar')); + $session->expects($this->any())->method('getMetadataBag')->will($this->returnValue($metadata)); + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('setSessionInformation')->with($this->isInstanceOf('Symfony\Component\Security\Http\Session\SessionInformation')); + $registry = $this->getSessionRegistry($storage); + $registry->registerNewSession('foo', $session); + } + + public function testRemoveSessionInformation() + { + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('removeSessionInformation')->with('foobar'); + $registry = $this->getSessionRegistry($storage); + $registry->removeSessionInformation('foobar'); + } + + private function getSessionRegistryStorage() + { + return $this->getMock('Symfony\Component\Security\Http\Session\SessionRegistryStorageInterface'); + } + + private function getSessionInformation() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\Session\SessionInformation') + ->disableOriginalConstructor() + ->getMock(); + } + + private function getSessionRegistry($storage) + { + return new SessionRegistry($storage, 'Symfony\Component\Security\Http\Session\SessionInformation'); + } +} From b2ad97edfe6f2aad56668e6ff5345485caed1685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 2 Dec 2014 10:35:30 +0100 Subject: [PATCH 4/5] Adds garbage collection capability --- .../Http/Session/FileSessionRegistryStorage.php | 14 ++++++++++++++ .../Security/Http/Session/SessionRegistry.php | 12 ++++++++++++ .../Session/SessionRegistryStorageInterface.php | 8 ++++++++ .../Http/Tests/Session/SessionRegistryTest.php | 8 ++++++++ 4 files changed, 42 insertions(+) diff --git a/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php b/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php index ed72f866f2bb4..ea7b13ff9c626 100644 --- a/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php +++ b/src/Symfony/Component/Security/Http/Session/FileSessionRegistryStorage.php @@ -85,6 +85,20 @@ public function removeSessionInformation($sessionId) } } + /** + * {@inheritdoc} + */ + public function collectGarbage($maxLifetime) + { + $now = time(); + foreach (glob($this->getFilePath('*')) as $filename) { + $sessionInfo = $this->fileToSessionInfo($filename); + if ($now - $sessionInfo->getLastUsed() > $maxLifetime) { + $this->removeSessionInformation($sessionInfo->getSessionId()); + } + } + } + /** * @param string $sessionId * @return string diff --git a/src/Symfony/Component/Security/Http/Session/SessionRegistry.php b/src/Symfony/Component/Security/Http/Session/SessionRegistry.php index b593378e3a91f..a851d4f4b491d 100644 --- a/src/Symfony/Component/Security/Http/Session/SessionRegistry.php +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistry.php @@ -132,6 +132,18 @@ public function removeSessionInformation($sessionId) $this->sessionRegistryStorage->removeSessionInformation($sessionId); } + /** + * Removes sessions information which last used timestamp is older + * than the given lifetime + * + * @param int $maxLifetime + */ + public function collectGarbage($maxLifetime = null) + { + $maxLifetime = $maxLifetime ?: ini_get('session.gc_maxlifetime'); + $this->sessionRegistryStorage->collectGarbage($maxLifetime); + } + /** * Sets a SessionInformation object. * diff --git a/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php b/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php index 7156f83e2a205..68897df583452 100644 --- a/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php @@ -52,4 +52,12 @@ public function setSessionInformation(SessionInformation $sessionInformation); * @param string $sessionId the session identifier key. */ public function removeSessionInformation($sessionId); + + /** + * Removes sessions information which last used timestamp is older + * than the given lifetime + * + * @param int $maxLifetime + */ + public function collectGarbage($maxLifetime); } diff --git a/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php b/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php index f5dca5904c2d8..a05aa7e10a89f 100644 --- a/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.php @@ -96,6 +96,14 @@ public function testRemoveSessionInformation() $registry->removeSessionInformation('foobar'); } + public function testCollectGarbage() + { + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('collectGarbage')->with(ini_get('session.gc_maxlifetime')); + $registry = $this->getSessionRegistry($storage); + $registry->collectGarbage(); + } + private function getSessionRegistryStorage() { return $this->getMock('Symfony\Component\Security\Http\Session\SessionRegistryStorageInterface'); From 3d707e7eab7c273176b25de8a688d8dc8e3355ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20J=2E=20Garc=C3=ADa=20Lagar?= Date: Tue, 2 Dec 2014 10:35:40 +0100 Subject: [PATCH 5/5] Event listener to collect session registry garbage on kernel terminate --- ...essionRegistryGarbageCollectorListener.php | 55 +++++++++++++++++++ .../config/security_session_concurrency.xml | 6 ++ 2 files changed, 61 insertions(+) create mode 100644 src/Symfony/Bundle/SecurityBundle/EventListener/SessionRegistryGarbageCollectorListener.php diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/SessionRegistryGarbageCollectorListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/SessionRegistryGarbageCollectorListener.php new file mode 100644 index 0000000000000..e8e3750dc0b8e --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/SessionRegistryGarbageCollectorListener.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\PostResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Http\Session\SessionRegistry; + +/** + * Clear session information from registry for idle sessions + * + * @author Antonio J. García Lagar + */ +class SessionRegistryGarbageCollectorListener implements EventSubscriberInterface +{ + /** + * @var SessionRegistry + */ + private $sessionRegistry; + private $maxLifetime; + private $probability; + private $divisor; + + public function __construct(SessionRegistry $sessionRegistry, $maxLifetime = 1, $probability = null, $divisor = null) + { + $this->sessionRegistry = $sessionRegistry; + $this->maxLifetime = $maxLifetime ?: ini_get('session.gc_maxlifetime'); + $this->probability = $probability ?: ini_get('session.gc_probability'); + $this->divisor = $divisor ?: ini_get('session.gc_divisor'); + } + + public function onKernelTerminate(PostResponseEvent $event) + { + if ($this->probability / $this->divisor > lcg_value() || true) { + $this->sessionRegistry->collectGarbage($this->maxLifetime); + } + } + + public static function getSubscribedEvents() + { + return array( + KernelEvents::TERMINATE => array(array('onKernelTerminate')), + ); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml index adb1f524c3d5c..7b410605e9318 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml @@ -36,5 +36,11 @@ %kernel.cache_dir%/session_registry + + + + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy