SessionManager.php 13.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13
<?php

/**
 * @file
 * Contains \Drupal\Core\Session\SessionManager.
 */

namespace Drupal\Core\Session;

use Drupal\Component\Utility\Crypt;
use Drupal\Core\Database\Connection;
use Drupal\Core\Session\AnonymousUserSession;
use Drupal\Core\Session\SessionHandler;
14
use Drupal\Core\Site\Settings;
15
use Symfony\Component\HttpFoundation\RequestStack;
16 17 18
use Symfony\Component\HttpFoundation\Session\Storage\Handler\WriteCheckSessionHandler;
use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag as SymfonyMetadataBag;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
19 20 21

/**
 * Manages user sessions.
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
 *
 * 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.
37
 */
38 39 40 41 42 43 44 45
class SessionManager extends NativeSessionStorage implements SessionManagerInterface {

  /**
   * Whether or not the session manager is operating in mixed mode SSL.
   *
   * @var bool
   */
  protected $mixedMode;
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81

  /**
   * The request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * The database connection to use.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $connection;

  /**
   * Whether a lazy session has been started.
   *
   * @var bool
   */
  protected $lazySession;

  /**
   * Whether session management is enabled or temporarily disabled.
   *
   * PHP session ID, session, and cookie handling happens in the global scope.
   * This value has to persist, since a potentially wrong or disallowed session
   * would be written otherwise.
   *
   * @var bool
   */
  protected static $enabled = TRUE;

  /**
   * Constructs a new session manager instance.
   *
82
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
83 84 85
   *   The request stack.
   * @param \Drupal\Core\Database\Connection $connection
   *   The database connection.
86 87 88 89
   * @param \Symfony\Component\HttpFoundation\Session\Storage\MetadataBag $metadata_bag
   *   The session metadata bag.
   * @param \Drupal\Core\Site\Settings $settings
   *   The settings instance.
90
   */
91 92
  public function __construct(RequestStack $request_stack, Connection $connection, SymfonyMetadataBag $metadata_bag, Settings $settings) {
    parent::__construct();
93 94
    $this->requestStack = $request_stack;
    $this->connection = $connection;
95 96 97 98 99 100 101 102 103 104 105
    $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();
106 107 108 109 110 111 112 113 114 115
  }

  /**
   * {@inheritdoc}
   */
  public function initialize() {
    global $user;

    // Register the default session handler.
    // @todo Extract session storage from session handler into a service.
116 117 118
    $save_handler = new SessionHandler($this, $this->requestStack, $this->connection);
    $write_check_handler = new WriteCheckSessionHandler($save_handler);
    $this->setSaveHandler($write_check_handler);
119 120 121

    $is_https = $this->requestStack->getCurrentRequest()->isSecure();
    $cookies = $this->requestStack->getCurrentRequest()->cookies;
122 123
    $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))))) {
124 125 126 127 128
      // If a session cookie exists, initialize the session. Otherwise the
      // session is only started on demand in save(), making
      // anonymous users not use a session cookie unless something is stored in
      // $_SESSION. This allows HTTP proxies to cache anonymous pageviews.
      $this->start();
129
      if ($user->isAuthenticated() || !$this->isSessionObsolete()) {
130 131 132 133 134 135 136 137 138 139
        drupal_page_is_cacheable(FALSE);
      }
    }
    else {
      // Set a session identifier for this request. This is necessary because
      // we lazily start sessions at the end of this request, and some
      // processes (like drupal_get_token()) needs to know the future
      // session ID in advance.
      $this->lazySession = TRUE;
      $user = new AnonymousUserSession();
140 141
      $this->setId(Crypt::randomBytesBase64());
      if ($is_https && $this->isMixedMode()) {
142 143 144 145 146 147 148 149 150 151 152 153 154
        $session_id = Crypt::randomBytesBase64();
        $cookies->set($insecure_session_name, $session_id);
      }
    }
    date_default_timezone_set(drupal_get_user_timezone());

    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function start() {
155
    if (!$this->isEnabled() || $this->isCli()) {
156 157 158 159 160
      return;
    }
    // Save current session data before starting it, as PHP will destroy it.
    $session_data = isset($_SESSION) ? $_SESSION : NULL;

161
    $result = parent::start();
162 163 164 165 166

    // Restore session data.
    if (!empty($session_data)) {
      $_SESSION += $session_data;
    }
167 168

    return $result;
169 170 171 172 173 174 175 176 177 178 179 180 181
  }

  /**
   * {@inheritdoc}
   */
  public function save() {
    global $user;

    if (!$this->isEnabled()) {
      // We don't have anything to do if we are not allowed to save the session.
      return;
    }

182
    if ($user->isAnonymous() && $this->isSessionObsolete()) {
183 184
      // There is no session data to store, destroy the session if it was
      // previously started.
185
      if ($this->getSaveHandler()->isActive()) {
186 187 188 189 190 191 192 193
        session_destroy();
      }
    }
    else {
      // There is session data to store. Start the session if it is not already
      // started.
      if (!$this->isStarted()) {
        $this->start();
194 195
        if ($this->requestStack->getCurrentRequest()->isSecure() && $this->isMixedMode()) {
          $insecure_session_name = $this->getInsecureName();
196 197 198 199 200 201 202
          $params = session_get_cookie_params();
          $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
          $cookie_params = $this->requestStack->getCurrentRequest()->cookies;
          setcookie($insecure_session_name, $cookie_params->get($insecure_session_name), $expire, $params['path'], $params['domain'], FALSE, $params['httponly']);
        }
      }
      // Write the session data.
203
      parent::save();
204 205 206 207 208 209
    }
  }

  /**
   * {@inheritdoc}
   */
