Drupal 7 bootstrap explained: DRUPAL_BOOTSTRAP_SESSION and Drupal's session handling

I looked for articles going through the Drupal bootstrap in detail and found some. Unfortunately, they all seemed to stop before actually getting all the bootstrap phases covered. I'm going to continue where Be Circle's Andre Molnar left off and cover DRUPAL_BOOTSTRAP_SESSION in this article. Go read the excellent Drupal 7 Line by Line article series to get an overview and description of the bootstrap phases that are run before this one.

DRUPAL_BOOTSTRAP_SESSION is a phase where Drupal takes care of setting up the session if one is needed. In order to do that, the bootstrap process includes the session.inc file and calls drupal_session_initialize() at first.

<?php
  session_set_save_handler
('_drupal_session_open', '_drupal_session_close', '_drupal_session_read', '_drupal_session_write', '_drupal_session_destroy', '_drupal_session_garbage_collection');

  // We use !empty() in the following check to ensure that blank session IDs
  // are not valid.
 
if (!empty($_COOKIE[session_name()]) || ($is_https && variable_get('https', FALSE) && !empty($_COOKIE[substr(session_name(), 1)]))) {
?>

drupal_session_initialize() sets it's own database-using session handlers. Next the function checks for a session cookie. For sites that have HTTP and HTTPS simultaneously enabled, there are two session cookies. The session cookie identifier is set at as part of DRUPAL_BOOTSTRAP_CONFIGURATION and is otherwise the same but has a prefix of SESS for HTTP requests and a prefix of SSESS for HTTPS. Both need to exist and this is why the code checks also for a cookie with the first character stripped when serving HTTPS requests.

<?php
   
// If a session cookie exists, initialize the session. Otherwise the
    // session is only started on demand in drupal_session_commit(), making
    // anonymous users not use a session cookie unless something is stored in
    // $_SESSION. This allows HTTP proxies to cache anonymous pageviews.
   
drupal_session_start();
    if (!empty(
$user->uid) || !empty($_SESSION)) {
     
drupal_page_is_cacheable(FALSE);
    }
  }
?>

If a session cookie is found, this request is part of a session started in a previous request. Only if it is, the session is initialized here by calling drupal_session_start(). This way those browsing the site anonymously won't get a session cookie. The page cache is also disabled here for users that are not anonymous.

drupal_session_start() is just a wrapper for PHP's session_start(). If it is called it will record the fact that a session has been started in a static variable by calling drupal_session_started(TRUE) in addition to calling the function provided by PHP. session_start() will call previously set open and read session save handlers _drupal_session_open() and _drupal_session_read().

_drupal_session_open() would be used like a class constructor, for example to open a database connection but drupal doesn't do anything with it, the function always returns true.

<?php
function _drupal_session_read($sid) {
  global
$user, $is_https;

  // Write and Close handlers are called after destructing objects
  // since PHP 5.0.5.
  // Thus destructors can use sessions but session handler can't use objects.
  // So we are moving session closure before destructing objects.
 
drupal_register_shutdown_function('session_write_close');
?>

_drupal_session_read() main tasks are to read the session data from the database and initialize the user object. First it sets a callback that will close the session with drupal_register_shutdown_function(), a wrapper around PHP's register_shutdown_function(). The database handling in Drupal 7 uses object-oriented code so objects need to be used when closing the session and _drupal_session_close() callback would be called only after the objects have been destroyed.

<?php
 
// Handle the case of first time visitors and clients that don't store
  // cookies (eg. web crawlers).
 
$insecure_session_name = substr(session_name(), 1);
  if (!isset(
$_COOKIE[session_name()]) && !isset($_COOKIE[$insecure_session_name])) {
   
$user = drupal_anonymous_user();
    return
'';
  }
?>

Afterwards, the function also checks that the session cookie(s) are set like drupal_session_initialize() earlier did and if not it initializes the global $user object to represent the anonymous user with drupal_anonymous_user(). For a session that was carried over from a previous request this is redundant but probably not for a newly created one.

<?php
 
// Otherwise, if the session is still active, we have a record of the
  // client's session in the database. If it's HTTPS then we are either have
  // a HTTPS session or we are about to log in so we check the sessions table
  // for an anonymous session with the non-HTTPS-only cookie.
 
if ($is_https) {
   
$user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.ssid = :ssid", array(':ssid' => $sid))->fetchObject();
    if (!
$user) {
      if (isset(
$_COOKIE[$insecure_session_name])) {
       
$user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.sid = :sid AND s.uid = 0", array(
       
':sid' => $_COOKIE[$insecure_session_name]))
        ->
fetchObject();
      }
    }
  }
  else {
   
$user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.sid = :sid", array(':sid' => $sid))->fetchObject();
  }
