mjackson/mach

View on GitHub
modules/middleware/session.js

Summary

Maintainability
A
1 hr
Test Coverage
var mach = require('../index');
var Promise = require('../utils/Promise');
var decodeBase64 = require('../utils/decodeBase64');
var encodeBase64 = require('../utils/encodeBase64');
var makeHash = require('../utils/makeHash');
var CookieStore = require('./session/CookieStore');

mach.extend(
  require('../extensions/server')
);

/**
 * The maximum size of an HTTP cookie.
 */
var MAX_COOKIE_SIZE = 4096;

/**
 * Stores the given session and returns a promise for a value that should be stored
 * in the session cookie to retrieve the session data again on the next request.
 */
function encodeSession(session, store, secret) {
  return store.save(session).then(function (data) {
    var cookie = encodeBase64(data + '--' + makeHashWithSecret(data, secret));

    if (cookie.length > MAX_COOKIE_SIZE)
      throw new Error('Cookie data size exceeds 4kb; content dropped');

    return cookie;
  });
}

/**
 * Decodes the given cookie value and returns a promise for the corresponding session
 * data from the store. Also verifies the hash value to ensure the cookie has not been
 * tampered with. If it has, returns null.
 */
function decodeCookie(cookie, store, secret) {
  var value = decodeBase64(cookie);
  var index = value.lastIndexOf('--');
  var data = value.substring(0, index);
  var hash = value.substring(index + 2);

  // Verify the cookie has not been tampered with.
  if (hash === makeHashWithSecret(data, secret))
    return store.load(data);

  return null;
}

function makeHashWithSecret(data, secret) {
  return makeHash(secret ? data + secret : data);
}

/**
 * A middleware that provides support for HTTP sessions using cookies.
 *
 * Options may be any of the following:
 *
 * - secret         A cryptographically secure secret key that will be used to verify
 *                  the integrity of session data that is received from the client
 * - name           The name of the cookie. Defaults to "_session"
 * - path           The path of the cookie. Defaults to "/"
 * - domain         The cookie's domain. Defaults to null
 * - secure         True to only send this cookie over HTTPS. Defaults to false
 * - expireAfter    The number of seconds after which sessions expire. Defaults
 *                  to 0 (no expiration)
 * - httpOnly       True to restrict access to this cookie to HTTP(S) APIs.
 *                  Defaults to true
 * - store          An instance of MemoryStore, CookieStore, or RedisStore that
 *                  is used to store session data. Defaults to a new CookieStore
 *
 * Example:
 *
 *   app.use(mach.session, {
 *     secret: 'the-secret',
 *     secure: true
 *   });
 *
 * Hint: A great way to generate a cryptographically secure session secret from
 * the command line:
 *
 *   $ node -p "require('crypto').randomBytes(64).toString('hex')"
 *
 * Note: Since cookies are only able to reliably store about 4k of data, if the
 * session cookie payload exceeds that the session will be dropped.
 */
function session(app, options) {
  options = options || {};

  if (typeof options === 'string')
    options = { secret: options };

  var secret = options.secret;
  var name = options.name || '_session';
  var path = options.path || '/';
  var domain = options.domain;
  var expireAfter = options.expireAfter || 0;
  var httpOnly = ('httpOnly' in options) ? (options.httpOnly || false) : true;
  var secure = options.secure || false;
  var store = options.store || new CookieStore(options);

  if (!secret) {
    console.warn([
      'WARNING: There was no "secret" option provided to mach.session! This poses',
      'a security vulnerability because session data will be stored on clients without',
      'any server-side verification that it has not been tampered with. It is strongly',
      'recommended that you set a secret to prevent exploits that may be attempted using',
      'carefully crafted cookies.'
    ].join('\n'));
  }

  return function (conn) {
    if (conn.session)
      return conn.call(app); // Don't overwrite the existing session.

    var cookie = conn.request.cookies[name];

    return Promise.resolve(cookie && decodeCookie(cookie, store, secret)).then(function (object) {
      conn.session = object || {};

      return conn.call(app).then(function () {
        return Promise.resolve(conn.session && encodeSession(conn.session, store, secret)).then(function (newCookie) {
          var expires = expireAfter && new Date(Date.now() + (expireAfter * 1000));

          // Don't bother setting the cookie if its value
          // hasn't changed and there is no expires date.
          if (newCookie === cookie && !expires)
            return;

          conn.response.setCookie(name, {
            value: newCookie,
            path: path,
            domain: domain,
            expires: expires,
            httpOnly: httpOnly,
            secure: secure
          });
        }, conn.onError);
      });
    }, conn.onError);
  };
}

module.exports = session;