210
  public function regenerate($destroy = FALSE, $lifetime = NULL) {
211 212 213 214 215 216 217
    global $user;

    // Nothing to do if we are not allowed to change the session.
    if (!$this->isEnabled()) {
      return;
    }

218 219 220 221 222 223
    // 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');
    }

224 225 226
    $is_https = $this->requestStack->getCurrentRequest()->isSecure();
    $cookies = $this->requestStack->getCurrentRequest()->cookies;

227 228
    if ($is_https && $this->isMixedMode()) {
      $insecure_session_name = $this->getInsecureName();
229 230 231 232 233 234 235 236 237 238 239 240 241 242
      if (!isset($this->lazySession) && $cookies->has($insecure_session_name)) {
        $old_insecure_session_id = $cookies->get($insecure_session_name);
      }
      $params = session_get_cookie_params();
      $session_id = Crypt::randomBytesBase64();
      // If a session cookie lifetime is set, the session will expire
      // $params['lifetime'] seconds from the current request. If it is not set,
      // it will expire when the browser is closed.
      $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
      setcookie($insecure_session_name, $session_id, $expire, $params['path'], $params['domain'], FALSE, $params['httponly']);
      $cookies->set($insecure_session_name, $session_id);
    }

    if ($this->isStarted()) {
243
      $old_session_id = $this->getId();
244 245 246
    }
    session_id(Crypt::randomBytesBase64());

247 248 249
    // @todo The token seed can be moved onto \Drupal\Core\Session\MetadataBag.
    //   The session manager then needs to notify the metadata bag when the
    //   token should be regenerated. https://drupal.org/node/2256257
250 251 252 253
    if (!empty($_SESSION)) {
      unset($_SESSION['csrf_token_seed']);
    }

254 255 256
    if (isset($old_session_id)) {
      $params = session_get_cookie_params();
      $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
257 258
      setcookie($this->getName(), $this->getId(), $expire, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
      $fields = array('sid' => Crypt::hashBase64($this->getId()));
259
      if ($is_https) {
260
        $fields['ssid'] = Crypt::hashBase64($this->getId());
261 262
        // If the "secure pages" setting is enabled, use the newly-created
        // insecure session identifier as the regenerated sid.
263
        if ($this->isMixedMode()) {
264 265 266 267 268 269 270 271 272 273 274 275 276
          $fields['sid'] = Crypt::hashBase64($session_id);
        }
      }
      $this->connection->update('sessions')
        ->fields($fields)
        ->condition($is_https ? 'ssid' : 'sid', Crypt::hashBase64($old_session_id))
        ->execute();
    }
    elseif (isset($old_insecure_session_id)) {
      // If logging in to the secure site, and there was no active session on
      // the secure site but a session was active on the insecure site, update
      // the insecure session with the new session identifiers.
      $this->connection->update('sessions')
277
        ->fields(array('sid' => Crypt::hashBase64($session_id), 'ssid' => Crypt::hashBase64($this->getId())))
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327
        ->condition('sid', Crypt::hashBase64($old_insecure_session_id))
        ->execute();
    }
    else {
      // Start the session when it doesn't exist yet.
      // Preserve the logged in user, as it will be reset to anonymous
      // by \Drupal\Core\Session\SessionHandler::read().
      $account = $user;
      $this->start();
      $user = $account;
    }
    date_default_timezone_set(drupal_get_user_timezone());
  }

  /**
   * {@inheritdoc}
   */
  public function delete($uid) {
    // Nothing to do if we are not allowed to change the session.
    if (!$this->isEnabled()) {
      return;
    }
    $this->connection->delete('sessions')
      ->condition('uid', $uid)
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  public function isEnabled() {
    return static::$enabled;
  }

  /**
   * {@inheritdoc}
   */
  public function disable() {
    static::$enabled = FALSE;
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function enable() {
    static::$enabled = TRUE;
    return $this;
  }

328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
  /**
   * {@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);
  }

349 350 351 352 353 354 355 356 357 358 359
  /**
   * Returns whether the current PHP process runs on CLI.
   *
   * Command line clients do not support cookies nor sessions.
   *
   * @return bool
   */
  protected function isCli() {
    return PHP_SAPI === 'cli';
  }

360 361 362 363 364 365 366 367
  /**
   * Determines whether the session contains user data.
   *
   * @return bool
   *   TRUE when the session does not contain any values and therefore can be
   *   destroyed.
   */
  protected function isSessionObsolete() {
368 369 370 371 372 373 374 375 376 377 378 379 380
    $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() {
381
    if (empty($_SESSION)) {
382
      return array();
383 384
    }

385 386 387 388 389 390
    // 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;

391 392 393 394 395 396 397
    // Ignore the CSRF token seed.
    //
    // @todo Anonymous users should not get a CSRF token at any time, or if they
    //   do, then the originating code is responsible for cleaning up the
    //   session once obsolete. Since that is not guaranteed to be the case,
    //   this check force-ignores the CSRF token, so as to avoid performance
    //   regressions.
398 399 400 401 402 403 404 405 406 407 408 409
    //   The token seed can be moved onto \Drupal\Core\Session\MetadataBag. This
    //   will result in the CSRF token being ignored automatically.
    //   https://drupal.org/node/2256257
    $mask['csrf_token_seed'] = FALSE;

    // Ignore attribute bags when they do not contain any data.
    foreach ($this->bags as $bag) {
      $key = $bag->getStorageKey();
      $mask[$key] = empty($_SESSION[$key]);
    }

    return array_intersect_key($mask, $_SESSION);
410 411
  }

412
}