?>

Next, the database is queried for the user and session data. This works the same for http and https except if "we are about to login" in which case there will be an anonymous (s.uid = 0) session with the non-HTTPS cookie in the sessions table that we'll retrieve.

<?php

  // We found the client's session record and they are an authenticated,
  // active user.
 
if ($user && $user->uid > 0 && $user->status == 1) {
   
// This is done to unserialize the data member of $user.
   
$user->data = unserialize($user->data);

    // Add roles element to $user.
   
$user->roles = array();
   
$user->roles[DRUPAL_AUTHENTICATED_RID] = 'authenticated user';
   
$user->roles += db_query("SELECT r.rid, r.name FROM {role} r INNER JOIN {users_roles} ur ON ur.rid = r.rid WHERE ur.uid = :uid", array(':uid' => $user->uid))->fetchAllKeyed(0, 1);
  }
?>

The user roles are added to the user object if we have a user that is authenticated and not blocked at this point. The authenticated user role is always added, others are fetched from the database.

<?php
 
elseif ($user) {
   
// The user is anonymous or blocked. Only preserve two fields from the
    // {sessions} table.
   
$account = drupal_anonymous_user();
   
$account->session = $user->session;
   
$account->timestamp = $user->timestamp;
   
$user = $account;
  }
?>

Alternatively, if we have a user but it's either anonymous (there is a session cookie) or blocked, the user object is again set to represent the anonymous user. Only the session (data stored modules for the duration of the session) and timestamp (the last time the user received a completed page) properties are set to reflect the values fetched for the session from the database so we end up with an anonymous user with those values.

<?php
 
else {
   
// The session has expired.
   
$user = drupal_anonymous_user();
   
$user->session = '';
  }

  // Store the session that was read for comparison in _drupal_session_write().
 
$last_read = &drupal_static('drupal_session_last_read');
 
$last_read = array(
   
'sid' => $sid,
   
'value' => $user->session,
  );

  return $user->session;
}
?>

The final case is that the session cookie contained a session id that was not found in the database, _drupal_session_read() just assumes that the session was expired, sets the user object to anonymous and also sets $user->session to empty so that all the cases can be handled the same way when doing the next step.

Finally in all the three cases the session is saved with drupal_static() so that it can be compared to the one at the end of the session in _drupal_write_session().

<?php
 
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.
   
$GLOBALS['lazy_session'] = TRUE;
   
$user = drupal_anonymous_user();
   
// Less random sessions (which are much faster to generate) are used for
    // anonymous users than are generated in drupal_session_regenerate() when
    // a user becomes authenticated.
   
session_id(drupal_hash_base64(uniqid(mt_rand(), TRUE)));
    if (
$is_https && variable_get('https', FALSE)) {
     
$insecure_session_name = substr(session_name(), 1);
     
$session_id = drupal_hash_base64(uniqid(mt_rand(), TRUE));
     
$_COOKIE[$insecure_session_name] = $session_id;
    }
  }
 
date_default_timezone_set(drupal_get_user_timezone());
}
?>

Back in drupal_initialize_session() a session id is set even if session cookie was not found and no session started. This is because things like drupal_get_token() require it in advance even though new sessions are lazily started only at the end of the request. The user object is once again also set to the anonymous user. The session id that is generated here is less random than the one generated for authenticated users and will be changed with drupal_session_regenerate() when the anonymous user becomes authenticated.

Lastly, date_default_timezone_set(drupal_get_user_timezone()) is called so that the current user's timezone will be used.

The other session handlers that were registered with PHP are _drupal_session_close(), _drupal_session_write(), _drupal_session_destroy() and _drupal_session_garbage_collection().

_drupal_session_close() is similar to _drupal_session_open() in that it is not really used and only returns TRUE.

