Commit 37af5099 authored by catch's avatar catch

Issue #2238087 by znerol, YesCT: Rebase SessionManager onto Symfony NativeSessionStorage.

parent fd0dcf94
...@@ -767,9 +767,12 @@ services: ...@@ -767,9 +767,12 @@ services:
arguments: ['@authentication', '@request'] arguments: ['@authentication', '@request']
session_manager: session_manager:
class: Drupal\Core\Session\SessionManager class: Drupal\Core\Session\SessionManager
arguments: ['@request_stack', '@database'] arguments: ['@request_stack', '@database', '@session_manager.metadata_bag', '@settings']
tags: tags:
- { name: persist } - { name: persist }
session_manager.metadata_bag:
class: Drupal\Core\Session\MetadataBag
arguments: ['@settings']
asset.css.collection_renderer: asset.css.collection_renderer:
class: Drupal\Core\Asset\CssCollectionRenderer class: Drupal\Core\Asset\CssCollectionRenderer
arguments: [ '@state' ] arguments: [ '@state' ]
......
<?php
/**
* @file
* Contains \Drupal\Core\Session\MetadataBag.
*/
namespace Drupal\Core\Session;
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag as SymfonyMetadataBag;
/**
* Provides a container for application specific session metadata.
*/
class MetadataBag extends SymfonyMetadataBag {
/**
* Constructs a new metadata bag instance.
*
* @param \Drupal\Core\Site\Settings $settings
* The settings instance.
*/
public function __construct(Settings $settings) {
$update_threshold = $settings->get('session_write_interval', 180);
parent::__construct('_sf2_meta', $update_threshold);
}
}
...@@ -12,11 +12,12 @@ ...@@ -12,11 +12,12 @@
use Drupal\Core\Site\Settings; use Drupal\Core\Site\Settings;
use Drupal\Core\Utility\Error; use Drupal\Core\Utility\Error;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
/** /**
* Default session handler. * Default session handler.
*/ */
class SessionHandler implements \SessionHandlerInterface { class SessionHandler extends AbstractProxy implements \SessionHandlerInterface {
/** /**
* The session manager. * The session manager.
...@@ -39,13 +40,6 @@ class SessionHandler implements \SessionHandlerInterface { ...@@ -39,13 +40,6 @@ class SessionHandler implements \SessionHandlerInterface {
*/ */
protected $connection; protected $connection;
/**
* An array containing the sid and data from last read.
*
* @var array
*/
protected $lastRead;
/** /**
* Constructs a new SessionHandler instance. * Constructs a new SessionHandler instance.
* *
...@@ -77,9 +71,9 @@ public function read($sid) { ...@@ -77,9 +71,9 @@ public function read($sid) {
// Handle the case of first time visitors and clients that don't store // Handle the case of first time visitors and clients that don't store
// cookies (eg. web crawlers). // cookies (eg. web crawlers).
$insecure_session_name = substr(session_name(), 1); $insecure_session_name = $this->sessionManager->getInsecureName();
$cookies = $this->requestStack->getCurrentRequest()->cookies; $cookies = $this->requestStack->getCurrentRequest()->cookies;
if (!$cookies->has(session_name()) && !$cookies->has($insecure_session_name)) { if (!$cookies->has($this->getName()) && !$cookies->has($insecure_session_name)) {
$user = new UserSession(); $user = new UserSession();
return ''; return '';
} }
...@@ -136,11 +130,6 @@ public function read($sid) { ...@@ -136,11 +130,6 @@ public function read($sid) {
$user = new UserSession(); $user = new UserSession();
} }
// Store the session that was read for comparison in self::write().
$this->lastRead = array(
'sid' => $sid,
'value' => $user->session,
);
return $user->session; return $user->session;
} }
...@@ -158,48 +147,40 @@ public function write($sid, $value) { ...@@ -158,48 +147,40 @@ public function write($sid, $value) {
// session. // session.
return TRUE; return TRUE;
} }
// Check whether $_SESSION has been changed in this request.
$is_changed = empty($this->lastRead) || $this->lastRead['sid'] != $sid || $this->lastRead['value'] !== $value;
// For performance reasons, do not update the sessions table, unless
// $_SESSION has changed or more than 180 has passed since the last
// update.
$needs_update = !$user->getLastAccessedTime() || REQUEST_TIME - $user->getLastAccessedTime() > Settings::get('session_write_interval', 180);
if ($is_changed || $needs_update) { // Either ssid or sid or both will be added from $key below.
// Either ssid or sid or both will be added from $key below. $fields = array(
$fields = array( 'uid' => $user->id(),
'uid' => $user->id(), 'hostname' => $this->requestStack->getCurrentRequest()->getClientIP(),
'hostname' => $this->requestStack->getCurrentRequest()->getClientIP(), 'session' => $value,
'session' => $value, 'timestamp' => REQUEST_TIME,
'timestamp' => REQUEST_TIME, );
); // Use the session ID as 'sid' and an empty string as 'ssid' by default.
// Use the session ID as 'sid' and an empty string as 'ssid' by default. // read() does not allow empty strings so that's a safe default.
// read() does not allow empty strings so that's a safe default. $key = array('sid' => Crypt::hashBase64($sid), 'ssid' => '');
$key = array('sid' => Crypt::hashBase64($sid), 'ssid' => ''); // On HTTPS connections, use the session ID as both 'sid' and 'ssid'.
// On HTTPS connections, use the session ID as both 'sid' and 'ssid'. if ($this->requestStack->getCurrentRequest()->isSecure()) {
if ($this->requestStack->getCurrentRequest()->isSecure()) { $key['ssid'] = Crypt::hashBase64($sid);
$key['ssid'] = Crypt::hashBase64($sid); $cookies = $this->requestStack->getCurrentRequest()->cookies;
$cookies = $this->requestStack->getCurrentRequest()->cookies; // The "secure pages" setting allows a site to simultaneously use both
// The "secure pages" setting allows a site to simultaneously use both // secure and insecure session cookies. If enabled and both cookies
// secure and insecure session cookies. If enabled and both cookies // are presented then use both keys. The session ID from the cookie is
// are presented then use both keys. The session ID from the cookie is // hashed before being stored in the database as a security measure.
// hashed before being stored in the database as a security measure. if ($this->sessionManager->isMixedMode()) {
if (Settings::get('mixed_mode_sessions', FALSE)) { $insecure_session_name = $this->sessionManager->getInsecureName();
$insecure_session_name = substr(session_name(), 1); if ($cookies->has($insecure_session_name)) {
if ($cookies->has($insecure_session_name)) { $key['sid'] = Crypt::hashBase64($cookies->get($insecure_session_name));
$key['sid'] = Crypt::hashBase64($cookies->get($insecure_session_name));
}
} }
} }
elseif (Settings::get('mixed_mode_sessions', FALSE)) {
unset($key['ssid']);
}
$this->connection->merge('sessions')
->keys($key)
->fields($fields)
->execute();
} }
elseif ($this->sessionManager->isMixedMode()) {
unset($key['ssid']);
}
$this->connection->merge('sessions')
->keys($key)
->fields($fields)
->execute();
// Likewise, do not update access time more than once per 180 seconds. // Likewise, do not update access time more than once per 180 seconds.
if ($user->isAuthenticated() && REQUEST_TIME - $user->getLastAccessedTime() > Settings::get('session_write_interval', 180)) { if ($user->isAuthenticated() && REQUEST_TIME - $user->getLastAccessedTime() > Settings::get('session_write_interval', 180)) {
$this->connection->update('users') $this->connection->update('users')
...@@ -252,12 +233,12 @@ public function destroy($sid) { ...@@ -252,12 +233,12 @@ public function destroy($sid) {
$user = new AnonymousUserSession(); $user = new AnonymousUserSession();
// Unset the session cookies. // Unset the session cookies.
$this->deleteCookie(session_name()); $this->deleteCookie($this->getName());
if ($is_https) { if ($is_https) {
$this->deleteCookie(substr(session_name(), 1), FALSE); $this->deleteCookie($this->sessionManager->getInsecureName(), FALSE);
} }
elseif (Settings::get('mixed_mode_sessions', FALSE)) { elseif ($this->sessionManager->isMixedMode()) {
$this->deleteCookie('S' . session_name(), TRUE); $this->deleteCookie('S' . $this->getName(), TRUE);
} }
return TRUE; return TRUE;
} }
......
...@@ -13,11 +13,36 @@ ...@@ -13,11 +13,36 @@
use Drupal\Core\Session\SessionHandler; use Drupal\Core\Session\SessionHandler;
use Drupal\Core\Site\Settings; use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\WriteCheckSessionHandler;
use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag as SymfonyMetadataBag;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
/** /**
* Manages user sessions. * Manages user sessions.
*
* This class implements the custom session management code inherited from
* Drupal 7 on top of the corresponding Symfony component. Regrettably the name
* NativeSessionStorage is not quite accurate. In fact the responsibility for
* storing and retrieving session data has been extracted from it in Symfony 2.1
* but the class name was not changed.
*
* @todo
* In fact the NativeSessionStorage class already implements all of the
* functionality required by a typical Symfony application. Normally it is not
* necessary to subclass it at all. In order to reach the point where Drupal
* can use the Symfony session management unmodified, the code implemented
* here needs to be extracted either into a dedicated session handler proxy
* (e.g. mixed mode SSL, sid-hashing) or relocated to the authentication
* subsystem.
*/ */
class SessionManager implements SessionManagerInterface { class SessionManager extends NativeSessionStorage implements SessionManagerInterface {
/**
* Whether or not the session manager is operating in mixed mode SSL.
*
* @var bool
*/
protected $mixedMode;
/** /**
* The request stack. * The request stack.
...@@ -54,14 +79,30 @@ class SessionManager implements SessionManagerInterface { ...@@ -54,14 +79,30 @@ class SessionManager implements SessionManagerInterface {
/** /**
* Constructs a new session manager instance. * Constructs a new session manager instance.
* *
* @param \Symfony\Component\HttpFoundation\RequestStack $request * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack. * The request stack.
* @param \Drupal\Core\Database\Connection $connection * @param \Drupal\Core\Database\Connection $connection
* The database connection. * The database connection.
* @param \Symfony\Component\HttpFoundation\Session\Storage\MetadataBag $metadata_bag
* The session metadata bag.
* @param \Drupal\Core\Site\Settings $settings
* The settings instance.
*/ */
public function __construct(RequestStack $request_stack, Connection $connection) { public function __construct(RequestStack $request_stack, Connection $connection, SymfonyMetadataBag $metadata_bag, Settings $settings) {
parent::__construct();
$this->requestStack = $request_stack; $this->requestStack = $request_stack;
$this->connection = $connection; $this->connection = $connection;
$this->setMetadataBag($metadata_bag);
$this->setMixedMode($settings->get('mixed_mode_sessions', FALSE));
// @todo When not using the Symfony Session object, the list of bags in the
// NativeSessionStorage will remain uninitialized. This will lead to
// errors in NativeSessionHandler::loadSession. Remove this after
// https://drupal.org/node/2229145, when we will be using the Symfony
// session object (which registers an attribute bag with the
// manager upon instantiation).
$this->bags = array();
} }
/** /**
...@@ -72,12 +113,14 @@ public function initialize() { ...@@ -72,12 +113,14 @@ public function initialize() {
// Register the default session handler. // Register the default session handler.
// @todo Extract session storage from session handler into a service. // @todo Extract session storage from session handler into a service.
$handler = new SessionHandler($this, $this->requestStack, $this->connection); $save_handler = new SessionHandler($this, $this->requestStack, $this->connection);
session_set_save_handler($handler, TRUE); $write_check_handler = new WriteCheckSessionHandler($save_handler);
$this->setSaveHandler($write_check_handler);
$is_https = $this->requestStack->getCurrentRequest()->isSecure(); $is_https = $this->requestStack->getCurrentRequest()->isSecure();
$cookies = $this->requestStack->getCurrentRequest()->cookies; $cookies = $this->requestStack->getCurrentRequest()->cookies;
if (($cookies->has(session_name()) && ($session_name = $cookies->get(session_name()))) || ($is_https && Settings::get('mixed_mode_sessions', FALSE) && ($cookies->has(substr(session_name(), 1))) && ($session_name = $cookies->get(substr(session_name(), 1))))) { $insecure_session_name = $this->getInsecureName();
if (($cookies->has($this->getName()) && ($session_name = $cookies->get($this->getName()))) || ($is_https && $this->isMixedMode() && ($cookies->has($insecure_session_name) && ($session_name = $cookies->get($insecure_session_name))))) {
// If a session cookie exists, initialize the session. Otherwise the // If a session cookie exists, initialize the session. Otherwise the
// session is only started on demand in save(), making // session is only started on demand in save(), making
// anonymous users not use a session cookie unless something is stored in // anonymous users not use a session cookie unless something is stored in
...@@ -94,12 +137,8 @@ public function initialize() { ...@@ -94,12 +137,8 @@ public function initialize() {
// session ID in advance. // session ID in advance.
$this->lazySession = TRUE; $this->lazySession = TRUE;
$user = new AnonymousUserSession(); $user = new AnonymousUserSession();
// Less random sessions (which are much faster to generate) are used for $this->setId(Crypt::randomBytesBase64());
// anonymous users than are generated in regenerate() when if ($is_https && $this->isMixedMode()) {
// a user becomes authenticated.
session_id(Crypt::randomBytesBase64());
if ($is_https && Settings::get('mixed_mode_sessions', FALSE)) {
$insecure_session_name = substr(session_name(), 1);
$session_id = Crypt::randomBytesBase64(); $session_id = Crypt::randomBytesBase64();
$cookies->set($insecure_session_name, $session_id); $cookies->set($insecure_session_name, $session_id);
} }
...@@ -113,18 +152,20 @@ public function initialize() { ...@@ -113,18 +152,20 @@ public function initialize() {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function start() { public function start() {
if ($this->isCli() || $this->isStarted()) { if (!$this->isEnabled() || $this->isCli()) {
return; return;
} }
// Save current session data before starting it, as PHP will destroy it. // Save current session data before starting it, as PHP will destroy it.
$session_data = isset($_SESSION) ? $_SESSION : NULL; $session_data = isset($_SESSION) ? $_SESSION : NULL;
session_start(); $result = parent::start();
// Restore session data. // Restore session data.
if (!empty($session_data)) { if (!empty($session_data)) {
$_SESSION += $session_data; $_SESSION += $session_data;
} }
return $result;
} }
/** /**
...@@ -141,7 +182,7 @@ public function save() { ...@@ -141,7 +182,7 @@ public function save() {
if ($user->isAnonymous() && $this->isSessionObsolete()) { if ($user->isAnonymous() && $this->isSessionObsolete()) {
// There is no session data to store, destroy the session if it was // There is no session data to store, destroy the session if it was
// previously started. // previously started.
if ($this->isStarted()) { if ($this->getSaveHandler()->isActive()) {
session_destroy(); session_destroy();
} }
} }
...@@ -150,8 +191,8 @@ public function save() { ...@@ -150,8 +191,8 @@ public function save() {
// started. // started.
if (!$this->isStarted()) { if (!$this->isStarted()) {
$this->start(); $this->start();
if ($this->requestStack->getCurrentRequest()->isSecure() && Settings::get('mixed_mode_sessions', FALSE)) { if ($this->requestStack->getCurrentRequest()->isSecure() && $this->isMixedMode()) {
$insecure_session_name = substr(session_name(), 1); $insecure_session_name = $this->getInsecureName();
$params = session_get_cookie_params(); $params = session_get_cookie_params();
$expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0; $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
$cookie_params = $this->requestStack->getCurrentRequest()->cookies; $cookie_params = $this->requestStack->getCurrentRequest()->cookies;
...@@ -159,21 +200,14 @@ public function save() { ...@@ -159,21 +200,14 @@ public function save() {
} }
} }
// Write the session data. // Write the session data.
session_write_close(); parent::save();
} }
} }
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function isStarted() { public function regenerate($destroy = FALSE, $lifetime = NULL) {
return session_status() === \PHP_SESSION_ACTIVE;
}
/**
* {@inheritdoc}
*/
public function regenerate() {
global $user; global $user;
// Nothing to do if we are not allowed to change the session. // Nothing to do if we are not allowed to change the session.
...@@ -181,11 +215,17 @@ public function regenerate() { ...@@ -181,11 +215,17 @@ public function regenerate() {
return; return;
} }
// We do not support the optional $destroy and $lifetime parameters as long
// as #2238561 remains open.
if ($destroy || isset($lifetime)) {
throw new \InvalidArgumentException('The optional parameters $destroy and $lifetime of SessionManager::regenerate() are not supported currently');
}
$is_https = $this->requestStack->getCurrentRequest()->isSecure(); $is_https = $this->requestStack->getCurrentRequest()->isSecure();
$cookies = $this->requestStack->getCurrentRequest()->cookies; $cookies = $this->requestStack->getCurrentRequest()->cookies;
if ($is_https && Settings::get('mixed_mode_sessions', FALSE)) { if ($is_https && $this->isMixedMode()) {
$insecure_session_name = substr(session_name(), 1); $insecure_session_name = $this->getInsecureName();
if (!isset($this->lazySession) && $cookies->has($insecure_session_name)) { if (!isset($this->lazySession) && $cookies->has($insecure_session_name)) {
$old_insecure_session_id = $cookies->get($insecure_session_name); $old_insecure_session_id = $cookies->get($insecure_session_name);
} }
...@@ -200,14 +240,13 @@ public function regenerate() { ...@@ -200,14 +240,13 @@ public function regenerate() {
} }
if ($this->isStarted()) { if ($this->isStarted()) {
$old_session_id = session_id(); $old_session_id = $this->getId();
} }
session_id(Crypt::randomBytesBase64()); session_id(Crypt::randomBytesBase64());
// @todo As soon as https://drupal.org/node/2238087 lands, the token seed // @todo The token seed can be moved onto \Drupal\Core\Session\MetadataBag.
// can be moved onto Drupal\Core\Session\MetadataBag. The session manager // The session manager then needs to notify the metadata bag when the
// then needs to notify the metadata bag when the token should be // token should be regenerated. https://drupal.org/node/2256257
// regenerated.
if (!empty($_SESSION)) { if (!empty($_SESSION)) {
unset($_SESSION['csrf_token_seed']); unset($_SESSION['csrf_token_seed']);
} }
...@@ -215,13 +254,13 @@ public function regenerate() { ...@@ -215,13 +254,13 @@ public function regenerate() {
if (isset($old_session_id)) { if (isset($old_session_id)) {
$params = session_get_cookie_params(); $params = session_get_cookie_params();
$expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0; $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
setcookie(session_name(), session_id(), $expire, $params['path'], $params['domain'], $params['secure'], $params['httponly']); setcookie($this->getName(), $this->getId(), $expire, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
$fields = array('sid' => Crypt::hashBase64(session_id())); $fields = array('sid' => Crypt::hashBase64($this->getId()));
if ($is_https) { if ($is_https) {
$fields['ssid'] = Crypt::hashBase64(session_id()); $fields['ssid'] = Crypt::hashBase64($this->getId());
// If the "secure pages" setting is enabled, use the newly-created // If the "secure pages" setting is enabled, use the newly-created
// insecure session identifier as the regenerated sid. // insecure session identifier as the regenerated sid.
if (Settings::get('mixed_mode_sessions', FALSE)) { if ($this->isMixedMode()) {
$fields['sid'] = Crypt::hashBase64($session_id); $fields['sid'] = Crypt::hashBase64($session_id);
} }
} }
...@@ -235,7 +274,7 @@ public function regenerate() { ...@@ -235,7 +274,7 @@ public function regenerate() {
// the secure site but a session was active on the insecure site, update // the secure site but a session was active on the insecure site, update
// the insecure session with the new session identifiers. // the insecure session with the new session identifiers.
$this->connection->update('sessions') $this->connection->update('sessions')
->fields(array('sid' => Crypt::hashBase64($session_id), 'ssid' => Crypt::hashBase64(session_id()))) ->fields(array('sid' => Crypt::hashBase64($session_id), 'ssid' => Crypt::hashBase64($this->getId())))
->condition('sid', Crypt::hashBase64($old_insecure_session_id)) ->condition('sid', Crypt::hashBase64($old_insecure_session_id))
->execute(); ->execute();
} }
...@@ -286,6 +325,27 @@ public function enable() { ...@@ -286,6 +325,27 @@ public function enable() {
return $this; return $this;
} }
/**
* {@inheritdoc}
*/
public function isMixedMode() {
return $this->mixedMode;
}
/**
* {@inheritdoc}
*/
public function setMixedMode($mixed_mode) {
$this->mixedMode = (bool) $mixed_mode;
}
/**
* {@inheritdoc}
*/
public function getInsecureName() {
return substr($this->getName(), 1);
}
/** /**
* Returns whether the current PHP process runs on CLI. * Returns whether the current PHP process runs on CLI.
* *
...@@ -305,11 +365,29 @@ protected function isCli() { ...@@ -305,11 +365,29 @@ protected function isCli() {
* destroyed. * destroyed.
*/ */
protected function isSessionObsolete() { protected function isSessionObsolete() {
// Return early when $_SESSION is empty or not initialized. $used_session_keys = array_filter($this->getSessionDataMask());
return empty($used_session_keys);
}
/**
* Returns a map specifying which session key is containing user data.
*
* @return array
* An array where keys correspond to the session keys and the values are
* booleans specifying whether the corresponding session key contains any
* user data.
*/
protected function getSessionDataMask() {
if (empty($_SESSION)) { if (empty($_SESSION)) {
return TRUE; return array();
} }
// Start out with a completely filled mask.
$mask = array_fill_keys(array_keys($_SESSION), TRUE);
// Ignore the metadata bag, it does not contain any user data.
$mask[$this->metadataBag->getStorageKey()] = FALSE;
// Ignore the CSRF token seed. // Ignore the CSRF token seed.
// //
// @todo Anonymous users should not get a CSRF token at any time, or if they // @todo Anonymous users should not get a CSRF token at any time, or if they
...@@ -317,10 +395,18 @@ protected function isSessionObsolete() { ...@@ -317,10 +395,18 @@ protected function isSessionObsolete() {
// session once obsolete. Since that is not guaranteed to be the case, // session once obsolete. Since that is not guaranteed to be the case,
// this check force-ignores the CSRF token, so as to avoid performance // this check force-ignores the CSRF token, so as to avoid performance
// regressions. // regressions.
// As soon as https://drupal.org/node/2238087 lands, the token seed can be // The token seed can be moved onto \Drupal\Core\Session\MetadataBag. This
// moved onto \Drupal\Core\Session\MetadataBag. This will result in the // will result in the CSRF token being ignored automatically.
// CSRF token to be ignored automatically.