Skip to content

[WIP] [Security] Session concurrency control #12009

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
BC Concurrent Sessions Control
  • Loading branch information
ajgarlag committed Nov 10, 2014
commit 90eba3473132105c46149511b11c2e4bbda8d9a6
11 changes: 6 additions & 5 deletions src/Symfony/Bridge/Doctrine/Security/SessionRegistry/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,28 @@
* The schema used for the ACL system.
*
* @author Stefan Paschke <stefan.paschke@gmail.com>
* @author Antonio J. García Lagar <aj@garcialagar.es>
*/
final class Schema extends BaseSchema
{
/**
* Constructor
*
* @param array $options the names for tables
* @param string $table
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing $table param description: the table to use? the table to create?

*/
public function __construct(array $options)
public function __construct($table)
{
parent::__construct();

$this->addSessionInformationTable($options);
$this->addSessionInformationTable($table);
}

/**
* Adds the session_information table to the schema
*/
protected function addSessionInformationTable(array $options)
protected function addSessionInformationTable($table)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be private

{
$table = $this->createTable($options['session_information_table_name']);
$table = $this->createTable($table);
$table->addColumn('session_id', 'string');
$table->addColumn('username', 'string');
$table->addColumn('expired', 'datetime', array('unsigned' => true, 'notnull' => false));
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,35 @@

namespace Symfony\Bridge\Doctrine\Security\SessionRegistry;

use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Connection;
use Symfony\Component\Security\Http\Session\SessionInformation;
use Symfony\Component\Security\Http\Session\SessionRegistryStorageInterface;

/**
* @author Stefan Paschke <stefan.paschke@gmail.com>
* @author Antonio J. García Lagar <aj@garcialagar.es>
*/
class SessionRegistryStorage implements SessionRegistryStorageInterface
{
protected $connection;
protected $options;
protected $table;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be private


public function __construct(Connection $connection, array $options)
public function __construct(Connection $connection, $table)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing PHPdoc in this constructor

{
$this->connection = $connection;
$this->options = $options;
}

/**
* not implemented
*/
public function getUsers()
{
throw new \BadMethodCallException("Not implemented.");
$this->connection = $connection;
$this->table = $table;
}

/**
* Obtains the maintained information for one session.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obtains the maintained information ... -> Returns the stored information ... , Gets the stored information ...

*
* @param string $sessionId the session identifier key.
* @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',
'SELECT * FROM '.$this->table.' WHERE session_id = :session_id',
array('session_id' => $sessionId)
);

Expand All @@ -46,24 +42,23 @@ public function getSessionInformation($sessionId)
/**
* Obtains the maintained information for one user.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

*
* @param string $username The user identifier.
* @param boolean $includeExpiredSessions.
* @return array An array of SessionInformation objects.
* @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'].'
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))
{
while ($data = $statement->fetch(\PDO::FETCH_ASSOC)) {
$sessionInformations[] = $this->instantiateSessionInformationFromResultSet($data);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should call $statement->closeCursor() at the end of the iteration to close the cursor so that memory can be reclaimed by the DB driver


Expand All @@ -78,17 +73,52 @@ public function getSessionInformations($username, $includeExpiredSessions = fals
*/
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();
$mergeSql = $this->getMergeSql();

if (null !== $mergeSql) {
$mergeStmt = $this->pdo->prepare($mergeSql);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined property pdo

$mergeStmt->bindValue('session_id', $sessionInformation->getSessionId());
$mergeStmt->bindValue('username', $sessionInformation->getUsername());
$mergeStmt->bindValue('last_request', $sessionInformation->getLastRequest(), 'datetime');
$mergeStmt->bindValue('expired', $sessionInformation->getExpired(), 'datetime');
$mergeStmt->execute();

return true;
}

$updateStmt = $this->pdo->prepare(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

"UPDATE $this->table SET username=:username, last_request=:last_request, expired=:expired WHERE session_id = :session_id"
);
$mergeStmt->bindValue('session_id', $sessionInformation->getSessionId());
$mergeStmt->bindValue('username', $sessionInformation->getUsername());
$mergeStmt->bindValue('last_request', $sessionInformation->getLastRequest(), 'datetime');
$mergeStmt->bindValue('expired', $sessionInformation->getExpired(), 'datetime');
$updateStmt->execute();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could use the DBAL API for this:

$this->connection->update($this->table, array(
    'username' => $sessionInformation->getUsername(),
    'expired' => $sessionInformation->getExpired(),
    'last_request' => $sessionInformation->getLastRequest(),
), array('session_id' =>  $sessionInformation->getSessionId()), array(
    'last_request' => 'datetime',
    'expired' => 'datetime',
));

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, actually no, given that the update statement is reused a second time


// 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 {
$insertStmt = $this->pdo->prepare(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

"INTO $this->table (session_id, username, last_request, expired) VALUES (:session_id, :username, :last_request, :expired)"
);
$insertStmt->bindValue('session_id', $sessionInformation->getSessionId());
$insertStmt->bindValue('username', $sessionInformation->getUsername());
$insertStmt->bindValue('last_request', $sessionInformation->getLastRequest(), 'datetime');
$insertStmt->bindValue('expired', $sessionInformation->getExpired(), 'datetime');
$insertStmt->execute();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could use $this->connection->insert() here as well

} catch (\PDOException $e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DBAL will never throw a PDOException, but a DBALException (PDOException will only be the previous exception)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, DBAL already handles identifying duplicate key errors in recent versions

// Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
if (0 === strpos($e->getCode(), '23')) {
$updateStmt->execute();
} else {
throw $e;
}
}
}
}