<?php
function _drupal_session_write($sid, $value) {
  global
$user, $is_https;

  // The exception handler is not active at this point, so we need to do it
  // manually.
 
try {
    if (!
drupal_save_session()) {
     
// We don't have anything to do if we are not allowed to save the session.
     
return;
    }

?>

_drupal_session_write() starts by calling drupal_save_session() to check that saving of session data is allowed. You can read more on the use of this function.

<?php
   
// Check whether $_SESSION has been changed in this request.
   
$last_read = &drupal_static('drupal_session_last_read');
   
$is_changed = !isset($last_read) || $last_read['sid'] != $sid || $last_read['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.
   
if ($is_changed || !isset($user->timestamp) || REQUEST_TIME - $user->timestamp > variable_get('session_write_interval', 180)) {
     
// Either ssid or sid or both will be added from $key below.
     
$fields = array(
       
'uid' => $user->uid,
       
'cache' => isset($user->cache) ? $user->cache : 0,
       
'hostname' => ip_address(),
       
'session' => $value,
       
'timestamp' => REQUEST_TIME,
      );
?>

If saving the session is indeed allowed, the current session is compared to the one saved at the beginning of the request in _drupal_session_read(). For performance reasons the session is only resaved to the database if it has either changed or at least 180 seconds has elapsed since the last save (timestamp value explained earlier).

<?php
     
// Use the session ID as 'sid' and an empty string as 'ssid' by default.
      // _drupal_session_read() does not allow empty strings so that's a safe
      // default.
     
$key = array('sid' => $sid, 'ssid' => '');
?>

The comment about _drupal_session_read() is not correct as far as I can tell but it is true that drupal_session_initialize() does disallow empty values. The session id is put to the keys array from the $sid parameter while ssid is empty by default. This is for regular HTTP requests on a site that doesn't simultaneously support HTTPS.

<?php
     
// On HTTPS connections, use the session ID as both 'sid' and 'ssid'.
     
if ($is_https) {
       
$key['ssid'] = $sid;
       
// The "secure pages" setting allows a site to simultaneously use both
        // secure and insecure session cookies. If enabled and both cookies are
        // presented then use both keys.
       
if (variable_get('https', FALSE)) {
         
$insecure_session_name = substr(session_name(), 1);
          if (isset(
$_COOKIE[$insecure_session_name])) {
          
$key['sid'] = $_COOKIE[$insecure_session_name];
          }
        }
      }
      elseif (
variable_get('https', FALSE)) {
        unset(
$key['ssid']);
      }
?>

For HTTPS requests, the received session id in $sid is set to be both 'sid' and 'ssid'.

Sites that are using the secure pages feature (allows the site to be used through both HTTP and HTTPS at the same time) handle HTTPS requests differently. The previously set 'sid' is then replaced with the actual HTTP session id retrieved from the $_SESSION superglobal instead of having the secure session id in both keys.

If this is not a HTTPS request and the site has secure pages enabled, 'ssid' is unset from the $key array.

<?php
      db_merge
('sessions')
        ->
key($key)
        ->
fields($fields)
        ->
execute();
    }

    // Likewise, do not update access time more than once per 180 seconds.
   
if ($user->uid && REQUEST_TIME - $user->access > variable_get('session_write_interval', 180)) {
     
db_update('users')
        ->
fields(array(
         
'access' => REQUEST_TIME
       
))
        ->
condition('uid', $user->uid)
        ->
execute();
    }

    return TRUE;
  }
?>

Next the session is either updated or inserted into the sessions table in the database. Finally the access timestamp for the user in the users table is updated if 180 seconds or more has passed since the last update.

_drupal_session_destroy() is another handler that is called when the session is to be destroyed. It gets called when session_destroy() is called or session_regenerate_id() gets called with the destroy parameter set to TRUE. It removes the session from the database, resets the $_SESSION superglobal to an empty array and the user object to anonymous. Finally it calls _drupal_session_delete_cookie() to delete the session cookies.

The final callback is _drupal_session_garbage_collection() and it's called periodically by PHP to clean expired sessions from the sessions table, if your PHP is properly configured. Most Linux distros actually have this garbage collection disabled by default. One way to fix it is to set something like this in settings.php:

<?php
ini_set
('session.cookie_lifetime', 0); // browser cookie deletion on browser close
ini_set('session.gc_maxlifetime', 600); // 10 minutes
ini_set('session.gc_probability', 1); // see next line...
ini_set('session.gc_divisor', 100); // in combination with previous
?>

We've now looked at what Drupal does in the DRUPAL_BOOTSTRAP_SESSION phase of the bootstrap process. Some session handling functions not part of the bootstrap process and how the Drupal login process works is a related topic that I may return to later in another post.

Tagit: 

Add new comment

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
CAPTCHA
This question is for testing whether you are a human visitor and to prevent automated spam submissions.
Fill in the blank.