diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index bc14262..ce358bd 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -1,23 +1,42 @@ +/*globals escape unescape */ + var crypto = require('crypto'); var url = require('url'); -var exports = module.exports = function(settings){ + +// Extend a given object with all the properties in passed-in object(s). +// From underscore.js (http://documentcloud.github.com/underscore/) +function extend(obj) { + Array.prototype.slice.call(arguments).forEach(function(source) { + for (var prop in source) obj[prop] = source[prop]; + }); + return obj; +} + +var exports; +exports = module.exports = function(settings){ var default_settings = { // don't set a default cookie secret, must be explicitly defined session_key: '_node', - timeout: 1000 * 60 * 60 * 24, // 24 hours - path: '/' + timeout: 60 * 60 * 24 * 1000, // 24 hours in milliseconds + path: '/', + domain: null, + secure: false, + useMaxAge: true, + useExpires: true, + useHttpOnly: true }; var s = extend(default_settings, settings); if(!s.secret) throw new Error('No secret set in cookie-session settings'); - if(typeof s.path !== 'string' || s.path.indexOf('/') != 0) + if(typeof s.path !== 'string' || s.path.indexOf('/') !== 0) { throw new Error('invalid cookie path, must start with "/"'); + } return function(req, res, next){ // if the request is not under the specified path, do nothing. - if (url.parse(req.url).pathname.indexOf(s.path) != 0) { + if (url.parse(req.url).pathname.indexOf(s.path) !== 0) { next(); return; } @@ -47,29 +66,32 @@ var exports = module.exports = function(settings){ var cookiestr; if (req.session === undefined) { if ("cookie" in req.headers) { - cookiestr = escape(s.session_key) + '=' - + '; expires=' + exports.expires(0) - + '; path=' + s.path + '; HttpOnly'; + cookiestr = escape(s.session_key) + '='; + s.timeout = 0; } } else { - cookiestr = escape(s.session_key) + '=' - + escape(exports.serialize(s.secret, req.session)) - + '; expires=' + exports.expires(s.timeout) - + '; path=' + s.path + '; HttpOnly'; + cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)); } - + if (cookiestr !== undefined) { - if(Array.isArray(headers)) headers.push(['Set-Cookie', cookiestr]); - else { + if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout); + if (s.useMaxAge) cookiestr += '; max-age=' + (s.timeout / 1000); // In seconds + if (s.path) cookiestr += '; path=' + s.path; + if (s.domain) cookiestr += '; domain=' + s.domain; + if (s.secure) cookiestr += '; secure'; + if (s.useHttpOnly) cookiestr += '; HttpOnly'; + + if(Array.isArray(headers)) { + headers.push(['Set-Cookie', cookiestr]); + } else { // if a Set-Cookie header already exists, convert headers to // array so we can send multiple Set-Cookie headers. - if(headers['Set-Cookie'] !== undefined){ + if (headers['Set-Cookie'] !== undefined) { headers = exports.headersToArray(headers); headers.push(['Set-Cookie', cookiestr]); - } - // if no Set-Cookie header exists, leave the headers as an - // object, and add a Set-Cookie property - else { + } else { + // if no Set-Cookie header exists, leave the headers as an + // object, and add a Set-Cookie property headers['Set-Cookie'] = cookiestr; } } @@ -81,7 +103,7 @@ var exports = module.exports = function(settings){ } // call the original writeHead on the request return _writeHead.apply(res, args); - } + }; next(); }; @@ -95,21 +117,11 @@ exports.headersToArray = function(headers){ }, []); }; - -// Extend a given object with all the properties in passed-in object(s). -// From underscore.js (http://documentcloud.github.com/underscore/) -function extend(obj) { - Array.prototype.slice.call(arguments).forEach(function(source) { - for (var prop in source) obj[prop] = source[prop]; - }); - return obj; -}; - exports.deserialize = function(secret, timeout, str){ // Parses a secure cookie string, returning the object stored within it. // Throws an exception if the secure cookie string does not validate. - if(!exports.valid(secret, timeout, str)){ + if(!exports.valid(secret, timeout, str)) { throw new Error('invalid cookie'); } var data = exports.decrypt(secret, exports.split(str).data_blob); @@ -121,7 +133,7 @@ exports.serialize = function(secret, data){ var data_str = JSON.stringify(data); var data_enc = exports.encrypt(secret, data_str); - var timestamp = (new Date()).getTime(); + var timestamp = new Date().getTime(); var hmac_sig = exports.hmac_signature(secret, timestamp, data_enc); var result = hmac_sig + timestamp + data_enc; if(!exports.checkLength(result)){ @@ -156,6 +168,7 @@ exports.valid = function(secret, timeout, str){ var hmac_sig = exports.hmac_signature( secret, parts.timestamp, parts.data_blob ); + return ( parts.hmac_signature === hmac_sig && parts.timestamp + timeout > new Date().getTime() @@ -184,21 +197,20 @@ exports.readCookies = function(req){ // will already contain the parsed cookies if (req.cookies) { return req.cookies; + } else { + // Extracts the cookies from a request object. + var cookie = req.headers.cookie; + if(!cookie){ + return {}; } - else { - // Extracts the cookies from a request object. - var cookie = req.headers.cookie; - if(!cookie){ - return {}; - } - var parts = cookie.split(/\s*;\s*/g).map(function(x){ - return x.split('='); - }); - return parts.reduce(function(a, x){ - a[unescape(x[0])] = unescape(x[1]); - return a; - }, {}); - } + var parts = cookie.split(/\s*;\s*/g).map(function(x){ + return x.split('='); + }); + return parts.reduce(function(a, x){ + a[unescape(x[0])] = unescape(x[1]); + return a; + }, {}); + } }; exports.readSession = function(key, secret, timeout, req){ @@ -212,7 +224,8 @@ exports.readSession = function(key, secret, timeout, req){ return undefined; }; - +// Generates an expires date +// @params timeout the time in milliseconds before the cookie expires exports.expires = function(timeout){ - return (new Date(new Date().getTime() + (timeout))).toUTCString(); + return new Date(new Date().getTime() + timeout).toUTCString(); }; diff --git a/test.js b/test.js index f41d154..95752f4 100755 --- a/test.js +++ b/test.js @@ -5,7 +5,7 @@ require.paths.push(__dirname + '/deps'); require.paths.push(__dirname + '/lib'); try { - var testrunner = require('nodeunit').testrunner; + var testrunner = require('nodeunit').reporters.default; } catch(e) { var sys = require('sys'); diff --git a/test/test-cookie-sessions.js b/test/test-cookie-sessions.js index c43dc87..f93dcde 100644 --- a/test/test-cookie-sessions.js +++ b/test/test-cookie-sessions.js @@ -319,14 +319,14 @@ exports['onRequest'] = function(test){ var s = { session_key:'_node', secret: 'secret', - timeout: 86400 + timeout: 86400000 }; var req = {url: '/'}; sessions.readSession = function(key, secret, timeout, req){ test.equals(key, '_node', 'readSession called with session key'); test.equals(secret, 'secret', 'readSession called with secret'); - test.equals(timeout, 86400, 'readSession called with timeout'); + test.equals(timeout, 86400000, 'readSession called with timeout'); return 'testsession'; }; var next = function(){ @@ -348,7 +348,7 @@ exports['writeHead'] = function(test){ var s = { session_key:'_node', secret: 'secret', - timeout: 86400 + timeout: 86400000 }; var req = {headers: {cookie: "_node="}, url: '/'}; var res = { @@ -357,6 +357,7 @@ exports['writeHead'] = function(test){ headers['Set-Cookie'], '_node=serialized_session; ' + 'expires=expiry_date; ' + + 'max-age=86400; ' + 'path=/; HttpOnly' ); test.equals(headers['original'], 'header'); @@ -394,7 +395,7 @@ exports['writeHead doesnt write cookie if none exists and session is undefined'] var s = { session_key:'_node', secret: 'secret', - timeout: 86400 + timeout: 86400000 }; var req = {headers: {}, url: '/'}; var res = { @@ -419,7 +420,7 @@ exports['writeHead writes empty cookie with immediate expiration if session is u var s = { session_key:'_node', secret: 'secret', - timeout: 86400 + timeout: 86400000 }; var req = {headers: {cookie: "_node=Blah"}, url: '/'}; var res = { @@ -428,6 +429,7 @@ exports['writeHead writes empty cookie with immediate expiration if session is u headers['Set-Cookie'], '_node=; ' + 'expires=now; ' + + 'max-age=0; ' + 'path=/; HttpOnly' ); test.equals(headers['original'], 'header'); @@ -488,7 +490,7 @@ exports['set multiple cookies'] = function(test){ var _expires = sessions.expires; sessions.expires = function(timeout){ - test.equals(timeout, 12345); + test.equals(timeout, 12345000); return 'expiry_date'; }; @@ -500,6 +502,7 @@ exports['set multiple cookies'] = function(test){ ['Set-Cookie', 'testcookie=testvalue'], ['Set-Cookie', '_node=session_data; ' + 'expires=expiry_date; ' + + 'max-age=12345; ' + 'path=/; HttpOnly'] ]); sessions.serialize = _serialize; @@ -507,7 +510,7 @@ exports['set multiple cookies'] = function(test){ test.done(); }}; - sessions({secret: 'secret', timeout: 12345})(req, res, function(){ + sessions({secret: 'secret', timeout: 12345000})(req, res, function(){ req.session = {test: 'test'}; res.writeHead(200, { 'other_header': 'val', @@ -525,7 +528,7 @@ exports['set single cookie'] = function(test){ var _expires = sessions.expires; sessions.expires = function(timeout){ - test.equals(timeout, 12345); + test.equals(timeout, 12345000); return 'expiry_date'; }; @@ -536,13 +539,14 @@ exports['set single cookie'] = function(test){ 'other_header': 'val', 'Set-Cookie': '_node=session_data; ' + 'expires=expiry_date; ' + + 'max-age=12345; ' + 'path=/; HttpOnly' }); sessions.serialize = _serialize; sessions.expires = _expires; test.done(); }}; - sessions({secret: 'secret', timeout: 12345})(req, res, function(){ + sessions({secret: 'secret', timeout: 12345000})(req, res, function(){ req.session = {test: 'test'}; res.writeHead(200, {'other_header': 'val'}); }); @@ -557,7 +561,7 @@ exports['handle headers as array'] = function(test){ var _expires = sessions.expires; sessions.expires = function(timeout){ - test.equals(timeout, 12345); + test.equals(timeout, 12345000); return 'expiry_date'; }; @@ -569,12 +573,13 @@ exports['handle headers as array'] = function(test){ ['header2', 'val2'], ['Set-Cookie', '_node=session_data; ' + 'expires=expiry_date; ' + + 'max-age=12345; ' + 'path=/; HttpOnly'] ]); sessions.serialize = _serialize; test.done(); }}; - sessions({secret: 'secret', timeout: 12345})(req, res, function(){ + sessions({secret: 'secret', timeout: 12345000})(req, res, function(){ req.session = {test: 'test'}; res.writeHead(200, [['header1', 'val1'],['header2', 'val2']]); }); @@ -602,7 +607,7 @@ exports['send cookies even if there are no headers'] = function (test) { test.done(); } }; - sessions({secret: 'secret', timeout: 12345})(req, res, function () { + sessions({secret: 'secret', timeout: 12345000})(req, res, function () { req.session = {test: 'test'}; res.writeHead(200); }); @@ -619,7 +624,7 @@ exports['send cookies when no headers but reason_phrase'] = function (test) { test.done(); } }; - sessions({secret: 'secret', timeout: 12345})(req, res, function () { + sessions({secret: 'secret', timeout: 12345000})(req, res, function () { req.session = {test: 'test'}; res.writeHead(200, 'reason'); }); @@ -637,7 +642,7 @@ exports['custom path'] = function (test) { }; sessions({ secret: 'secret', - timeout: 12345, + timeout: 12345000, path: '/test/path' })(req, res, function () { req.session = {test: 'test'}; @@ -657,10 +662,136 @@ exports['don\'t set cookie if incorrect path'] = function (test) { }; sessions({ secret: 'secret', - timeout: 12345, + timeout: 12345000, path: '/test/path' })(req, res, function () { req.session = {test: 'test'}; res.writeHead(200, {'other_header': 'val'}); }); }; + +exports['custom domain'] = function (test) { + test.expect(2); + var req = {headers: {cookie:''}, url: '/'}; + var res = { + writeHead: function (code, headers) { + test.equal(code, 200); + test.ok(/domain=testdomain.com/.test(headers['Set-Cookie'])); + test.done(); + } + }; + sessions({ + secret: 'secret', + domain: 'testdomain.com' + })(req, res, function () { + req.session = {test: 'test'}; + res.writeHead(200, {'other_header': 'val'}); + }); +}; + +exports['secure'] = function (test) { + test.expect(2); + var req = {headers: {cookie:''}, url: '/'}; + var res = { + writeHead: function (code, headers) { + test.equal(code, 200); + test.ok(/secure;/.test(headers['Set-Cookie'])); + test.done(); + } + }; + sessions({ + secret: 'secret', + secure: true + })(req, res, function () { + req.session = {test: 'test'}; + res.writeHead(200, {'other_header': 'val'}); + }); +}; + +exports['useExpires: false'] = function(test){ + test.expect(2); + var _serialize = sessions.serialize; + sessions.serialize = function(){ + return 'session_data'; + }; + + var req = {headers: {cookie:''}, url: '/'}; + var res = {writeHead: function(statusCode, headers){ + test.equals(statusCode, 200); + test.same(headers, { + 'other_header': 'val', + 'Set-Cookie': '_node=session_data; ' + + 'max-age=12345; ' + + 'path=/; HttpOnly' + }); + sessions.serialize = _serialize; + test.done(); + }}; + sessions({secret: 'secret', timeout: 12345000, useExpires: false})(req, res, function(){ + req.session = {test: 'test'}; + res.writeHead(200, {'other_header': 'val'}); + }); +}; + +exports['useMaxAge: false'] = function(test){ + test.expect(3); + var _serialize = sessions.serialize; + sessions.serialize = function(){ + return 'session_data'; + }; + + var _expires = sessions.expires; + sessions.expires = function(timeout){ + test.equals(timeout, 12345000); + return 'expiry_date'; + }; + var req = {headers: {cookie:''}, url: '/'}; + var res = {writeHead: function(statusCode, headers){ + test.equals(statusCode, 200); + test.same(headers, { + 'other_header': 'val', + 'Set-Cookie': '_node=session_data; ' + + 'expires=expiry_date; ' + + 'path=/; HttpOnly' + }); + sessions.serialize = _serialize; + sessions.expires = _expires; + test.done(); + }}; + sessions({secret: 'secret', timeout: 12345000, useMaxAge: false})(req, res, function(){ + req.session = {test: 'test'}; + res.writeHead(200, {'other_header': 'val'}); + }); +}; + +exports['useHttpOnly: false'] = function(test){ + test.expect(3); + var _serialize = sessions.serialize; + sessions.serialize = function(){ + return 'session_data'; + }; + + var _expires = sessions.expires; + sessions.expires = function(timeout){ + test.equals(timeout, 12345000); + return 'expiry_date'; + }; + var req = {headers: {cookie:''}, url: '/'}; + var res = {writeHead: function(statusCode, headers){ + test.equals(statusCode, 200); + test.same(headers, { + 'other_header': 'val', + 'Set-Cookie': '_node=session_data; ' + + 'expires=expiry_date; ' + + 'max-age=12345; ' + + 'path=/' + }); + sessions.serialize = _serialize; + sessions.expires = _expires; + test.done(); + }}; + sessions({secret: 'secret', timeout: 12345000, useHttpOnly: false})(req, res, function(){ + req.session = {test: 'test'}; + res.writeHead(200, {'other_header': 'val'}); + }); +}; \ No newline at end of file