quorrajs/NodeSession

View on GitHub
index.js

Summary

Maintainability
B
6 hrs
Test Coverage
/**
 * index.js
 *
 * @author: Harish Anchu <harishanchu@gmail.com>
 * @copyright 2015, Harish Anchu. All rights reserved.
 * @license Licensed under MIT
 */

var SessionManager = require('./lib/SessionManager');
var onHeaders = require('on-headers');
var _ = require('lodash');
var signature = require('cookie-signature');
var cookie = require('cookie');
var util = require('./lib/util');

/**
 * Create a new NodeSession instance
 *
 * @param {Object} config - session configuration object
 * @param {Object | void} encrypter
 * @constructor
 */
function NodeSession(config, encrypter) {
    var defaults = {
        'driver': 'file',
        'lifetime': 300000, // five minutes
        'expireOnClose': false,
        'files': process.cwd()+'/sessions',
        'connection': false,
        'table': 'sessions',
        'lottery': [2, 100],
        'cookie': 'node_session',
        'path': '/',
        'domain': null,
        'secure': false,
        'httpOnly': true,
        'encrypt': false
    };

    /**
     * The Session configuration
     *
     * @type {Object}
     * @private
     */
    this.__config = _.merge(defaults, config);

    if(this.__config.trustProxy && !this.__config.trustProxyFn) {
        this.__config.trustProxyFn = util.compileTrust(this.__config.trustProxy)
    }

    /**
     * The session manager instance
     * @type {SessionManager}
     * @private
     */
    this.__manager = new SessionManager(this.__config, encrypter);

    if (!this.__config.secret) {
        throw new Error('secret option required for sessions');
    }
}

/**
 * Start session for a given http request - response
 *
 * @param {Object} request - http request object
 * @param {Object} response - http response object
 * @param {function} callback
 */
NodeSession.prototype.startSession = function (request, response, callback) {
    var self = this;
    var end = response.end;
    var ended = false;

    // Set cookie to response headers before headers are sent
    onHeaders(response, function () {
        self.__addCookieToResponse(request, response);
    });

    // Proxy response.end to close session before request end
    response.end = function () {
        var endArguments = arguments;

        if (ended) {
            return false;
        }

        ended = true;

        self.__closeSession(request.session, function (err) {
            if(err) {
                throw err;
            }
            end.apply(response, endArguments);
        });

    };

    // start the session for the request
    this.__startSession(request, callback);
};

/**
 * Start the session for the given request.
 *
 * @param {Object} request - http request object
 * @param {function} callback
 * @private
 */
NodeSession.prototype.__startSession = function (request, callback) {
    this.getSession(request, function(session) {
        request.session = session;

        session.start(callback);
    });
};

/**
 * Get the session implementation from the manager.
 *
 * @param {Object} request - http request object
 * @param {function} callback - callback to return session object
 */
NodeSession.prototype.getSession = function (request, callback) {
    var self = this;
    this.__manager.driver(null, function(session){
        session.setId(self.__getCookie(request, session.getName()));

        callback(session);
    });
};

/**
 * Add the session cookie to the application response.
 *
 * @param {Object} request - http request object
 * @param {Object} response - http response object
 * @private
 */
NodeSession.prototype.__addCookieToResponse = function (request, response) {
    var config = this.__config;
    var session = request.session;
    var maxAge = this.__getCookieLifetime();
    var data = {
        signed: true,
        path: config.path,
        domain: config.domain,
        secure: config.secure,
        httpOnly: config.httpOnly
    };

    // maxAge = 0 => cookie expire on browser close.
    // so no need to set maxAge.
    if (maxAge !== 0) {
        data.maxAge = maxAge;
    }

    this.__setCookie(
        request,
        response,
        session.getName(),
        session.getId(),
        data
    );
};

/**
 * Get the cookie lifetime in seconds.
 *
 * @return {Number}
 * @private
 */
NodeSession.prototype.__getCookieLifetime = function () {
    var config = this.__config;

    return config.expireOnClose ? 0 : config.lifetime;
};

/**
 * Closes the given session.
 *
 * @param {Object} session - the session object
 * @param {function} callback
 * @private
 */
NodeSession.prototype.__closeSession = function (session, callback) {
    session.save(callback);
    //@todo: note callback is executed after save, not waiting for garbage collection to complete
    this.__collectGarbage(session);
};

