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..0fb2c14a2ddba --- /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 + * @author Antonio J. García Lagar + */ +final class Schema extends BaseSchema +{ + /** + * @param string $table The name of the table to create. + */ + public function __construct($table) + { + parent::__construct(); + + $this->addSessionInformationTable($table); + } + + /** + * Adds the session_information table to the schema + * + * @param string $table The name of the table to create. + */ + private function addSessionInformationTable($table) + { + $table = $this->createTable($table); + $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')); + } +} 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..1d60a91b21fc0 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Security/SessionRegistry/SessionRegistryStorage.php @@ -0,0 +1,190 @@ + + * @author Antonio J. García Lagar + */ +class SessionRegistryStorage implements SessionRegistryStorageInterface +{ + private $connection; + private $table; + + /** + * @param Connection $connection The DB connection + * @param string $table The table name to store session information + */ + public function __construct(Connection $connection, $table) + { + $this->connection = $connection; + $this->table = $table; + } + + /** + * Gets the stored information for the given session. + * + * @param string $sessionId the session identifier key. + * @return SessionInformation a SessionInformation object. + */ + public function getSessionInformation($sessionId) + { + $statement = $this->connection->executeQuery( + 'SELECT * FROM '.$this->table.' WHERE session_id = :session_id', + array('session_id' => $sessionId) + ); + + $data = $statement->fetch(\PDO::FETCH_ASSOC); + + return $data ? $this->instantiateSessionInformationFromResultSet($data) : null; + } + + /** + * Gets the stored sessions information for the given username. + * + * @param string $username The user identifier. + * @param bool $includeExpiredSessions If true, expired sessions information is included. + * @return SessionInformations[] An array of SessionInformation objects. + */ + public function getSessionInformations($username, $includeExpiredSessions = false) + { + $sessionInformations = array(); + + $statement = $this->connection->executeQuery( + 'SELECT * + FROM '.$this->table.' + 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); + } + + $statement->closeCursor(); + + return $sessionInformations; + } + + /** + * Adds information for one session. + * + * @param SessionInformation a SessionInformation object. + */ + public function setSessionInformation(SessionInformation $sessionInformation) + { + $mergeSql = $this->getMergeSql(); + + if (null !== $mergeSql) { + $this->connection->executeQuery( + $mergeSql, + array( + 'session_id' => $sessionInformation->getSessionId(), + 'username' => $sessionInformation->getUsername(), + 'last_request' => $sessionInformation->getLastRequest(), + 'expired' => $sessionInformation->getExpired(), + ), + array( + 'last_request' => 'datetime', + 'expired' => 'datetime', + ) + ); + + return true; + } + + $updateStmt = $this->connection->prepare( + "UPDATE $this->table SET username=:username, last_request=:last_request, expired=:expired WHERE session_id = :session_id" + ); + $updateStmt->bindValue('session_id', $sessionInformation->getSessionId()); + $updateStmt->bindValue('username', $sessionInformation->getUsername()); + $updateStmt->bindValue('last_request', $sessionInformation->getLastRequest(), 'datetime'); + $updateStmt->bindValue('expired', $sessionInformation->getExpired(), 'datetime'); + $updateStmt->execute(); + + // When MERGE is not supported, like in Postgres, we have to use this approach that can result in + // duplicate key errors when the same sessioninfo is written simultaneously. We can just catch such an + // error and re-execute the update. This is similar to a serializable transaction with retry logic + // on serialization failures but without the overhead and without possible false positives due to + // longer gap locking. + if (!$updateStmt->rowCount()) { + try { + $this->connection->insert( + $this->table, + array( + 'session_id' => $sessionInformation->getSessionId(), + 'username' => $sessionInformation->getUsername(), + 'last_request' => $sessionInformation->getLastRequest(), + 'expired' => $sessionInformation->getExpired(), + ), + array( + 'last_request' => 'datetime', + 'expired' => 'datetime', + ) + ); + } catch (DBALException $e) { + // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys + if ($e->getPrevious() instanceof \PDOException && 0 === strpos($e->getPrevious()->getCode(), '23')) { + $updateStmt->execute(); + } else { + throw $e; + } + } + } + } + + /** + * Deletes stored information of one session. + * + * @param string $sessionId the session identifier key. + */ + public function removeSessionInformation($sessionId) + { + $this->connection->delete($this->table, array('session_id' => $sessionId)); + } + + private function instantiateSessionInformationFromResultSet($data) + { + return new SessionInformation( + $data['session_id'], + $data['username'], + null === $data['last_request'] ? null : new \DateTime($data['last_request']), + null === $data['expired'] ? null : new \DateTime($data['expired']) + ); + } + + /** + * Returns a merge/upsert (i.e. insert or update) SQL query when supported by the database. + * + * @return string|null The SQL string or null when not supported + */ + private function getMergeSql() + { + switch ($this->connection->getDatabasePlatform()->getName()) { + case 'mysql': + return "INSERT INTO $this->table (session_id, username, last_request, expired) VALUES (:session_id, :username, :last_request, :expired) ". + "ON DUPLICATE KEY UPDATE username = VALUES(username), last_request = VALUES(last_request), expired = VALUES(expired)"; + case 'oracle': + // DUAL is Oracle specific dummy table + return "MERGE INTO $this->table USING DUAL ON (session_id= :session_id) ". + "WHEN NOT MATCHED THEN INSERT (session_id, username, last_request, expired) VALUES (:session_id, :username, :last_request, :expired) ". + "WHEN MATCHED THEN UPDATE SET username = :username, last_request = :last_request, expired = :expired"; + case 'mssql': + if ($this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLServer2008Platform || $this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLServer2012Platform) { + // MERGE is only available since SQL Server 2008 and must be terminated by semicolon + // It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx + return "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON (session_id = :session_id) ". + "WHEN NOT MATCHED THEN INSERT (session_id, username, last_request, expired) VALUES (:session_id, :username, :last_request, :expired) ". + "WHEN MATCHED THEN UPDATE SET username = :username, last_request = :last_request, expired = :expired;"; + } + case 'sqlite': + return "INSERT OR REPLACE INTO $this->table (session_id, username, last_request, expired) VALUES (:session_id, :username, :last_request, :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..fa6203290350c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Command/InitConcurrentSessionsCommand.php @@ -0,0 +1,67 @@ + + * + * 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 + * @author Antonio J. García Lagar + */ +class InitConcurrentSessionsCommand extends ContainerAwareCommand +{ + /** + * @see Command + */ + protected function 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 +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(); + $table = $this->getContainer()->getParameter('security.session_registry.dbal.session_information_table_name'); + + if (in_array($table, $tableNames, true)) { + $output->writeln(sprintf('The table "%s" already exists. Aborting.', $table)); + + return; + } + + $schema = new Schema($table); + foreach ($schema->toSql($connection->getDatabasePlatform()) as $sql) { + $connection->exec($sql); + } + + $output->writeln('concurrent session table have been initialized successfully.'); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index 3f25c3da03e4a..be6bac44b5d7c 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -70,8 +70,7 @@ public function getConfigTreeBuilder() ->booleanNode('allow_if_equal_granted_denied')->defaultTrue()->end() ->end() ->end() - ->end() - ; + ->end(); $this->addAclSection($rootNode); $this->addEncodersSection($rootNode); @@ -79,10 +78,25 @@ 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() + ->scalarNode('table')->defaultValue('cs_session_information')->end() + ->scalarNode('session_registry_storage')->end() + ->end() + ->end() + ->end(); + } + private function addAclSection(ArrayNodeDefinition $rootNode) { $rootNode @@ -119,8 +133,7 @@ private function addAclSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() - ->end() - ; + ->end(); } private function addRoleHierarchySection(ArrayNodeDefinition $rootNode) @@ -140,8 +153,7 @@ private function addRoleHierarchySection(ArrayNodeDefinition $rootNode) ->prototype('scalar')->end() ->end() ->end() - ->end() - ; + ->end(); } private function addAccessControlSection(ArrayNodeDefinition $rootNode) @@ -180,8 +192,7 @@ private function addAccessControlSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() - ->end() - ; + ->end(); } private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $factories) @@ -195,8 +206,7 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->disallowNewKeysInSubsequentConfigs() ->useAttributeAsKey('name') ->prototype('array') - ->children() - ; + ->children(); $firewallNodeBuilder ->scalarNode('pattern')->end() @@ -288,15 +298,21 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto ->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end() ->end() ->end() - ; + ->arrayNode('session_concurrency') + ->canBeUnset() + ->children() + ->integerNode('max_sessions')->defaultValue(0)->min(0)->end() + ->booleanNode('error_if_maximum_exceeded')->defaultTrue()->end() + ->scalarNode('expiration_url')->defaultValue('/')->end() + ->end() + ->end(); $abstractFactoryKeys = array(); foreach ($factories as $factoriesAtPosition) { foreach ($factoriesAtPosition as $factory) { $name = str_replace('-', '_', $factory->getKey()); $factoryNode = $firewallNodeBuilder->arrayNode($name) - ->canBeUnset() - ; + ->canBeUnset(); if ($factory instanceof AbstractFactory) { $abstractFactoryKeys[] = $name; @@ -326,8 +342,7 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto return $firewall; }) - ->end() - ; + ->end(); } private function addProvidersSection(ArrayNodeDefinition $rootNode) @@ -351,8 +366,7 @@ private function addProvidersSection(ArrayNodeDefinition $rootNode) ->isRequired() ->requiresAtLeastOneElement() ->useAttributeAsKey('name') - ->prototype('array') - ; + ->prototype('array'); $providerNodeBuilder ->children() @@ -369,8 +383,7 @@ private function addProvidersSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() - ->end() - ; + ->end(); foreach ($this->userProviderFactories as $factory) { $name = str_replace('-', '_', $factory->getKey()); @@ -387,8 +400,7 @@ private function addProvidersSection(ArrayNodeDefinition $rootNode) ->validate() ->ifTrue(function ($v) {return count($v) === 0;}) ->thenInvalid('You must set a provider definition for the provider.') - ->end() - ; + ->end(); } private function addEncodersSection(ArrayNodeDefinition $rootNode) @@ -427,7 +439,6 @@ private function addEncodersSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() - ->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..5d73d47d12833 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/AbstractFactory.php @@ -59,8 +59,7 @@ public function create(ContainerBuilder $container, $id, $config, $userProviderI if ($this->isRememberMeAware($config)) { $container ->getDefinition($listenerId) - ->addTag('security.remember_me_aware', array('id' => $id, 'provider' => $userProviderId)) - ; + ->addTag('security.remember_me_aware', array('id' => $id, 'provider' => $userProviderId)); } // create entry point if applicable (optional) @@ -77,8 +76,7 @@ public function addConfiguration(NodeDefinition $node) ->scalarNode('provider')->end() ->booleanNode('remember_me')->defaultTrue()->end() ->scalarNode('success_handler')->end() - ->scalarNode('failure_handler')->end() - ; + ->scalarNode('failure_handler')->end(); foreach (array_merge($this->options, $this->defaultSuccessHandlerOptions, $this->defaultFailureHandlerOptions) as $name => $default) { if (is_bool($default)) { @@ -157,6 +155,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 1f20fc7596414..1c0d5f4e3a0fb 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'])) { + $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']); @@ -79,8 +83,7 @@ public function load(array $configs, ContainerBuilder $container) ->getDefinition('security.access.decision_manager') ->addArgument($config['access_decision_manager']['strategy']) ->addArgument($config['access_decision_manager']['allow_if_all_abstain']) - ->addArgument($config['access_decision_manager']['allow_if_equal_granted_denied']) - ; + ->addArgument($config['access_decision_manager']['allow_if_equal_granted_denied']); $container->setParameter('security.access.always_authenticate_before_granting', $config['always_authenticate_before_granting']); $container->setParameter('security.authentication.hide_user_not_found', $config['hide_user_not_found']); @@ -147,8 +150,7 @@ private function configureDbalAclProvider(array $config, ContainerBuilder $conta 'connection' => $config['connection'], 'event' => 'postGenerateSchema', 'lazy' => true, - )) - ; + )); $container->getDefinition('security.acl.cache.doctrine')->addArgument($config['cache']['prefix']); @@ -159,6 +161,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_concurrency.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['table']); + } + /** * Loads the web configuration. * @@ -236,8 +262,7 @@ private function createFirewalls($config, ContainerBuilder $container) $context = $container->setDefinition($contextId, new DefinitionDecorator('security.firewall.context')); $context ->replaceArgument(0, $listeners) - ->replaceArgument(1, $exceptionListener) - ; + ->replaceArgument(1, $exceptionListener); $map[$contextId] = $matcher; } $mapDef->replaceArgument(1, $map); @@ -248,8 +273,7 @@ private function createFirewalls($config, ContainerBuilder $container) }, array_values(array_unique($authenticationProviders))); $container ->getDefinition('security.authentication.manager') - ->replaceArgument(0, $authenticationProviders) - ; + ->replaceArgument(0, $authenticationProviders); } private function createFirewall(ContainerBuilder $container, $id, $firewall, &$authenticationProviders, $providerIds) @@ -304,14 +328,7 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a )); $listeners[] = new Reference($listenerId); - // add logout success handler - if (isset($firewall['logout']['success_handler'])) { - $logoutSuccessHandlerId = $firewall['logout']['success_handler']; - } else { - $logoutSuccessHandlerId = 'security.logout.success_handler.'.$id; - $logoutSuccessHandler = $container->setDefinition($logoutSuccessHandlerId, new DefinitionDecorator('security.logout.success_handler')); - $logoutSuccessHandler->replaceArgument(1, $firewall['logout']['target']); - } + $logoutSuccessHandlerId = $this->createLogoutSuccessHandler($container, $id, $firewall['logout']); $listener->replaceArgument(2, new Reference($logoutSuccessHandlerId)); // add CSRF provider @@ -319,24 +336,7 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a $listener->addArgument(new Reference($firewall['logout']['csrf_token_generator'])); } - // add session logout handler - if (true === $firewall['logout']['invalidate_session'] && false === $firewall['stateless']) { - $listener->addMethodCall('addHandler', array(new Reference('security.logout.handler.session'))); - } - - // add cookie logout handler - if (count($firewall['logout']['delete_cookies']) > 0) { - $cookieHandlerId = 'security.logout.handler.cookie_clearing.'.$id; - $cookieHandler = $container->setDefinition($cookieHandlerId, new DefinitionDecorator('security.logout.handler.cookie_clearing')); - $cookieHandler->addArgument($firewall['logout']['delete_cookies']); - - $listener->addMethodCall('addHandler', array(new Reference($cookieHandlerId))); - } - - // add custom handlers - foreach ($firewall['logout']['handlers'] as $handlerId) { - $listener->addMethodCall('addHandler', array(new Reference($handlerId))); - } + $this->addLogoutHandlers($container, $listenerId, $id, $firewall); // register with LogoutUrlHelper $container @@ -347,8 +347,7 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a $firewall['logout']['csrf_token_id'], $firewall['logout']['csrf_parameter'], isset($firewall['logout']['csrf_token_generator']) ? new Reference($firewall['logout']['csrf_token_generator']) : null, - )) - ; + )); } // Authentication listeners @@ -364,6 +363,11 @@ private function createFirewall(ContainerBuilder $container, $id, $firewall, &$a // Access listener $listeners[] = new Reference('security.access_listener'); + // Expired session listener + if (isset($firewall['session_concurrency'])) { + $listeners[] = new Reference($this->createExpiredSessionListener($container, $id, $firewall)); + } + // Determine default entry point if (isset($firewall['entry_point'])) { $defaultEntryPoint = $firewall['entry_point']; @@ -394,6 +398,10 @@ private function createAuthenticationListeners($container, $id, $firewall, &$aut $hasListeners = false; $defaultEntryPoint = null; + if (isset($firewall['session_concurrency'])) { + $this->createConcurrentSessionAuthenticationStrategy($container, $id, $firewall['session_concurrency']); + } + foreach ($this->listenerPositions as $position) { foreach ($this->factories[$position] as $factory) { $key = str_replace('-', '_', $factory->getKey()); @@ -415,16 +423,14 @@ private function createAuthenticationListeners($container, $id, $firewall, &$aut $listenerId = 'security.authentication.listener.anonymous.'.$id; $container ->setDefinition($listenerId, new DefinitionDecorator('security.authentication.listener.anonymous')) - ->replaceArgument(1, $firewall['anonymous']['key']) - ; + ->replaceArgument(1, $firewall['anonymous']['key']); $listeners[] = new Reference($listenerId); $providerId = 'security.authentication.provider.anonymous.'.$id; $container ->setDefinition($providerId, new DefinitionDecorator('security.authentication.provider.anonymous')) - ->replaceArgument(0, $firewall['anonymous']['key']) - ; + ->replaceArgument(0, $firewall['anonymous']['key']); $authenticationProviders[] = $providerId; $hasListeners = true; @@ -446,8 +452,7 @@ private function createEncoders($encoders, ContainerBuilder $container) $container ->getDefinition('security.encoder_factory.generic') - ->setArguments(array($encoderMap)) - ; + ->setArguments(array($encoderMap)); } private function createEncoder($config, ContainerBuilder $container) @@ -542,8 +547,7 @@ private function createUserDaoProvider($name, $provider, ContainerBuilder $conta $container ->setDefinition($name, new DefinitionDecorator('security.user.provider.chain')) - ->addArgument($providers) - ; + ->addArgument($providers); return $name; } @@ -553,8 +557,7 @@ private function createUserDaoProvider($name, $provider, ContainerBuilder $conta $container ->setDefinition($name, new DefinitionDecorator('security.user.provider.entity')) ->addArgument($provider['entity']['class']) - ->addArgument($provider['entity']['property']) - ; + ->addArgument($provider['entity']['property']); return $name; } @@ -566,8 +569,7 @@ private function createUserDaoProvider($name, $provider, ContainerBuilder $conta $container ->setDefinition($userId, new DefinitionDecorator('security.user.provider.in_memory.user')) - ->setArguments(array($username, (string) $user['password'], $user['roles'])) - ; + ->setArguments(array($username, (string) $user['password'], $user['roles'])); $definition->addMethodCall('createUser', array(new Reference($userId))); } @@ -627,6 +629,22 @@ private function createExpression($container, $expression) return $this->expressions[$id] = new Reference($id); } + private function createExpiredSessionListener($container, $id, $config) + { + $expiredSessionListenerId = 'security.authentication.expiredsession_listener.'.$id; + $listener = $container->setDefinition($expiredSessionListenerId, new DefinitionDecorator('security.authentication.expiredsession_listener')); + + $listener->replaceArgument(3, $config['session_concurrency']['expiration_url']); + + if (isset($config['logout'])) { + $logoutSuccessHandlerId = $this->createLogoutSuccessHandler($container, $id, $config); + $listener->replaceArgument(4, new Reference($logoutSuccessHandlerId)); + $this->addLogoutHandlers($container, $expiredSessionListenerId, $id, $config); + } + + return $expiredSessionListenerId; + } + private function createRequestMatcher($container, $path = null, $host = null, $methods = array(), $ip = null, array $attributes = array()) { $serialized = serialize(array($path, $host, $methods, $ip, $attributes)); @@ -649,8 +667,7 @@ private function createRequestMatcher($container, $path = null, $host = null, $m $container ->register($id, '%security.matcher.class%') ->setPublic(false) - ->setArguments($arguments) - ; + ->setArguments($arguments); return $this->requestMatchers[$id] = new Reference($id); } @@ -697,4 +714,100 @@ private function getExpressionLanguage() return $this->expressionLanguage; } + + private function createConcurrentSessionAuthenticationStrategy($container, $id, $config) + { + $sessionStrategyId = 'security.authentication.session_strategy.'.$id; + + if (isset($config['max_sessions']) && $config['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['max_sessions']) + ->replaceArgument(2, $config['error_if_maximum_exceeded']); + + $fixationSessionStrategyId = 'security.authentication.session_strategy.fixation.'.$id; + $container->setAlias( + $fixationSessionStrategyId, + 'security.authentication.session_strategy' + ); + + $registerSessionStrategyId = 'security.authentication.session_strategy.register.'.$id; + $container->setDefinition( + $registerSessionStrategyId, + new DefinitionDecorator( + 'security.authentication.session_strategy.register' + ) + ); + + $container->setDefinition( + $sessionStrategyId, + new DefinitionDecorator( + 'security.authentication.session_strategy.composite' + ) + )->replaceArgument( + 0, + array( + new Reference($concurrentSessionControlStrategyId), + new Reference($fixationSessionStrategyId), + new Reference($registerSessionStrategyId), + ) + ); + } else { + $container->setAlias( + $sessionStrategyId, + 'security.authentication.session_strategy' + ); + } + + return $sessionStrategyId; + } + + private function createLogoutSuccessHandler($container, $id, $config) + { + // add logout success handler + if (isset($config['success_handler'])) { + $logoutSuccessHandlerId = $config['success_handler']; + } else { + $logoutSuccessHandlerId = 'security.logout.success_handler.'.$id; + if (!$container->hasDefinition($logoutSuccessHandlerId)) { + $logoutSuccessHandler = $container->setDefinition($logoutSuccessHandlerId, new DefinitionDecorator('security.logout.success_handler')); + $logoutSuccessHandler->replaceArgument(1, $config['target']); + } + } + + return $logoutSuccessHandlerId; + } + + private function addLogoutHandlers($container, $listenerId, $id, $config) + { + $listener = $container->findDefinition($listenerId); + + // add session registry logout handler + if (isset($config['session_concurrency'])) { + $listener->addMethodCall('addHandler', array(new Reference('security.logout.handler.session_registry'))); + } + + // add session logout handler + if (true === $config['logout']['invalidate_session'] && false === $config['stateless']) { + $listener->addMethodCall('addHandler', array(new Reference('security.logout.handler.session'))); + } + + // add cookie logout handler + if (count($config['logout']['delete_cookies']) > 0) { + $cookieHandlerId = 'security.logout.handler.cookie_clearing.'.$id; + $cookieHandler = $container->setDefinition($cookieHandlerId, new DefinitionDecorator('security.logout.handler.cookie_clearing')); + $cookieHandler->addArgument($config['logout']['delete_cookies']); + + $listener->addMethodCall('addHandler', array(new Reference($cookieHandlerId))); + } + + // add custom handlers + foreach ($config['logout']['handlers'] as $handlerId) { + $listener->addMethodCall('addHandler', array(new Reference($handlerId))); + } + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 7d3ba1a6f322c..7b28ffa6cff09 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\ExpiredSessionListener + Symfony\Component\Security\Http\Firewall\LogoutListener Symfony\Component\Security\Http\Logout\SessionLogoutHandler Symfony\Component\Security\Http\Logout\CookieClearingLogoutHandler @@ -257,6 +259,16 @@ + + + + + + + + + + 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..8855be55ac393 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_concurrency.xml @@ -0,0 +1,41 @@ + + + + + + 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\Component\Security\Http\Session\SessionInformation + Symfony\Component\Security\Http\Logout\SessionRegistryLogoutHandler + + + + + + + + + + + + + + + + + + + + %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..bcee305f19d61 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_session_registry_dbal.xml @@ -0,0 +1,20 @@ + + + + + + Symfony\Bridge\Doctrine\Security\SessionRegistry\SessionRegistryStorage + + + + + + + + %security.session_registry.dbal.session_information_table_name% + + + + 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..37c59d3225b6f --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SessionConcurrencyTest.php @@ -0,0 +1,87 @@ + + * + * 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' => 'StandardFormLogin', 'root_config' => 'session_concurrency.yml')); + $client->insulate(); + $form = $client->request('GET', '/login')->selectButton('login')->form(); + $form['_username'] = 'johannes'; + $form['_password'] = 'test'; + $client->submit($form); + + $this->assertRedirect($client->getResponse(), '/profile'); + } + + public function testLoginFailsWhenConcurrentSessionsGreaterOrEqualThanMaximun() + { + $client1 = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => 'session_concurrency.yml')); + $client1->insulate(); + $form1 = $client1->request('GET', '/login')->selectButton('login')->form(); + $form1['_username'] = 'johannes'; + $form1['_password'] = 'test'; + $client1->submit($form1); + + $client2 = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => 'session_concurrency.yml')); + $client2->insulate(); + $form2 = $client2->request('GET', '/login')->selectButton('login')->form(); + $form2['_username'] = 'johannes'; + $form2['_password'] = 'test'; + $client2->submit($form2); + + $this->assertRedirect($client2->getResponse(), '/login'); + } + + public function testOldSessionExpiresConcurrentSessionsGreaterOrEqualThanMaximun() + { + $client1 = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => 'session_concurrency_expiration.yml')); + $client1->insulate(); + $form1 = $client1->request('GET', '/login')->selectButton('login')->form(); + $form1['_username'] = 'johannes'; + $form1['_password'] = 'test'; + $client1->submit($form1); + $this->assertRedirect($client1->getResponse(), '/profile'); + + $client2 = $this->createClient(array('test_case' => 'StandardFormLogin', 'root_config' => 'session_concurrency_expiration.yml')); + $client2->insulate(); + $form2 = $client2->request('GET', '/login')->selectButton('login')->form(); + $form2['_username'] = 'johannes'; + $form2['_password'] = 'test'; + $client2->submit($form2); + + $this->assertRedirect($client2->getResponse(), '/profile'); + + $client1->request('GET', '/profile'); + $this->assertRedirect($client1->getResponse(), '/expired'); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('StandardFormLogin'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('StandardFormLogin'); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/session_concurrency.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/session_concurrency.yml new file mode 100644 index 0000000000000..edef156195f20 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/session_concurrency.yml @@ -0,0 +1,21 @@ +imports: + - { resource: ./config.yml } + +services: + my_session_registry_storage: + class: Symfony\Component\Security\Http\Session\MockFileSessionRegistryStorage + arguments: + - %kernel.cache_dir%/session_registry + +security: + firewalls: + default: + form_login: + check_path: /login_check + default_target_path: /profile + anonymous: ~ + session_concurrency: + max_sessions: 1 + + session_registry: + session_registry_storage: my_session_registry_storage diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/session_concurrency_expiration.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/session_concurrency_expiration.yml new file mode 100644 index 0000000000000..f47e7ea1dd14b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/StandardFormLogin/session_concurrency_expiration.yml @@ -0,0 +1,14 @@ +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 + expiration_url: /expired 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/ExpiredSessionListener.php b/src/Symfony/Component/Security/Http/Firewall/ExpiredSessionListener.php new file mode 100644 index 0000000000000..2be5d619bfe09 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Firewall/ExpiredSessionListener.php @@ -0,0 +1,104 @@ + + * + * 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\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\Log\LoggerInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +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; + +/** + * @author Stefan Paschke + * @author Antonio J. García Lagar + */ +class ExpiredSessionListener implements ListenerInterface +{ + private $tokenStorage; + private $httpUtils; + private $sessionRegistry; + private $targetUrl; + private $successHandler; + private $logger; + private $handlers; + + public function __construct(TokenStorageInterface $tokenStorage, HttpUtils $httpUtils, SessionRegistry $sessionRegistry, $targetUrl = '/', LogoutSuccessHandlerInterface $successHandler = null, LoggerInterface $logger = null) + { + $this->tokenStorage = $tokenStorage; + $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 + * @throws \RuntimeException if the successHandler exists and do not return a response + */ + public function handle(GetResponseEvent $event) + { + $request = $event->getRequest(); + + $session = $request->getSession(); + + if (null === $session || null === $token = $this->tokenStorage->getToken()) { + return; + } + + if ($sessionInformation = $this->sessionRegistry->getSessionInformation($session->getId())) { + if ($sessionInformation->isExpired()) { + if (null !== $this->logger) { + $this->logger->info(sprintf("Logging out expired session for username '%s'", $token->getUsername())); + } + + 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->tokenStorage->setToken(null); + + $event->setResponse($response); + } else { + $this->sessionRegistry->refreshLastRequest($session->getId()); + } + } else { + // sessionInformation was lost, try to recover by recreating it + $this->sessionRegistry->registerNewSession($session->getId(), $token->getUsername()); + } + } +} diff --git a/src/Symfony/Component/Security/Http/Logout/SessionRegistryLogoutHandler.php b/src/Symfony/Component/Security/Http/Logout/SessionRegistryLogoutHandler.php new file mode 100644 index 0000000000000..760e67ec42f63 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Logout/SessionRegistryLogoutHandler.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Logout; + +use Symfony\Component\Security\Http\Session\SessionRegistry; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Request; + +/** + * Handler for removing session information from the session registry. + * + * @author Antonio J. García Lagar + */ +class SessionRegistryLogoutHandler +{ + private $registry; + + /** + * Constructor + * + * @param SessionRegistry $registry + */ + public function __construct(SessionRegistry $registry) + { + $this->registry = $registry; + } + + /** + * Remove current session information from the session registry + * + * @param Request $request + * @param Response $response + * @param TokenInterface $token + */ + public function logout(Request $request, Response $response, TokenInterface $token) + { + if (null !== $session = $request->getSession()) { + $this->registry->removeSessionInformation($session->getId()); + } + } +} 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..cd3592c7c2ee0 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/ConcurrentSessionControlAuthenticationStrategy.php @@ -0,0 +1,121 @@ + + * + * 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->getAllSessions($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, $this->registry); + } + + /** + * 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 + * @param SessionRegistry $registry + */ + protected function allowedSessionsExceeded($orderedSessions, $allowableSessions, SessionRegistry $registry) + { + 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++) { + $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/MockFileSessionRegistryStorage.php b/src/Symfony/Component/Security/Http/Session/MockFileSessionRegistryStorage.php new file mode 100644 index 0000000000000..6bc0e8faa50b1 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/MockFileSessionRegistryStorage.php @@ -0,0 +1,94 @@ + + * + * 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; + +/** + * MockFileSessionRegistryStorage mocks the session registry for functional tests. + * + * @author Antonio J. García Lagar + */ +class MockFileSessionRegistryStorage 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 getSessionInformations($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) + { + if (isset($this->sessionInformations[$sessionId])) { + unset($this->sessionInformations[$sessionId]); + } + } + + private function getFilePath($sessionId) + { + return $this->savePath.'/'.$sessionId.'.mocksessinfo'; + } + + 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..8d426f03820b9 --- /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($session->getId(), $token->getUsername()); + } + } +} 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..ebb22d285060e --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionInformation.php @@ -0,0 +1,121 @@ + + * + * 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 +{ + private $sessionId; + private $username; + private $expired; + private $lastRequest; + + public function __construct($sessionId, $username, \DateTime $lastRequest, \DateTime $expired = null) + { + $this->setSessionId($sessionId); + $this->setUsername($username); + $this->setLastRequest($lastRequest); + + if (null !== $expired) { + $this->setExpired($expired); + } + } + + /** + * Sets the session informations expiration 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; + } + + /** + * 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->getExpired() && $this->getExpired()->getTimestamp() < microtime(true); + } + + /** + * Set the last request date to the current date and time. + * + */ + public function refreshLastRequest() + { + $this->lastRequest = new \DateTime(); + } + + private function getExpired() + { + return $this->expired; + } + + private function setExpired(\DateTime $expired) + { + $this->expired = $expired; + } + + private 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..5ff6a53348467 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Session/SessionRegistry.php @@ -0,0 +1,114 @@ + + * + * 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; + +/** + * 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; + } + + /** + * Returns all the sessions stored for the given user ordered from newest to oldest. + * + * @param string $username the given user. + * @param bool $includeExpiredSessions + * @return SessionInformation[] An array of SessionInformation objects. + */ + public function getAllSessions($username, $includeExpiredSessions = false) + { + return $this->sessionRegistryStorage->getSessionInformations($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); + } + + /** + * Sets a SessionInformation object. + * + * @param SessionInformation $sessionInformation + */ + private 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); + } + } + + /** + * Expires the given sessionId. + * + * @param string $sessionId the session identifier key. + */ + public function expireNow($sessionId) + { + if ($sessionInformation = $this->getSessionInformation($sessionId)) { + $sessionInformation->expireNow(); + $this->setSessionInformation($sessionInformation); + } + } + + /** + * Registers a new session for the given user. + * + * @param string $sessionId the session identifier key. + * @param string $username the given user. + * @param \DateTime $lastRequest + */ + public function registerNewSession($sessionId, $username, \DateTime $lastRequest = null) + { + $lastRequest = $lastRequest ?: new \DateTime(); + $sessionInformation = new SessionInformation($sessionId, $username, $lastRequest); + + $this->setSessionInformation($sessionInformation); + } + + /** + * Deletes the stored information of one session. + * + * @param string $sessionId the session identifier key. + */ + 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..b9a04995f57cd --- /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); + + /** + * Obtains the maintained information for one user ordered from newest to + * oldest + * + * @param string $username The user identifier. + * @param bool $includeExpiredSessions + * @return SessionInformation[] An array of SessionInformation objects. + */ + public function getSessionInformations($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/Firewall/ExpiredSessionListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/ExpiredSessionListenerTest.php new file mode 100644 index 0000000000000..55bc7b28b9a38 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/ExpiredSessionListenerTest.php @@ -0,0 +1,288 @@ + + * + * 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\ExpiredSessionListener; + +/** + * @author Antonio J. García Lagar + */ +class ExpiredSessionListenerTest extends \PHPUnit_Framework_TestCase +{ + public function testHandleWhenNoSession() + { + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('hasSession') + ->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 ExpiredSessionListener( + $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'), + $this->getHttpUtils(), + $this->getSessionRegistry() + ); + + $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('hasSession') + ->will($this->returnValue(true)); + $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 ExpiredSessionListener( + $securityContext, + $this->getHttpUtils(), + $this->getSessionRegistry() + ); + + $this->assertNull($listener->handle($event)); + } + + public function testHandleWhenSessionInformationIsExpired() + { + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session + ->expects($this->any()) + ->method('getId') + ->will($this->returnValue('foo')); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('hasSession') + ->will($this->returnValue(true)); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $token + ->expects($this->any()) + ->method('getUsername') + ->will($this->returnValue('foobar')); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $sessionInformation = $this->getSessionInformation(); + $sessionInformation + ->expects($this->once()) + ->method('isExpired') + ->will($this->returnValue(true)); + + $sessionRegistry = $this->getSessionRegistry(); + $sessionRegistry + ->expects($this->once()) + ->method('getSessionInformation') + ->with($this->equalTo('foo')) + ->will($this->returnValue($sessionInformation)); + + $response = $this->getMock('Symfony\Component\HttpFoundation\Response'); + + $httpUtils = $this->getHttpUtils(); + $httpUtils + ->expects($this->once()) + ->method('createRedirectResponse') + ->with($this->identicalTo($request), $this->equalTo('/')) + ->will($this->returnValue($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)); + + $listener = new ExpiredSessionListener( + $securityContext, + $httpUtils, + $sessionRegistry + ); + + $this->assertNull($listener->handle($event)); + } + + public function testHandleWhenSessionInformationIsNotExpired() + { + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session + ->expects($this->any()) + ->method('getId') + ->will($this->returnValue('foo')); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('hasSession') + ->will($this->returnValue(true)); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $token + ->expects($this->any()) + ->method('getUsername') + ->will($this->returnValue('foobar')); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $sessionInformation = $this->getSessionInformation(); + $sessionInformation + ->expects($this->once()) + ->method('isExpired') + ->will($this->returnValue(false)); + + $sessionRegistry = $this->getSessionRegistry(); + $sessionRegistry + ->expects($this->once()) + ->method('getSessionInformation') + ->with($this->equalTo('foo')) + ->will($this->returnValue($sessionInformation)); + $sessionRegistry + ->expects($this->once()) + ->method('refreshLastRequest') + ->with($this->equalTo('foo')); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + + $listener = new ExpiredSessionListener( + $securityContext, + $this->getHttpUtils(), + $sessionRegistry + ); + + $this->assertNull($listener->handle($event)); + } + + public function testHandleWhenNoSessionInformationIsRegistered() + { + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\SessionInterface'); + $session + ->expects($this->any()) + ->method('getId') + ->will($this->returnValue('foo')); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->any()) + ->method('hasSession') + ->will($this->returnValue(true)); + $request + ->expects($this->any()) + ->method('getSession') + ->will($this->returnValue($session)); + + $token = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface'); + $token + ->expects($this->once()) + ->method('getUsername') + ->will($this->returnValue('foobar')); + + $securityContext = $this->getMock('Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'); + $securityContext + ->expects($this->any()) + ->method('getToken') + ->will($this->returnValue($token)); + + $sessionRegistry = $this->getSessionRegistry(); + $sessionRegistry + ->expects($this->once()) + ->method('getSessionInformation') + ->with($this->equalTo('foo')) + ->will($this->returnValue(null)); + $sessionRegistry + ->expects($this->once()) + ->method('registerNewSession') + ->with($this->equalTo('foo'), $this->equalTo('foobar')); + + $event = $this->getMock('Symfony\Component\HttpKernel\Event\GetResponseEvent', array(), array(), '', false); + $event + ->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue($request)); + + $listener = new ExpiredSessionListener( + $securityContext, + $this->getHttpUtils(), + $sessionRegistry + ); + + $this->assertNull($listener->handle($event)); + } + + private function getHttpUtils() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\HttpUtils') + ->disableOriginalConstructor() + ->getMock(); + } + + private function getSessionRegistry() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\Session\SessionRegistry') + ->disableOriginalConstructor() + ->getMock(); + } + + private function getSessionInformation() + { + return $this->getMockBuilder('Symfony\Component\Security\Http\Session\SessionInformation') + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Logout/SessionRegistryLogoutHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/Logout/SessionRegistryLogoutHandlerTest.php new file mode 100644 index 0000000000000..32f9b6cc83563 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Logout/SessionRegistryLogoutHandlerTest.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\Component\Security\Http\Tests\Logout; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Http\Logout\SessionRegistryLogoutHandler; + +class SessionRegistryLogoutHandlerTest extends \PHPUnit_Framework_TestCase +{ + public function testLogout() + { + $registry = $this->getMock('Symfony\Component\Security\Http\Session\SessionRegistry', array(), array(), '', false); + $handler = new SessionRegistryLogoutHandler($registry); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $response = new Response(); + $session = $this->getMock('Symfony\Component\HttpFoundation\Session\Session', array(), array(), '', false); + + $request + ->expects($this->once()) + ->method('getSession') + ->will($this->returnValue($session)) + ; + + $session + ->expects($this->once()) + ->method('getId') + ->will($this->returnValue('foobar')) + ; + + $registry + ->expects($this->once()) + ->method('removeSessionInformation') + ->with($this->equalTo('foobar')) + ; + + $handler->logout($request, $response, $this->getMock('Symfony\Component\Security\Core\Authentication\Token\TokenInterface')); + } +} 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..d9126506778b4 --- /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('getAllSessions') + ->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('getAllSessions') + ->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('getAllSessions') + ->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('getAllSessions') + ->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..bbdce8f50ea5f --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/RegisterSessionAuthenticationStrategyTest.php @@ -0,0 +1,59 @@ + + * + * 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'); + $session->expects($this->any())->method('getId')->will($this->returnValue('bar')); + $request = $this->getRequest($session); + + $registry = $this->getSessionRegistry(); + $registry->expects($this->once())->method('registerNewSession')->with($this->equalTo('bar'), $this->equalTo('foo')); + + $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..c876c7adb58b3 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/SessionInformationTest.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\SessionInformation; + +/** + * @author Antonio J. García Lagar + */ +class SessionInformationTest extends \PHPUnit_Framework_TestCase +{ + public function testExpiration() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertFalse($sessionInfo->isExpired()); + $sessionInfo->expireNow(); + + $this->assertTrue($sessionInfo->isExpired()); + } + + public function testRefreshLastRequest() + { + $sessionInfo = $this->getSessionInformation(); + $lastRequest = $sessionInfo->getLastRequest(); + $this->assertInstanceOf('DateTime', $lastRequest); + $sessionInfo->refreshLastRequest(); + $this->assertGreaterThanOrEqual($lastRequest, $sessionInfo->getLastRequest()); + } + + public function testGetSessionId() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertEquals('foo', $sessionInfo->getSessionId()); + } + + public function testGetUsername() + { + $sessionInfo = $this->getSessionInformation(); + $this->assertEquals('bar', $sessionInfo->getUsername()); + } + + /** + * @return \Symfony\Component\Security\Http\Session\SessionInformation + */ + private function getSessionInformation() + { + return new SessionInformation('foo', 'bar', new \DateTime()); + } +} 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..aadcecda896fd --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Session/SessionRegistryTest.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\Tests\Session; + +use Symfony\Component\Security\Http\Session\SessionRegistry; + +/** + * @author Antonio J. García Lagar + */ +class SessionRegistryTest extends \PHPUnit_Framework_TestCase +{ + public function testGetAllSessions() + { + $storage = $this->getSessionRegistryStorage(); + $storage->expects($this->once())->method('getSessionInformations')->with('foo', true); + $registry = $this->getSessionRegistry($storage); + $registry->getAllSessions('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 testRefreshLastRequest() + { + $sessionInformation = $this->getSessionInformation(); + $sessionInformation->expects($this->once())->method('refreshLastRequest'); + $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->refreshLastRequest('foobar'); + } + + public function testExpireNow() + { + $sessionInformation = $this->getSessionInformation(); + $sessionInformation->expects($this->once())->method('expireNow'); + $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 testRegisterNewSession() + { + $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', 'bar', new \DateTime()); + } + + 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'); + } +}