/**
Expand All @@ -98,16 +128,45 @@ public function setSessionInformation(SessionInformation $sessionInformation)
*/
public function removeSessionInformation($sessionId)
{
$this->connection->delete($this->options['session_information_table_name'], array('session_id' => $sessionId));
$this->connection->delete($this->table, array('session_id' => $sessionId));
}

private function instantiateSessionInformationFromResultSet($data)
{
return new $this->options['session_information_class_name'](
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'])
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand this phrase. Could it be simplified as follows?

Returns a insert or update SQL query when supported by the database.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned SQL is not an INSERT nor an UPDATE, it's a MERGE (or upsert)
The phrase is copied from https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpFoundation/Session/Storage/Handler/PdoSessionHandler.php#L401.

*
* @return string|null The SQL string or null when not supported
*/
private function getMergeSql()
{
switch ($this->connection->getDriver()->getName()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should rather be based on the DBAL platform than the DBAL driver name actually

case 'pdo_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 'pdo_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 'pdo_sqlsrv':
if (version_compare($this->connection->getWrappedConnection()->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>=')) {
// 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 'pdo_sqlite':
return "INSERT OR REPLACE INTO $this->table (session_id, username, last_request, expired) VALUES (:session_id, :username, :last_request, :expired)";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* Installs the database schema required by the concurrent session Doctrine implementation
*
* @author Stefan Paschke <stefan.paschke@gmail.com>
* @author Antonio J. García Lagar <aj@garcialagar.es>
*/
class InitConcurrentSessionsCommand extends ContainerAwareCommand
{
Expand All @@ -28,8 +29,6 @@ class InitConcurrentSessionsCommand extends ContainerAwareCommand
*/
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.')
Expand All @@ -38,10 +37,6 @@ protected function configure()
generate the database schema required by the concurrent session Doctrine implementation:

<info>./app/console init:concurrent-session</info>

You can also output the SQL instead of executing it:

<info>./app/console init:concurrent-session --dump-sql</info>
EOT
);
}
Expand All @@ -54,23 +49,19 @@ 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'),
);
$table = $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));
if (in_array($table, $tableNames, true)) {
$output->writeln(sprintf('The table "%s" already exists. Aborting.', $table));

return;
}
return;
}

$schema = new Schema($tables);
$schema = new Schema($table);
foreach ($schema->toSql($connection->getDatabasePlatform()) as $sql) {
$connection->exec($sql);
}

$output->writeln('concurrent session tables have been initialized successfully.');
$output->writeln('concurrent session table have been initialized successfully.');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,7 @@ private function addSessionRegistrySection(ArrayNodeDefinition $rootNode)
->arrayNode('session_registry')
->children()
->scalarNode('connection')->end()
->arrayNode('tables')
->addDefaultsIfNotSet()
->children()
->scalarNode('session_information')->defaultValue('cs_session_information')->end()
->end()
->end()
->scalarNode('table')->defaultValue('cs_session_information')->end()
->scalarNode('session_registry_storage')->end()
->end()
->end()
Expand Down Expand Up @@ -313,6 +308,7 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto
->canBeUnset()
->children()
->scalarNode('max_sessions')->defaultNull()->end()
->booleanNode('error_if_maximum_exceeded')->defaultTrue()->end()
->scalarNode('expiration_url')->defaultValue('/')->end()
->end()
->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ abstract class AbstractFactory implements SecurityFactoryInterface
'failure_path_parameter' => '_failure_path',
);

public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId, $sessionStrategy)
public function create(ContainerBuilder $container, $id, $config, $userProviderId, $defaultEntryPointId)
{
// authentication provider
$authProviderId = $this->createAuthProvider($container, $id, $config, $userProviderId);

// authentication listener
$listenerId = $this->createListener($container, $id, $config, $userProviderId, $sessionStrategy);
$listenerId = $this->createListener($container, $id, $config, $userProviderId);

// add remember-me aware tag if requested
if ($this->isRememberMeAware($config)) {
Expand Down Expand Up @@ -153,11 +153,17 @@ protected function isRememberMeAware($config)
return $config['remember_me'];
}

protected function createListener($container, $id, $config, $userProvider, $sessionStrategy)
protected function createListener($container, $id, $config, $userProvider)
{
$listenerId = $this->getListenerId();
$listener = new DefinitionDecorator($listenerId);
$listener->replaceArgument(2, $sessionStrategy);

//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)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ protected function createAuthProvider(ContainerBuilder $container, $id, $config,
return $provider;
}

protected function createListener($container, $id, $config, $userProvider, $sessionStrategy)
protected function createListener($container, $id, $config, $userProvider)
{
$listenerId = parent::createListener($container, $id, $config, $userProvider, $sessionStrategy);
$listenerId = parent::createListener($container, $id, $config, $userProvider);

$container
->getDefinition($listenerId)
Expand Down
Loading
pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy