diff --git a/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/Schema.php b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/Schema.php new file mode 100644 index 0000000000000..355e662b29fba --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/Schema.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Security\SessionRegistry; + +use Doctrine\DBAL\Schema\Schema as BaseSchema; + +/** + * The schema used for the ACL system. + * + * @author Stefan Paschke + */ +final class Schema extends BaseSchema +{ + /** + * Constructor + * + * @param array $options the names for tables + */ + public function __construct(array $options) + { + parent::__construct(); + + $this->addSessionInformationTable($options); + } + + /** + * Adds the session_information table to the schema + */ + protected function addSessionInformationTable(array $options) + { + $table = $this->createTable($options['session_information_table_name']); + $table->addColumn('session_id', 'string'); + $table->addColumn('username', 'string'); + $table->addColumn('expired', 'datetime', array('unsigned' => true, 'notnull' => false)); + $table->addColumn('last_request', 'datetime', array('unsigned' => true, 'notnull' => false)); + $table->setPrimaryKey(array('session_id')); + $table->addUniqueIndex(array('session_id')); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionInformation.php b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionInformation.php new file mode 100644 index 0000000000000..f9eb8eb0ed957 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionInformation.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Security\SessionRegistry; + +use Symfony\Component\Security\Http\Session\SessionInformation as BaseSessionInformation; + +/** + * SessionInformation. + * + * Allows to persist SessionInformation using Doctrine DBAL. + * + * @author Stefan Paschke + */ +class SessionInformation extends BaseSessionInformation +{ + public function __construct($sessionId, $username, \DateTime $lastRequest = null, \DateTime $expired = null) + { + parent::__construct($sessionId, $username); + + if (null !== $lastRequest) { + $this->setLastRequest($lastRequest); + } + + if (null !== $expired) { + $this->setExpired($expired); + } + } + + public function getExpired() + { + return parent::getExpired(); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionRegistryStorage.php b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionRegistryStorage.php new file mode 100644 index 0000000000000..742713a1789fc --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionRegistryStorage.php @@ -0,0 +1,113 @@ +connection = $connection; + $this->options = $options; + } + + /** + * not implemented + */ + public function getUsers() + { + throw new \BadMethodCallException("Not implemented."); + } + + /** + * Obtains the maintained information for one session. + * + * @param string $sessionId the session identifier key. + * @return SessionInformation a SessionInformation object. + */ + public function getSessionInformation($sessionId) + { + $statement = $this->connection->executeQuery( + 'SELECT * FROM '.$this->options['session_information_table_name'].' WHERE session_id = :session_id', + array('session_id' => $sessionId) + ); + + $data = $statement->fetch(\PDO::FETCH_ASSOC); + + return $data ? $this->instantiateSessionInformationFromResultSet($data) : null; + } + + /** + * Obtains the maintained information for one user. + * + * @param string $username The user identifier. + * @param boolean $includeExpiredSessions. + * @return array An array of SessionInformation objects. + */ + public function getSessionInformations($username, $includeExpiredSessions = false) + { + $sessionInformations = array(); + + $statement = $this->connection->executeQuery( + 'SELECT * + FROM '.$this->options['session_information_table_name'].' + WHERE username = :username'.($includeExpiredSessions ? '' : ' AND expired IS NULL ').' + ORDER BY last_request DESC', + array('username' => $username) + ); + + while ($data = $statement->fetch(\PDO::FETCH_ASSOC)) + { + $sessionInformations[] = $this->instantiateSessionInformationFromResultSet($data); + } + + return $sessionInformations; + } + + /** + * Adds information for one session. + * + * @param string $sessionId the session identifier key. + * @param SessionInformation a SessionInformation object. + */ + public function setSessionInformation(SessionInformation $sessionInformation) + { + $statement = $this->connection->prepare( + 'INSERT INTO '.$this->options['session_information_table_name'].' + (session_id, username, last_request, expired) VALUES(:session_id, :username, :last_request, :expired) + ON DUPLICATE KEY + UPDATE username=:username, last_request=:last_request, expired=:expired'); + + $statement->bindValue('session_id', $sessionInformation->getSessionId()); + $statement->bindValue('username', $sessionInformation->getUsername()); + $statement->bindValue('last_request', $sessionInformation->getLastRequest(), 'datetime'); + $statement->bindValue('expired', $sessionInformation->getExpired(), 'datetime'); + $statement->execute(); + } + + /** + * Deletes the maintained information of one session. + * + * @param string $sessionId the session identifier key. + */ + public function removeSessionInformation($sessionId) + { + $this->connection->delete($this->options['session_information_table_name'], array('session_id' => $sessionId)); + } + + private function instantiateSessionInformationFromResultSet($data) + { + return new $this->options['session_information_class_name']( + $data['session_id'], + $data['username'], + (null == $data['last_request']) ? null : new \DateTime($data['last_request']), + (null == $data['expired']) ? null : new \DateTime($data['expired']) + ); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Command/InitConcurrentSessionsCommand.php b/src/Symfony/Bundle/SecurityBundle/Command/InitConcurrentSessionsCommand.php new file mode 100644 index 0000000000000..ea20546d78bda --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/InitConcurrentSessionsCommand.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; +use Symfony\Bridge\Doctrine\Security\SessionRegistry\Schema; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Installs the database schema required by the concurrent session Doctrine implementation + * + * @author Stefan Paschke + */ +class InitConcurrentSessionsCommand extends ContainerAwareCommand +{ + /** + * @see Command + */ + protected function configure() + { + parent::configure(); + + $this + ->setName('init:concurrent-session') + ->setDescription('Executes the SQL needed to generate the database schema required by the concurrent sessions feature.') + ->setHelp(<<init:concurrent-session command executes the SQL needed to +generate the database schema required by the concurrent session Doctrine implementation: + +./app/console init:concurrent-session + +You can also output the SQL instead of executing it: + +./app/console init:concurrent-session --dump-sql +EOT + ); + } + + /** + * @see Command + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $connection = $this->getContainer()->get('security.session_registry.dbal.connection'); + $sm = $connection->getSchemaManager(); + $tableNames = $sm->listTableNames(); + $tables = array( + 'session_information_table_name' => $this->getContainer()->getParameter('security.session_registry.dbal.session_information_table_name'), + ); + + foreach ($tables as $table) { + if (in_array($table, $tableNames, true)) { + $output->writeln(sprintf('The table "%s" already exists. Aborting.', $table)); + + return; + } + } + + $schema = new Schema($tables); + foreach ($schema->toSql($connection->getDatabasePlatform()) as $sql) { + $connection->exec($sql); + } + + $output->writeln('concurrent session tables have been initialized successfully.'); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index d5f60c117d16b..5d07045bd1583 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -79,10 +79,31 @@ public function getConfigTreeBuilder() $this->addFirewallsSection($rootNode, $this->factories); $this->addAccessControlSection($rootNode); $this->addRoleHierarchySection($rootNode); + $this->addSessionRegistrySection($rootNode); return $tb; } + private function addSessionRegistrySection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('session_registry') + ->children() + ->scalarNode('connection')->end() + ->arrayNode('tables') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('session_information')->defaultValue('cs_session_information')->end() + ->end() + ->end() + ->scalarNode('session_registry_storage')->end() + ->end() + ->end() + ->end() + ; + } + private function addAclSection(ArrayNodeDefinition $rootNode) { $rootNode @@ -248,6 +269,13 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end() ->end() ->end() + ->arrayNode('session_concurrency') + ->canBeUnset() + ->children() + ->scalarNode('max_sessions')->defaultNull()->end() + ->scalarNode('expiration_url')->defaultValue('/')->end() + ->end() + ->end() ; $abstractFactoryKeys = array(); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php index b0cfadd5468f0..4cdf5a2db4026 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php @@ -38,13 +38,13 @@ abstract class AbstractFactory implements SecurityFactoryInterface 'failure_forward' => false, ); - public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId) + public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId, $sessionStrategy) { // authentication provider $authProviderId = $this->createAuthProvider($container, $id, $config, $userProviderId); // authentication listener - $listenerId = $this->createListener($container, $id, $config, $userProviderId); + $listenerId = $this->createListener($container, $id, $config, $userProviderId, $sessionStrategy); // add remember-me aware tag if requested if ($this->isRememberMeAware($config)) { @@ -144,10 +144,11 @@ protected function isRememberMeAware($config) return $config['remember_me']; } - protected function createListener($container, $id, $config, $userProvider) + protected function createListener($container, $id, $config, $userProvider, $sessionStrategy) { $listenerId = $this->getListenerId(); $listener = new DefinitionDecorator($listenerId); + $listener->replaceArgument(2, $sessionStrategy); $listener->replaceArgument(4, $id); $listener->replaceArgument(5, array_intersect_key($config, $this->options)); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php index ce06bba7d281a..ba18399ccb515 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php @@ -71,9 +71,9 @@ protected function createAuthProvider(ContainerBuilder $container, $id, $config, return $provider; } - protected function createListener($container, $id, $config, $userProvider) + protected function createListener($container, $id, $config, $userProvider, $sessionStrategy) { - $listenerId = parent::createListener($container, $id, $config, $userProvider); + $listenerId = parent::createListener($container, $id, $config, $userProvider, $sessionStrategy); if (isset($config['csrf_provider'])) { $container diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php index c87f68c38ff6b..62fe22ca3115a 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicFactory.php @@ -24,7 +24,7 @@ */ class HttpBasicFactory implements SecurityFactoryInterface { - public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint, $sessionStrategy) { $provider = 'security.authentication.provider.dao.'.$id; $container diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php index 3a49b5dcdc583..5c1e9c334463c 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpDigestFactory.php @@ -24,7 +24,7 @@ */ class HttpDigestFactory implements SecurityFactoryInterface { - public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint, $sessionStrategy) { $provider = 'security.authentication.provider.dao.'.$id; $container diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php index 08c4a33d283ef..c5dd83b683b4b 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php @@ -29,7 +29,7 @@ class RememberMeFactory implements SecurityFactoryInterface 'remember_me_parameter' => '_remember_me', ); - public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint, $sessionStrategy) { // authentication provider $authProviderId = 'security.authentication.provider.rememberme.'.$id; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php index 5ef4c00fff0b3..8a3bd7c78b3af 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/SecurityFactoryInterface.php @@ -21,7 +21,7 @@ */ interface SecurityFactoryInterface { - function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint); + function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint, $sessionStrategy); function getPosition(); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php index d24942b6722ac..cefe700d8ca51 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/X509Factory.php @@ -25,7 +25,7 @@ */ class X509Factory implements SecurityFactoryInterface { - public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint, $sessionStrategy) { $provider = 'security.authentication.provider.pre_authenticated.'.$id; $container diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 71d8d364518a8..677b48efae34b 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -62,6 +62,10 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('templating_twig.xml'); $loader->load('collectors.xml'); + if (isset($config['session_registry'])) { + $this->sessionRegistryLoad($config['session_registry'], $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']); @@ -155,6 +159,30 @@ private function configureDbalAclProvider(array $config, ContainerBuilder $conta $container->setParameter('security.acl.dbal.sid_table_name', $config['tables']['security_identity']); } + private function sessionRegistryLoad($config, ContainerBuilder $container, $loader) + { + $loader->load('security_session_registry.xml'); + + if (isset($config['session_registry_storage'])) { + $container->setAlias('security.authentication.session_registry_storage', $config['session_registry_storage']); + + return; + } + + $this->configureDbalSessionRegistryStorage($config, $container, $loader); + } + + private function configureDbalSessionRegistryStorage($config, ContainerBuilder $container, $loader) + { + $loader->load('security_session_registry_dbal.xml'); + + if (isset($config['connection'])) { + $container->setAlias('security.session_registry.dbal.connection', sprintf('doctrine.dbal.%s_connection', $config['connection'])); + } + + $container->setParameter('security.session_registry.dbal.session_information_table_name', $config['tables']['session_information']); + } + /** * Loads the web configuration. * @@ -350,6 +378,11 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a $listeners[] = new Reference($this->createSwitchUserListener($container, $id, $firewall['switch_user'], $defaultProvider)); } + // Concurrent session listener + if (isset($firewall['session_concurrency'])) { + $listeners[] = new Reference($this->createConcurrentSessionListener($container, $id, $firewall['session_concurrency'])); + } + // Determine default entry point if (isset($firewall['entry_point'])) { $defaultEntryPoint = $firewall['entry_point']; @@ -380,6 +413,16 @@ private function createAuthenticationListeners($container, $id, $firewall, &$aut $hasListeners = false; $defaultEntryPoint = null; + if (isset($firewall['session_concurrency']['max_sessions'])) { + $sessionStrategyId = 'security.authentication.concurrent_session_strategy.'.$id; + $container->setDefinition($sessionStrategyId, new DefinitionDecorator('security.authentication.concurrent_session_strategy')) + ->replaceArgument(1, $firewall['session_concurrency']['max_sessions']); + + $sessionStrategy = new Reference($sessionStrategyId); + } else { + $sessionStrategy = $container->get('security.authentication.session_strategy'); + } + foreach ($this->listenerPositions as $position) { foreach ($this->factories[$position] as $factory) { $key = str_replace('-', '_', $factory->getKey()); @@ -387,7 +430,7 @@ private function createAuthenticationListeners($container, $id, $firewall, &$aut if (isset($firewall[$key])) { $userProvider = isset($firewall[$key]['provider']) ? $this->getUserProviderId($firewall[$key]['provider']) : $defaultProvider; - list($provider, $listenerId, $defaultEntryPoint) = $factory->create($container, $id, $firewall[$key], $userProvider, $defaultEntryPoint); + list($provider, $listenerId, $defaultEntryPoint) = $factory->create($container, $id, $firewall[$key], $userProvider, $defaultEntryPoint, $sessionStrategy); $listeners[] = new Reference($listenerId); $authenticationProviders[] = $provider; @@ -577,6 +620,16 @@ private function createSwitchUserListener($container, $id, $config, $defaultProv return $switchUserListenerId; } + private function createConcurrentSessionListener($container, $id, $config) + { + $concurrentSessionListenerId = 'security.authentication.concurrentsession_listener.'.$id; + $listener = $container->setDefinition($concurrentSessionListenerId, new DefinitionDecorator('security.authentication.concurrentsession_listener')); + $listener->replaceArgument(3, $config['expiration_url']); + $listener->addMethodCall('addHandler', array(new Reference('security.logout.handler.session'))); + + return $concurrentSessionListenerId; + } + private function createRequestMatcher($container, $path = null, $host = null, $methods = null, $ip = null, array $attributes = array()) { $serialized = serialize(array($path, $host, $methods, $ip, $attributes)); diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 6f088bfe2df6d..603700cce0570 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -24,6 +24,8 @@ Symfony\Component\Security\Http\Firewall\SwitchUserListener + Symfony\Component\Security\Http\Firewall\ConcurrentSessionListener + Symfony\Component\Security\Http\Firewall\LogoutListener Symfony\Component\Security\Http\Logout\SessionLogoutHandler Symfony\Component\Security\Http\Logout\CookieClearingLogoutHandler @@ -174,6 +176,16 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry.xml new file mode 100644 index 0000000000000..93eaa73eec193 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry.xml @@ -0,0 +1,25 @@ + + + + + + Symfony\Component\Security\Http\Session\ConcurrentSessionControlStrategy + Symfony\Component\Security\Http\Session\SessionRegistry + Symfony\Component\Security\Http\Session\SessionInformation + + + + + + + %security.authentication.session_strategy.strategy% + + + + + %security.authentication.session_information.class% + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry_dbal.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry_dbal.xml new file mode 100644 index 0000000000000..42c8d03a8f9b1 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry_dbal.xml @@ -0,0 +1,25 @@ + + + + + + Symfony\Bridge\Doctrine\Security\SessionRegistry\SessionRegistryStorage + Symfony\Bridge\Doctrine\Security\SessionRegistry\SessionInformation + + + + + + + + + + %security.session_registry.dbal.session_information_table_name% + %security.authentication.session_information.class% + + + + + 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..6d3759a7a0c06 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Exception/MaxSessionsExceededException.php @@ -0,0 +1,23 @@ + + * + * 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. + * (By default, the ConcurrentSessionControlStrategy will expire the user's oldest existing session) + * + * @author Stefan Paschke + */ +class MaxSessionsExceededException extends AuthenticationException +{ +} diff --git a/src/Symfony/Component/Security/Http/Firewall/ConcurrentSessionListener.php b/src/Symfony/Component/Security/Http/Firewall/ConcurrentSessionListener.php new file mode 100644 index 0000000000000..95dbbf795eb15 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Firewall/ConcurrentSessionListener.php @@ -0,0 +1,97 @@ + + * + * 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\Security\Core\SecurityContextInterface; +use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface; +use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; +use Symfony\Component\Security\Http\Session\SessionRegistry; +use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpKernel\Log\LoggerInterface; + +class ConcurrentSessionListener implements ListenerInterface +{ + private $securityContext; + private $httpUtils; + private $sessionRegistry; + private $targetUrl; + private $logger; + private $handlers; + private $successHandler; + + public function __construct(SecurityContextInterface $securityContext, HttpUtils $httpUtils, SessionRegistry $sessionRegistry, $targetUrl = '/', LogoutSuccessHandlerInterface $successHandler = null, LoggerInterface $logger = null) + { + $this->securityContext = $securityContext; + $this->httpUtils = $httpUtils; + $this->sessionRegistry = $sessionRegistry; + $this->targetUrl = $targetUrl; + $this->successHandler = $successHandler; + $this->logger = $logger; + $this->handlers = array(); + } + + /** + * Adds a logout handler + * + * @param LogoutHandlerInterface $handler + */ + public function addHandler(LogoutHandlerInterface $handler) + { + $this->handlers[] = $handler; + } + + /** + * Handles the number of simultaneous sessions for a single user. + * + * @param GetResponseEvent $event A GetResponseEvent instance + */ + public function handle(GetResponseEvent $event) + { + $request = $event->getRequest(); + + $session = $request->hasSession() ? $request->getSession() : null; + + if (null === $session || null === $token = $this->securityContext->getToken()) { + return; + } else { + if ($sessionInformation = $this->sessionRegistry->getSessionInformation($session->getId())) { + if ($sessionInformation->isExpired()) { + if (null !== $this->successHandler) { + $response = $this->successHandler->onLogoutSuccess($request); + + if (!$response instanceof Response) { + throw new \RuntimeException('Logout Success Handler did not return a Response.'); + } + } else { + $response = $this->httpUtils->createRedirectResponse($request, $this->targetUrl); + } + + foreach ($this->handlers as $handler) { + $handler->logout($request, $response, $token); + } + + $this->securityContext->setToken(null); + + $event->setResponse($response); + } else { + $sessionInformation->refreshLastRequest(); + $this->sessionRegistry->setSessionInformation($sessionInformation); + } + } else { + // sessionInformation was lost, try to recover by recreating it + $this->sessionRegistry->registerNewSession($session->getId(), $token->getUser()); + } + } + } +} diff --git a/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlStrategy.php b/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlStrategy.php new file mode 100644 index 0000000000000..36909330248f3 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlStrategy.php @@ -0,0 +1,138 @@ + + * + * 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; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * ConcurrentSessionControlStrategy. + * + * Strategy which handles concurrent session-control, in addition to the functionality provided by the base class. + * 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 + */ +class ConcurrentSessionControlStrategy extends SessionAuthenticationStrategy +{ + protected $registry; + protected $alwaysCreateSession; + protected $exceptionIfMaximumExceeded = false; + protected $maximumSessions; + + public function __construct(SessionRegistry $registry, $maximumSessions, $sessionAuthenticationStrategy) + { + parent::__construct($sessionAuthenticationStrategy); + $this->registry = $registry; + $this->setMaximumSessions($maximumSessions); + } + + /** + * {@inheritDoc} + */ + public function onAuthentication(Request $request, TokenInterface $token) + { + $user = $token->getUser(); + $originalSessionId = $request->getSession()->getId(); + + parent::onAuthentication($request, $token); + + if ($originalSessionId != $request->getSession()->getId()) { + $this->onSessionChange($originalSessionId, $request->getSession()->getId()); + } + + $sessions = $this->registry->getAllSessions($user); + $maxSessions = $this->getMaximumSessionsForThisUser($user); + + if (count($sessions) >= $maxSessions && $this->alwaysCreateSession !== true) { + if ($this->exceptionIfMaximumExceeded) { + throw new MaxSessionsExceededException(sprintf('Maximum of sessions (%s) exceeded', $maxSessions)); + } + + $this->allowableSessionsExceeded($sessions, $maxSessions, $this->registry); + } + + $this->registry->registerNewSession($request->getSession()->getID(), $user); + } + + /** + * Sets a boolean flag that allows to bypass allowableSessionsExceeded(). + * + * param boolean $alwaysCreateSession + */ + public function setAlwaysCreateSession($alwaysCreateSession) + { + $this->alwaysCreateSession = $alwaysCreateSession; + } + + /** + * Sets a boolean flag that causes a RuntimeException to be thrown if the number of sessions is exceeded. + * + * @param boolean $exceptionIfMaximumExceeded + */ + public function setExceptionIfMaximumExceeded($exceptionIfMaximumExceeded) + { + $this->exceptionIfMaximumExceeded = $exceptionIfMaximumExceeded; + } + + /** + * Sets the maxSessions property. + * + * @param $maximumSessions + */ + public function setMaximumSessions($maximumSessions) + { + $this->maximumSessions = $maximumSessions; + } + + /** + * Allows subclasses to customise behaviour when too many sessions are detected. + * + * @param array $sessions + * @param integer $allowableSessions + * @param SessionRegistry $registry + */ + protected function allowableSessionsExceeded($sessions, $allowableSessions, SessionRegistry $registry) + { + // remove oldest sessions from registry + for ($i = $allowableSessions - 1; $i < count($sessions); $i++) { + $sessions[$i]->expireNow(); + $registry->setSessionInformation($sessions[$i]); + } + } + + /** + * Method intended for use by subclasses to override the maximum number of sessions that are permitted for a particular authentication. + * + * @param UserInterface $user + * @return integer + */ + protected function getMaximumSessionsForThisUser(UserInterface $user) + { + return $this->maximumSessions; + } + + /** + * Called when the session has been changed and the old attributes have been migrated to the new session. + * + * @param string $originalSessionId + * @param string $newSessionId + * @param TokenInterface $token + */ + protected function onSessionChange($originalSessionId, $newSessionId) + { + $this->registry->removeSessionInformation($originalSessionId); + } +} 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..81b47f3974e8d --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionInformation.php @@ -0,0 +1,116 @@ + + * + * 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 + */ +class SessionInformation +{ + protected $sessionId; + protected $username; + protected $expired; + protected $lastRequest; + + public function __construct($sessionId, $username) + { + $this->setSessionId($sessionId); + $this->setUsername($username); + } + + /** + * Sets the session informations expired date to the current date and time. + * + */ + public function expireNow() + { + $this->setExpired(new \DateTime()); + } + + /** + * Obtain the last request date. + * + * @return DateTime the last request date and time. + */ + public function getLastRequest() + { + return $this->lastRequest; + } + + /** + * Obtains the username. + * + * @return string + */ + public function getUsername() + { + return $this->username; + } + + /** + * Obtain the session identifier. + * + * @return string $sessionId the session identifier key. + */ + public function getSessionId() + { + return $this->sessionId; + } + + /** + * Return wether this session is expired. + * + * @return boolean + */ + public function isExpired() + { + return $this->getExpired() && $this->getExpired()->getTimestamp() < time(); + } + + /** + * Set the last request date to the current date and time. + * + */ + public function refreshLastRequest() + { + $this->lastRequest = new \DateTime(); + } + + protected function getExpired() + { + return $this->expired; + } + + protected function setExpired(\DateTime $expired) + { + $this->expired = $expired; + } + + protected function setLastRequest(\DateTime $lastRequest) + { + $this->lastRequest = $lastRequest; + } + + private function setSessionId($sessionId) + { + $this->sessionId = $sessionId; + } + + private function setUsername($username) + { + $this->username = $username; + } +} 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..ab34177c31a93 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistry.php @@ -0,0 +1,106 @@ + + * + * 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\User\UserInterface; + +/** + * SessionRegistry. + * + * Maintains a registry of SessionInformation instances. + * + * @author Stefan Paschke + */ +class SessionRegistry +{ + protected $sessionRegistryStorage; + protected $sessionInformationClass; + + public function __construct(SessionRegistryStorageInterface $sessionRegistryStorage, $sessionInformationClass) + { + $this->sessionRegistryStorage = $sessionRegistryStorage; + $this->sessionInformationClass = $sessionInformationClass; + } + + /** + * Obtains all the users for which session information is stored. + * + * @return array An array of UserInterface objects. + */ + public function getAllUsers() + { + return $this->sessionRegistryStorage->getUsers(); + } + + /** + * Obtains all the known sessions for the specified user. + * + * @param UserInterface $user the specified user. + * @param boolean $includeExpiredSessions. + * @return array An array of SessionInformation objects. + */ + public function getAllSessions(UserInterface $user, $includeExpiredSessions = false) + { + return $this->sessionRegistryStorage->getSessionInformations($user->getUsername(), $includeExpiredSessions); + } + + /** + * {@inheritDoc} + */ + public function getSessionInformation($sessionId) + { + return $this->sessionRegistryStorage->getSessionInformation($sessionId); + } + + /** + * {@inheritDoc} + */ + public function setSessionInformation(SessionInformation $sessionInformation) + { + $this->sessionRegistryStorage->setSessionInformation($sessionInformation); + } + + /** + * Updates the given sessionId so its last request time is equal to the present date and time. + * + * @param string $sessionId the session identifier key. + */ + public function refreshLastRequest($sessionId) + { + if ($sessionInformation = $this->getSessionInformation($sessionId)) { + $sessionInformation->refreshLastRequest(); + $this->setSessionInformation($sessionInformation); + } + } + + /** + * Registers a new session for the specified user. + * + * @param string $sessionId the session identifier key. + * @param UserInterface $user the specified user. + */ + public function registerNewSession($sessionId, UserInterface $user) + { + $sessionInformation = new $this->sessionInformationClass($sessionId, $user->getUsername()); + $sessionInformation->refreshLastRequest(); + + $this->setSessionInformation($sessionInformation); + } + + /** + * {@inheritDoc} + */ + public function removeSessionInformation($sessionId) + { + $this->sessionRegistryStorage->removeSessionInformation($sessionId); + } +} 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..6c43c7d864a9c --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistryStorageInterface.php @@ -0,0 +1,60 @@ + + * + * 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 + */ +interface SessionRegistryStorageInterface +{ + /** + * Obtains all the users for which session information is stored. + * + * @return array An array of UserInterface objects. + */ + function getUsers(); + + /** + * Obtains the session information for the specified sessionId. + * + * @param string $sessionId the session identifier key. + * @return SessionInformation $sessionInformation + */ + function getSessionInformation($sessionId); + + /** + * Obtains the maintained information for one user. + * + * @param string $username The user identifier. + * @param boolean $includeExpiredSessions. + * @return array An array of SessionInformation objects. + */ + function getSessionInformations($username, $includeExpiredSessions); + + /** + * Sets a SessionInformation object. + * + * @param SessionInformation $sessionInformation + */ + function setSessionInformation(SessionInformation $sessionInformation); + + /** + * Deletes the maintained information of one session. + * + * @param string $sessionId the session identifier key. + */ + function removeSessionInformation($sessionId); +}