/**
 * Remove the garbage from the session if necessary.
 *
 * @param {Object} session - the session object
 * @private
 */
NodeSession.prototype.__collectGarbage = function (session) {
    // Here we will see if this request hits the garbage collection lottery by hitting
    // the odds needed to perform garbage collection on any given request. If we do
    // hit it, we'll call this handler to let it delete all the expired sessions.
    if (this.__configHitsLottery()) {
        session.getHandler().gc && session.getHandler().gc(this.__config.lifetime);
    }
};

/**
 * Determine if the configuration odds hit the lottery.
 *
 * @return {Boolean}
 * @private
 */
NodeSession.prototype.__configHitsLottery = function () {
    return (_.random(1, this.__config['lottery'][1]) <= this.__config['lottery'][0]);
};

/**
 * Add session cookie to response
 *
 * @param {Object} request - http request object
 * @param {Object} response - http response object
 * @param {String} name - cookie name
 * @param {*} val - cookie value
 * @param {*} options
 * @private
 */
NodeSession.prototype.__setCookie = function (request, response, name, val, options) {
    options = _.merge({}, options);

    // only send secure cookies via https
    if (!(options.secure && !this.__isSecure(request))) {
        var secret = this.__config.secret;
        var signed = options.signed;

        if (signed && !secret) {
            throw new Error('An encryption key is required for signed cookies');
        }

        if ('number' == typeof val) {
            val = val.toString();
        }

        if ('object' == typeof val) {
            val = 'j:' + JSON.stringify(val);
        }

        if (signed) {
            val = 's:' + signature.sign(val, secret);
        }

        if ('maxAge' in options) {
            options.expires = new Date(Date.now() + options.maxAge);
            options.maxAge /= 1000;
        }

        if (null == options.path) {
            options.path = '/';
        }

        var headerVal = cookie.serialize(name, String(val), options);

        // supports multiple 'setCookie' calls by getting previous value
        var prev = response.getHeader('set-cookie') || [];
        var header = Array.isArray(prev) ? prev.concat(headerVal)
            : Array.isArray(headerVal) ? [prev].concat(headerVal)
            : [prev, headerVal];

        response.setHeader('set-cookie', header);
    }
};

/**
 * Check whether request is secure
 *
 * @param {Object} request - http request object
 * @return {boolean}
 * @private
 */
NodeSession.prototype.__isSecure = function (request) {
    var proto;

    // socket is https server
    if (request.connection && request.connection.encrypted) {
        proto = 'https'
    } else {
        proto = 'http';
    }

    if (this.__config.trustProxy &&
        this.__config.trustProxyFn &&
        this.__config.trustProxyFn(request.connection.remoteAddress, 0)) {
        // Note: X-Forwarded-Proto is normally only ever a
        //       single value, but this is to be safe.
        // read the proto from x-forwarded-proto header
        var header = request.headers['x-forwarded-proto'] || '';
        var index = header.indexOf(',');
        proto = (index !== -1
            ? header.substr(0, index).toLowerCase().trim()
            : header.toLowerCase().trim()) || proto;
    }

    return proto === 'https';
};

/**
 * Get the session ID cookie from request.
 *
 * @return {string} session id
 * @private
 */
NodeSession.prototype.__getCookie = function (request, name) {
    // if signed cookie is already present in request(means cookie
    // parsing is already done), we will use it straight.
    if (request.signedCookies) {
        return request.signedCookies[name];
    }

    var header = request.headers.cookie;
    var raw;
    var val;

    // read from cookie header
    if (header) {
        var cookies = cookie.parse(header);

        raw = cookies[name];

        if (raw) {
            if (raw.substr(0, 2) === 's:') {
                val = this.__unsignCookie(raw.slice(2));

                if (val === false) {
                    //console.error('cookie signature invalid');
                    val = undefined;
                }
            }
        }
    }

    return val;
};

/**
 * Unsign a cookie value
 *
 * @param {String} val
 * @returns {String|Boolean}
 * @private
 */
NodeSession.prototype.__unsignCookie = function (val) {
    return signature.unsign(val, this.__config.secret);
};

/**
 * Update session manager encrypter service.
 *
 * @param {Object} encrypter
 */
NodeSession.prototype.setEncrypter = function (encrypter) {
    this.__manager.setEncrypter(encrypter);
};


module.exports = NodeSession;