From 9b478881e41105dfe4d97e6512f1c48fbeaa3b0e Mon Sep 17 00:00:00 2001 From: Public Keating Date: Fri, 13 May 2011 09:30:22 -0600 Subject: [PATCH 01/14] Code cleanup to keep jslint from complaining. No functional changes. --- lib/cookie-sessions.js | 79 ++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index bc14262..0452adf 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -1,7 +1,20 @@ +/*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 @@ -12,12 +25,13 @@ var exports = module.exports = function(settings){ 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; } @@ -57,19 +71,19 @@ var exports = module.exports = function(settings){ + '; expires=' + exports.expires(s.timeout) + '; path=' + s.path + '; HttpOnly'; } - + if (cookiestr !== undefined) { - if(Array.isArray(headers)) headers.push(['Set-Cookie', cookiestr]); - else { + 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 +95,7 @@ var exports = module.exports = function(settings){ } // call the original writeHead on the request return _writeHead.apply(res, args); - } + }; next(); }; @@ -95,16 +109,6 @@ 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. @@ -121,7 +125,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)){ @@ -184,21 +188,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){ @@ -214,5 +217,5 @@ exports.readSession = function(key, secret, timeout, req){ exports.expires = function(timeout){ - return (new Date(new Date().getTime() + (timeout))).toUTCString(); + return new Date(new Date().getTime() + (timeout)).toUTCString(); }; From 292749be0b749a5250c7fd7901591035a3dc3834 Mon Sep 17 00:00:00 2001 From: Public Keating Date: Fri, 13 May 2011 10:12:26 -0600 Subject: [PATCH 02/14] I added a few options for tuning the cookies. You can now set the 'domain' and make the cookie 'secure' as well as 'max-age' will be sent if useMaxAge is true. Just to be complete, I made it so that you could also selectively turn on/off setting 'expires' and 'HttpOnly' with useExpires and useHttpOnly options, but the defaults shouldn't change any existing uses of cookie-sessions, except that useMaxAge is true by default. --- lib/cookie-sessions.js | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index 0452adf..e53f894 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -19,8 +19,13 @@ 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, // 24 hours in seconds + 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'); @@ -61,19 +66,22 @@ 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 (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout * 1000); // In milliseconds + if (s.useMaxAge) cookiestr += '; max-age=' + s.timeout; // 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 (cookiestr !== undefined) { - if(Array.isArray(headers)) { + if(Array.isArray(headers)) { headers.push(['Set-Cookie', cookiestr]); } else { // if a Set-Cookie header already exists, convert headers to From cd8bd61bd09e484c7d2756a67036e2a4a5d148ec Mon Sep 17 00:00:00 2001 From: Public Keating Date: Fri, 13 May 2011 10:51:27 -0600 Subject: [PATCH 03/14] Don't set cookieStr parameters if it doesn't already exist --- lib/cookie-sessions.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index e53f894..59159b4 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -73,14 +73,14 @@ exports = module.exports = function(settings){ cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)); } - if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout * 1000); // In milliseconds - if (s.useMaxAge) cookiestr += '; max-age=' + s.timeout; // 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 (cookiestr !== undefined) { + if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout * 1000); // In milliseconds + if (s.useMaxAge) cookiestr += '; max-age=' + s.timeout; // 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 { From 41390b865d7752f1072e3b86ba4632aff520b709 Mon Sep 17 00:00:00 2001 From: Public Keating Date: Fri, 13 May 2011 10:53:16 -0600 Subject: [PATCH 04/14] nodeunit says .testrunner is deprecated, use .reporters.default instead --- test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'); From f7159f66bc0545a0b6d1a3e26e8b75ddead4ba1f Mon Sep 17 00:00:00 2001 From: Public Keating Date: Fri, 13 May 2011 10:56:38 -0600 Subject: [PATCH 05/14] Added unit tests for the new functionality --- test/test-cookie-sessions.js | 131 +++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/test/test-cookie-sessions.js b/test/test-cookie-sessions.js index c43dc87..d3595de 100644 --- a/test/test-cookie-sessions.js +++ b/test/test-cookie-sessions.js @@ -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'); @@ -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'); @@ -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; @@ -536,6 +539,7 @@ 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; @@ -569,6 +573,7 @@ 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; @@ -664,3 +669,129 @@ exports['don\'t set cookie if incorrect path'] = function (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: 12345, 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, 12345); + 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: 12345, 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, 12345); + 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: 12345, useHttpOnly: false})(req, res, function(){ + req.session = {test: 'test'}; + res.writeHead(200, {'other_header': 'val'}); + }); +}; \ No newline at end of file From 43f9509979f1e41d2d071609e0eff7a97b3d0094 Mon Sep 17 00:00:00 2001 From: Public Keating Date: Fri, 13 May 2011 10:59:32 -0600 Subject: [PATCH 06/14] Let the expires() function change timeout into milliseconds, which makes it work with existing unit tests and probably a little more semantically correct. --- lib/cookie-sessions.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index 59159b4..3380d7a 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -74,7 +74,7 @@ exports = module.exports = function(settings){ } if (cookiestr !== undefined) { - if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout * 1000); // In milliseconds + if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout); if (s.useMaxAge) cookiestr += '; max-age=' + s.timeout; // In seconds if (s.path) cookiestr += '; path=' + s.path; if (s.domain) cookiestr += '; domain=' + s.domain; @@ -223,7 +223,8 @@ exports.readSession = function(key, secret, timeout, req){ return undefined; }; - +// Generates an expires date +// @params timeout the time in seconds before the cookie expires exports.expires = function(timeout){ - return new Date(new Date().getTime() + (timeout)).toUTCString(); + return new Date(new Date().getTime() + (timeout * 1000)).toUTCString(); }; From f2782705b2f94ee11a2df72c4d8237cd60673fbd Mon Sep 17 00:00:00 2001 From: Public Keating Date: Sat, 14 May 2011 20:03:51 -0600 Subject: [PATCH 07/14] Adjust timestamp (which is in seconds) into milliseconds for comparison with Date. --- lib/cookie-sessions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index 3380d7a..7aa19c7 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -170,7 +170,7 @@ exports.valid = function(secret, timeout, str){ ); return ( parts.hmac_signature === hmac_sig && - parts.timestamp + timeout > new Date().getTime() + parts.timestamp + (timeout * 1000) > new Date().getTime() ); }; From 02f081f088b66091be924fde8e3646a3d84d4ded Mon Sep 17 00:00:00 2001 From: Public Keating Date: Thu, 19 May 2011 11:17:21 -0600 Subject: [PATCH 08/14] Don't mess with success! Switched back to expecting timeout to be in milliseconds, not in seconds. --- lib/cookie-sessions.js | 10 ++++----- test/test-cookie-sessions.js | 40 ++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index 7aa19c7..bc16cb4 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -19,7 +19,7 @@ exports = module.exports = function(settings){ var default_settings = { // don't set a default cookie secret, must be explicitly defined session_key: '_node', - timeout: 60 * 60 * 24, // 24 hours in seconds + timeout: 60 * 60 * 24 * 1000, // 24 hours in milliseconds path: '/', domain: null, secure: false, @@ -75,7 +75,7 @@ exports = module.exports = function(settings){ if (cookiestr !== undefined) { if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout); - if (s.useMaxAge) cookiestr += '; max-age=' + s.timeout; // In seconds + 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'; @@ -170,7 +170,7 @@ exports.valid = function(secret, timeout, str){ ); return ( parts.hmac_signature === hmac_sig && - parts.timestamp + (timeout * 1000) > new Date().getTime() + parts.timestamp + timeout > new Date().getTime() ); }; @@ -224,7 +224,7 @@ exports.readSession = function(key, secret, timeout, req){ }; // Generates an expires date -// @params timeout the time in seconds before the cookie expires +// @params timeout the time in milliseconds before the cookie expires exports.expires = function(timeout){ - return new Date(new Date().getTime() + (timeout * 1000)).toUTCString(); + return new Date(new Date().getTime() + timeout).toUTCString(); }; diff --git a/test/test-cookie-sessions.js b/test/test-cookie-sessions.js index d3595de..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 = { @@ -395,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 = { @@ -420,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 = { @@ -490,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'; }; @@ -510,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', @@ -528,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'; }; @@ -546,7 +546,7 @@ exports['set single cookie'] = function(test){ 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'}); }); @@ -561,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'; }; @@ -579,7 +579,7 @@ exports['handle headers as array'] = function(test){ 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']]); }); @@ -607,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); }); @@ -624,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'); }); @@ -642,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'}; @@ -662,7 +662,7 @@ 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'}; @@ -727,7 +727,7 @@ exports['useExpires: false'] = function(test){ sessions.serialize = _serialize; test.done(); }}; - sessions({secret: 'secret', timeout: 12345, useExpires: false})(req, res, function(){ + sessions({secret: 'secret', timeout: 12345000, useExpires: false})(req, res, function(){ req.session = {test: 'test'}; res.writeHead(200, {'other_header': 'val'}); }); @@ -742,7 +742,7 @@ exports['useMaxAge: false'] = function(test){ var _expires = sessions.expires; sessions.expires = function(timeout){ - test.equals(timeout, 12345); + test.equals(timeout, 12345000); return 'expiry_date'; }; var req = {headers: {cookie:''}, url: '/'}; @@ -758,7 +758,7 @@ exports['useMaxAge: false'] = function(test){ sessions.expires = _expires; test.done(); }}; - sessions({secret: 'secret', timeout: 12345, useMaxAge: false})(req, res, function(){ + sessions({secret: 'secret', timeout: 12345000, useMaxAge: false})(req, res, function(){ req.session = {test: 'test'}; res.writeHead(200, {'other_header': 'val'}); }); @@ -773,7 +773,7 @@ exports['useHttpOnly: false'] = function(test){ var _expires = sessions.expires; sessions.expires = function(timeout){ - test.equals(timeout, 12345); + test.equals(timeout, 12345000); return 'expiry_date'; }; var req = {headers: {cookie:''}, url: '/'}; @@ -790,7 +790,7 @@ exports['useHttpOnly: false'] = function(test){ sessions.expires = _expires; test.done(); }}; - sessions({secret: 'secret', timeout: 12345, useHttpOnly: false})(req, res, function(){ + sessions({secret: 'secret', timeout: 12345000, useHttpOnly: false})(req, res, function(){ req.session = {test: 'test'}; res.writeHead(200, {'other_header': 'val'}); }); From 8c11acc55d0a1934a14be4813923217041d3eb07 Mon Sep 17 00:00:00 2001 From: Public Keating Date: Sun, 29 May 2011 18:33:16 -0600 Subject: [PATCH 09/14] Whitespace --- lib/cookie-sessions.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index bc16cb4..ce358bd 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -121,7 +121,7 @@ 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); @@ -168,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() From 043f2cbe0e01d6ac770a99feb6fee45b999b6837 Mon Sep 17 00:00:00 2001 From: Public Keating Date: Wed, 8 Jun 2011 21:54:54 -0600 Subject: [PATCH 10/14] Give the error a type so that we can identify properly. --- lib/cookie-sessions.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index ce358bd..5d54342 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -122,7 +122,9 @@ exports.deserialize = function(secret, timeout, str){ // Throws an exception if the secure cookie string does not validate. if(!exports.valid(secret, timeout, str)) { - throw new Error('invalid cookie'); + var error = new Error('invalid cookie'); + error.type = 'InvalidCookieError'; + throw error; } var data = exports.decrypt(secret, exports.split(str).data_blob); return JSON.parse(data); From 3b5b5db285640dcc007373548675a4c7b8b603da Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Thu, 12 Jan 2012 23:57:49 +0000 Subject: [PATCH 11/14] Wrote CoffeeScript version of code. Added Cakefile. Updated test code to work with node 0.6.5 (require.paths.push no longer supported). All tests pass. --- .gitignore | 1 + Cakefile | 16 ++ lib/cookie-sessions.js | 371 +++++++++++++++-------------------- package.json | 5 +- src/cookie-sessions.coffee | 224 +++++++++++++++++++++ test.js | 6 +- test/test-cookie-sessions.js | 2 +- 7 files changed, 410 insertions(+), 215 deletions(-) create mode 100644 .gitignore create mode 100644 Cakefile create mode 100644 src/cookie-sessions.coffee diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/Cakefile b/Cakefile new file mode 100644 index 0000000..0e40e49 --- /dev/null +++ b/Cakefile @@ -0,0 +1,16 @@ +# Based on https://github.com/twilson63/express-coffee/blob/master/Cakefile + +fs = require 'fs' +{print} = require 'sys' +{spawn, exec} = require 'child_process' + +build = (callback) -> + options = ['-c', '-o', 'lib', 'src'] + coffee = spawn 'coffee', options + coffee.stdout.on 'data', (data) -> print data.toString() + coffee.stderr.on 'data', (data) -> print data.toString() + coffee.on 'exit', (status) -> callback?() if status is 0 + +task 'build', 'Compile CoffeeScript source files', -> + build() + diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index 5d54342..fa6ce2f 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -1,233 +1,186 @@ -/*globals escape unescape */ - -var crypto = require('crypto'); -var url = require('url'); - - -// 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: 60 * 60 * 24 * 1000, // 24 hours in milliseconds - path: '/', - domain: null, - secure: false, - useMaxAge: true, - useExpires: true, - useHttpOnly: true +(function() { + var crypto, exports, url; + var __hasProp = Object.prototype.hasOwnProperty, _this = this; + + crypto = require('crypto'); + + url = require('url'); + + exports = module.exports = function(settings) { + var k, s, v; + var _this = this; + s = { + session_key: '_node', + timeout: 24 * 60 * 60 * 1000, + 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) { - throw new Error('invalid cookie path, must start with "/"'); + for (k in settings) { + if (!__hasProp.call(settings, k)) continue; + v = settings[k]; + s[k] = v; } - - 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) { - next(); - return; + if (!s.secret) throw new Error('No secret set in cookie-session settings'); + if ("string" !== typeof s.path || 0 !== s.path.indexOf("/")) { + throw new Error('invalid cookie path, must start with "/"'); + } + return function(req, res, next) { + var _writeHead; + if (0 !== url.parse(req.url).pathname.indexOf(s.path)) return next(); + req.session = exports.readSession(s.session_key, s.secret, s.timeout, req); + _writeHead = res.writeHead; + res.writeHead = function(statusCode) { + var args, cookiestr, headers, reasonPhrase; + reasonPhrase = null; + headers = null; + if ("string" === typeof arguments[1]) { + reasonPhrase = arguments[1]; + headers = arguments[2] || {}; + } else { + headers = arguments[1] || {}; } - - // Read session data from a request and store it in req.session - req.session = exports.readSession( - s.session_key, s.secret, s.timeout, req); - - // proxy writeHead to add cookie to response - var _writeHead = res.writeHead; - res.writeHead = function(statusCode){ - - var reasonPhrase, headers; - if (typeof arguments[1] === 'string') { - reasonPhrase = arguments[1]; - headers = arguments[2] || {}; - } - else { - headers = arguments[1] || {}; - } - - // Add a Set-Cookie header to all responses with the session data - // and the current timestamp. The cookie needs to be set on every - // response so that the timestamp is up to date, and the session - // does not expire unless the user is inactive. - - var cookiestr; - if (req.session === undefined) { - if ("cookie" in req.headers) { - cookiestr = escape(s.session_key) + '='; - s.timeout = 0; - } + cookiestr = null; + if (!req.session) { + if ("cookie" in req.headers) { + cookiestr = escape(s.session_key) + '='; + s.timeout = 0; + } + } else { + cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)); + } + if (cookiestr) { + if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout); + if (s.useMaxAge) cookiestr += '; max-age=' + (s.timeout / 1000); + 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 (headers['Set-Cookie']) { + headers = exports.headersToArray(headers); + headers.push(['Set-Cookie', cookiestr]); } else { - cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)); + headers['Set-Cookie'] = cookiestr; } + } + } + args = [statusCode, reasonPhrase, headers]; + if (!args[1]) args.splice(1, 1); + return _writeHead.apply(res, args); + }; + return next(); + }; + }; - if (cookiestr !== undefined) { - 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) { - headers = exports.headersToArray(headers); - headers.push(['Set-Cookie', cookiestr]); - } else { - // if no Set-Cookie header exists, leave the headers as an - // object, and add a Set-Cookie property - headers['Set-Cookie'] = cookiestr; - } - } - } + exports.MAX_LENGTH = 4096; - var args = [statusCode, reasonPhrase, headers]; - if (!args[1]) { - args.splice(1, 1); - } - // call the original writeHead on the request - return _writeHead.apply(res, args); - }; - next(); + exports.readSession = function(session_key, secret, timeout, req) { + var cookies; + cookies = exports.readCookies(req); + if (session_key in cookies && cookies[session_key]) { + return exports.deserialize(secret, timeout, cookies[session_key]); + } else { + } + }; + + exports.readCookies = function(req) { + var cookie, func, parts; + if (req.cookies) { + return req.cookies; + } else { + cookie = req.headers.cookie; + if (!cookie) return {}; + parts = cookie.split(/\s*;\s*/g).map(function(x) { + return x.split('='); + }); + func = function(a, x) { + a[unescape(x[0])] = unescape(x[1]); + return a; + }; + return parts.reduce(func, {}); + } + }; + + exports.headersToArray = function(headers) { + var func; + if (Array.isArray(headers)) return headers; + func = function(arr, k) { + arr.push([k, headers[k]]); + return arr; }; -}; - -exports.headersToArray = function(headers){ - if(Array.isArray(headers)) return headers; - return Object.keys(headers).reduce(function(arr, k){ - arr.push([k, headers[k]]); - return arr; - }, []); -}; - -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)) { - var error = new Error('invalid cookie'); - error.type = 'InvalidCookieError'; - throw error; + return Object.keys(headers).reduce(func, []); + }; + + exports.deserialize = function(secret, timeout, str) { + var error; + if (!exports.valid(secret, timeout, str)) { + error = new Error('invalid cookie'); + error.type = 'InvalidCookieError'; + throw error; } - var data = exports.decrypt(secret, exports.split(str).data_blob); - return JSON.parse(data); -}; - -exports.serialize = function(secret, data){ - // Turns a JSON-compatibile object literal into a secure cookie string - - var data_str = JSON.stringify(data); - var data_enc = exports.encrypt(secret, data_str); - 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)){ - throw new Error('data too long to store in a cookie'); + return JSON.parse(exports.decrypt(secret, exports.split(str).data_blob)); + }; + + exports.serialize = function(secret, data) { + var data_enc, data_str, hmac_sig, result, timestamp; + data_str = JSON.stringify(data); + data_enc = exports.encrypt(secret, data_str); + timestamp = new Date().getTime(); + hmac_sig = exports.hmac_signature(secret, timestamp, data_enc); + result = hmac_sig + timestamp + data_enc; + if (!exports.checkLength(result)) { + throw new Error('data too long to store in a cookie'); } return result; -}; + }; -exports.split = function(str){ - // Splits a cookie string into hmac signature, timestamp and data blob. + exports.split = function(str) { return { - hmac_signature: str.slice(0,40), - timestamp: parseInt(str.slice(40, 53), 10), - data_blob: str.slice(53) + hmac_signature: str.slice(0, 40), + timestamp: parseInt(str.slice(40, 53), 10), + data_blob: str.slice(53) }; -}; + }; -exports.hmac_signature = function(secret, timestamp, data){ - // Generates a HMAC for the timestamped data, returning the - // hex digest for the signature. - var hmac = crypto.createHmac('sha1', secret); + exports.hmac_signature = function(secret, timestamp, data) { + var hmac; + hmac = crypto.createHmac('sha1', secret); hmac.update(timestamp + data); return hmac.digest('hex'); -}; - -exports.valid = function(secret, timeout, str){ - // Tests the validity of a cookie string. Returns true if the HMAC - // signature of the secret, timestamp and data blob matches the HMAC in the - // cookie string, and the cookie's age is less than the timeout value. - - var parts = exports.split(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() - ); -}; - -exports.decrypt = function(secret, str){ - // Decrypt the aes192 encoded str using secret. - var decipher = crypto.createDecipher("aes192", secret); + }; + + exports.valid = function(secret, timeout, str) { + var hmac_sig, parts; + parts = exports.split(str); + hmac_sig = exports.hmac_signature(secret, parts.timestamp, parts.data_blob); + return parts.hmac_signature === hmac_sig && new Date().getTime() < (parts.timestamp + timeout); + }; + + exports.decrypt = function(secret, str) { + var decipher; + decipher = crypto.createDecipher("aes192", secret); return decipher.update(str, 'hex', 'utf8') + decipher.final('utf8'); -}; + }; -exports.encrypt = function(secret, str){ - // Encrypt the str with aes192 using secret. - var cipher = crypto.createCipher("aes192", secret); + exports.encrypt = function(secret, str) { + var cipher; + cipher = crypto.createCipher("aes192", secret); return cipher.update(str, 'utf8', 'hex') + cipher.final('hex'); -}; + }; -exports.checkLength = function(str){ - // Test if a string is within the maximum length allowed for a cookie. - return str.length <= 4096; -}; + exports.checkLength = function(str) { + return str.length <= exports.MAX_LENGTH; + }; -exports.readCookies = function(req){ - // if "cookieDecoder" is in use, then req.cookies - // 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 {}; - } - 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){ - // Reads the session data stored in the cookie named 'key' if it validates, - // otherwise returns an empty object. + exports.expires = function(timeout) { + return new Date(new Date().getTime() + timeout).toUTCString(); + }; - var cookies = exports.readCookies(req); - if(cookies[key]){ - return exports.deserialize(secret, timeout, cookies[key]); - } - 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(); -}; +}).call(this); diff --git a/package.json b/package.json index daddbdb..66fe4f3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ , "version": "0.0.2" , "repository" : { "type" : "git" - , "url" : "http://github.com/caolan/cookie-sessions.git" + , "url" : "http://github.com/caolan/cookie-sessions.git" } , "bugs" : { "url" : "http://github.com/caolan/cookie-sessions/issues" } , "licenses" : @@ -13,4 +13,7 @@ , "url" : "http://github.com/caolan/cookie-sessions/raw/master/LICENSE" } ] +, "devDependencies": { + "coffee-script": "1.1.x" + } } diff --git a/src/cookie-sessions.coffee b/src/cookie-sessions.coffee new file mode 100644 index 0000000..7a61fdd --- /dev/null +++ b/src/cookie-sessions.coffee @@ -0,0 +1,224 @@ +crypto = require('crypto') +url = require('url') + + +exports = module.exports = (settings) -> + s = + # key name + session_key: '_node' + # timeout/expiry (24 hours) + timeout: 24 * 60 * 60 * 1000 + # path + path: '/' + # domain + domain: null + # https only? + secure: false + # use 'max-age' ? + useMaxAge: true + # use 'expires'? + useExpires: true + # use 'HttpOnly'? + useHttpOnly: true + + # extend with passed-in settings + for own k,v of settings + s[k] = v + + if not s.secret + throw new Error 'No secret set in cookie-session settings' + + if "string" isnt typeof s.path or 0 isnt s.path.indexOf("/") + throw new Error 'invalid cookie path, must start with "/"' + + + # Handle a request - the main method! + return (req, res, next) => + # if the request is not under the specified path, do nothing. + if 0 isnt url.parse(req.url).pathname.indexOf(s.path) + return next() + + # Read session data from a request and store it in req.session + req.session = exports.readSession(s.session_key, s.secret, s.timeout, req) + + # proxy writeHead to add cookie to response + _writeHead = res.writeHead + res.writeHead = (statusCode) => + + reasonPhrase = null + headers = null + if "string" is typeof arguments[1] + reasonPhrase = arguments[1] + headers = arguments[2] or {} + else + headers = arguments[1] or {} + + + # Add a Set-Cookie header to all responses with the session data + # and the current timestamp. The cookie needs to be set on every + # response so that the timestamp is up to date, and the session + # does not expire unless the user is inactive. + + cookiestr = null + # no session yet + if not req.session + if "cookie" of req.headers + cookiestr = escape(s.session_key) + '=' + s.timeout = 0 + else + cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)) + + if cookiestr + if s.useExpires then cookiestr += '; expires=' + exports.expires(s.timeout) + if s.useMaxAge then cookiestr += '; max-age=' + (s.timeout / 1000) # In seconds + if s.path then cookiestr += '; path=' + s.path + if s.domain then cookiestr += '; domain=' + s.domain + if s.secure then cookiestr += '; secure' + if s.useHttpOnly then 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'] + headers = exports.headersToArray(headers) + headers.push ['Set-Cookie', cookiestr] + else + # if no Set-Cookie header exists, leave the headers as an + # object, and add a Set-Cookie property + headers['Set-Cookie'] = cookiestr + + + args = [statusCode, reasonPhrase, headers] + # get rid of reasonPhrase if not defined + if not args[1] + args.splice(1, 1) + + # call the original writeHead on the request + return _writeHead.apply res, args + + next() + + + +# max allowable length of a cookie (4KB) +exports.MAX_LENGTH = 4096 + + + +# read session from given request cookie +exports.readSession = (session_key, secret, timeout, req) => + # Reads the session data stored in the cookie named 'key' if it validates, + # otherwise returns an empty object. + cookies = exports.readCookies(req) + if session_key of cookies and cookies[session_key] + return exports.deserialize(secret, timeout, cookies[session_key]) + else + return undefined; + + +# read cookies from request object +exports.readCookies = (req) -> + # if "cookieDecoder" is in use, then req.cookies + # will already contain the parsed cookies + if req.cookies + return req.cookies + else + # Extracts the cookies from a request object. + cookie = req.headers.cookie + return {} if not cookie + + parts = cookie.split(/\s*;\s*/g).map (x) -> + x.split('=') + + func = (a, x) -> + a[unescape(x[0])] = unescape(x[1]) + a + + parts.reduce(func, {}) + + + +# convert key-value headers into arrays +exports.headersToArray = (headers) -> + return headers if Array.isArray(headers) + func = (arr, k) -> + arr.push [k, headers[k]] + arr + Object.keys(headers).reduce(func, []) + + +# parse cookie data +exports.deserialize = (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 not exports.valid(secret, timeout, str) + error = new Error('invalid cookie') + error.type = 'InvalidCookieError' + throw error + JSON.parse(exports.decrypt(secret, exports.split(str).data_blob)) + + +# construct cookie data +exports.serialize = (secret, data) => + # Turns a JSON-compatibile object literal into a secure cookie string + data_str = JSON.stringify(data) + data_enc = exports.encrypt(secret, data_str) + timestamp = new Date().getTime() + hmac_sig = exports.hmac_signature(secret, timestamp, data_enc) + result = hmac_sig + timestamp + data_enc; + if not exports.checkLength(result) + throw new Error 'data too long to store in a cookie' + result; + + +exports.split = (str) -> + # Splits a cookie string into hmac signature, timestamp and data blob. + return { + hmac_signature: str.slice(0,40) + timestamp: parseInt(str.slice(40, 53), 10) + data_blob: str.slice(53) + } + + +# calculate hmac signature +exports.hmac_signature = (secret, timestamp, data) -> + # Generates a HMAC for the timestamped data, returning the + # hex digest for the signature. + hmac = crypto.createHmac('sha1', secret) + hmac.update(timestamp + data); + hmac.digest('hex') + + +exports.valid = (secret, timeout, str) => + # Tests the validity of a cookie string. Returns true if the HMAC + # signature of the secret, timestamp and data blob matches the HMAC in the + # cookie string, and the cookie's age is less than the timeout value. + parts = exports.split(str) + hmac_sig = exports.hmac_signature secret, parts.timestamp, parts.data_blob + return parts.hmac_signature is hmac_sig and + new Date().getTime() < (parts.timestamp + timeout) + + +exports.decrypt = (secret, str) -> + # Decrypt the aes192 encoded str + decipher = crypto.createDecipher("aes192", secret) + decipher.update(str, 'hex', 'utf8') + decipher.final('utf8') + + +exports.encrypt = (secret, str) -> + # Encrypt the str with aes192 using secret. + cipher = crypto.createCipher("aes192", secret) + cipher.update(str, 'utf8', 'hex') + cipher.final('hex') + + +exports.checkLength = (str) -> + # Test if a string is within the maximum length allowed for a cookie (4KB. + return str.length <= exports.MAX_LENGTH + + +# Generates an expires date +# exports.params timeout the time in milliseconds before the cookie expires +exports.expires = (timeout) -> + new Date(new Date().getTime() + timeout).toUTCString(); diff --git a/test.js b/test.js index 95752f4..2936f2b 100755 --- a/test.js +++ b/test.js @@ -1,11 +1,9 @@ #!/usr/local/bin/node -require.paths.push(__dirname); -require.paths.push(__dirname + '/deps'); -require.paths.push(__dirname + '/lib'); +var nodeunit = require('./deps/nodeunit'); try { - var testrunner = require('nodeunit').reporters.default; + var testrunner = nodeunit.reporters.default; } catch(e) { var sys = require('sys'); diff --git a/test/test-cookie-sessions.js b/test/test-cookie-sessions.js index f93dcde..2329e19 100644 --- a/test/test-cookie-sessions.js +++ b/test/test-cookie-sessions.js @@ -1,4 +1,4 @@ -var sessions = require('cookie-sessions'); +var sessions = require('../lib/cookie-sessions'); exports['split'] = function(test){ From c793ee05b48ffa5c4a729554552050ef567cdd9c Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Fri, 13 Jan 2012 00:50:24 +0000 Subject: [PATCH 12/14] Added an onError setting to the initial call. If ommitted (default) then the cookie-session will throw errors as normal. If instead a function is provided for this setting then that function will recieve a callback with the Error object. --- lib/cookie-sessions.js | 43 ++++++++++++--- src/cookie-sessions.coffee | 49 +++++++++++++---- test/test-cookie-sessions.js | 100 ++++++++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 19 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index fa6ce2f..a527f3a 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -17,16 +17,20 @@ secure: false, useMaxAge: true, useExpires: true, - useHttpOnly: true + useHttpOnly: true, + onError: null }; for (k in settings) { if (!__hasProp.call(settings, k)) continue; v = settings[k]; s[k] = v; } - if (!s.secret) throw new Error('No secret set in cookie-session settings'); + if ("function" === typeof s.onError) exports.Events.onError = s.onError; + if (!s.secret) { + return exports.Events.throwErr('No secret set in cookie-session settings'); + } if ("string" !== typeof s.path || 0 !== s.path.indexOf("/")) { - throw new Error('invalid cookie path, must start with "/"'); + return exports.Events.throwErr('Invalid cookie path, must start with "/"'); } return function(req, res, next) { var _writeHead; @@ -34,7 +38,7 @@ req.session = exports.readSession(s.session_key, s.secret, s.timeout, req); _writeHead = res.writeHead; res.writeHead = function(statusCode) { - var args, cookiestr, headers, reasonPhrase; + var args, cookiestr, headers, reasonPhrase, serializedData; reasonPhrase = null; headers = null; if ("string" === typeof arguments[1]) { @@ -50,7 +54,10 @@ s.timeout = 0; } } else { - cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)); + serializedData = exports.serialize(s.secret, req.session); + if (serializedData) { + cookiestr = escape(s.session_key) + '=' + escape(serializedData); + } } if (cookiestr) { if (s.useExpires) cookiestr += '; expires=' + exports.expires(s.timeout); @@ -121,9 +128,9 @@ exports.deserialize = function(secret, timeout, str) { var error; if (!exports.valid(secret, timeout, str)) { - error = new Error('invalid cookie'); + error = new Error('Invalid cookie'); error.type = 'InvalidCookieError'; - throw error; + return exports.Events.throwErr(error); } return JSON.parse(exports.decrypt(secret, exports.split(str).data_blob)); }; @@ -136,7 +143,7 @@ hmac_sig = exports.hmac_signature(secret, timestamp, data_enc); result = hmac_sig + timestamp + data_enc; if (!exports.checkLength(result)) { - throw new Error('data too long to store in a cookie'); + return exports.Events.throwErr('Data too long to store in a cookie'); } return result; }; @@ -183,4 +190,24 @@ return new Date(new Date().getTime() + timeout).toUTCString(); }; + exports.Events = (function() { + + function Events() {} + + Events.onError = null; + + Events.throwErr = function(err) { + if (typeof err !== "object") err = new Error(err); + if (this.onError) { + this.onError(err); + } else { + throw err; + } + return; + }; + + return Events; + + })(); + }).call(this); diff --git a/src/cookie-sessions.coffee b/src/cookie-sessions.coffee index 7a61fdd..70675a5 100644 --- a/src/cookie-sessions.coffee +++ b/src/cookie-sessions.coffee @@ -20,16 +20,23 @@ exports = module.exports = (settings) -> useExpires: true # use 'HttpOnly'? useHttpOnly: true + # error handler + onError: null # extend with passed-in settings for own k,v of settings s[k] = v + # do we have an error handling callback? + if "function" is typeof s.onError + exports.Events.onError = s.onError + + # do some basic checks if not s.secret - throw new Error 'No secret set in cookie-session settings' + return exports.Events.throwErr 'No secret set in cookie-session settings' if "string" isnt typeof s.path or 0 isnt s.path.indexOf("/") - throw new Error 'invalid cookie path, must start with "/"' + return exports.Events.throwErr 'Invalid cookie path, must start with "/"' # Handle a request - the main method! @@ -66,7 +73,9 @@ exports = module.exports = (settings) -> cookiestr = escape(s.session_key) + '=' s.timeout = 0 else - cookiestr = escape(s.session_key) + '=' + escape(exports.serialize(s.secret, req.session)) + serializedData = exports.serialize(s.secret, req.session) + if serializedData + cookiestr = escape(s.session_key) + '=' + escape(serializedData) if cookiestr if s.useExpires then cookiestr += '; expires=' + exports.expires(s.timeout) @@ -106,8 +115,8 @@ exports = module.exports = (settings) -> exports.MAX_LENGTH = 4096 - # read session from given request cookie +# @return undefined if session data wasn't found or couldn't be read exports.readSession = (session_key, secret, timeout, req) => # Reads the session data stored in the cookie named 'key' if it validates, # otherwise returns an empty object. @@ -150,27 +159,29 @@ exports.headersToArray = (headers) -> # parse cookie data +# @return undefined if data couldn't be parsed exports.deserialize = (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. + # Returns undefined (and sends out an error) if the secure cookie string does not validate. if not exports.valid(secret, timeout, str) - error = new Error('invalid cookie') + error = new Error('Invalid cookie') error.type = 'InvalidCookieError' - throw error + return exports.Events.throwErr(error) JSON.parse(exports.decrypt(secret, exports.split(str).data_blob)) # construct cookie data +# @return undefined if data couldn't be constructed exports.serialize = (secret, data) => # Turns a JSON-compatibile object literal into a secure cookie string data_str = JSON.stringify(data) data_enc = exports.encrypt(secret, data_str) timestamp = new Date().getTime() hmac_sig = exports.hmac_signature(secret, timestamp, data_enc) - result = hmac_sig + timestamp + data_enc; + result = hmac_sig + timestamp + data_enc if not exports.checkLength(result) - throw new Error 'data too long to store in a cookie' - result; + return exports.Events.throwErr 'Data too long to store in a cookie' + result exports.split = (str) -> @@ -222,3 +233,21 @@ exports.checkLength = (str) -> # exports.params timeout the time in milliseconds before the cookie expires exports.expires = (timeout) -> new Date(new Date().getTime() + timeout).toUTCString(); + + +# events delegate +class exports.Events + # The error-handling callback + @onError: null + + # Throw an error + # @param errObj an Error object or error message + # @return undefined + @throwErr: (err) -> + if typeof err isnt "object" + err = new Error(err) + if @onError then @onError(err) else throw err + undefined + + + diff --git a/test/test-cookie-sessions.js b/test/test-cookie-sessions.js index 2329e19..d1af212 100644 --- a/test/test-cookie-sessions.js +++ b/test/test-cookie-sessions.js @@ -206,7 +206,7 @@ exports['serialize data over 4096 chars'] = function(test){ var r = ''; for(var i=0; i<4089; i++){ r = r + 'x'; - }; + } return r; }; sessions.hmac_signature = function(secret, timestamp, data_str){ @@ -794,4 +794,102 @@ exports['useHttpOnly: false'] = function(test){ req.session = {test: 'test'}; res.writeHead(200, {'other_header': 'val'}); }); +}; + + +exports['onError: no secret'] = function(test){ + test.expect(2); + sessions.Events.onError = null; + var errMsg = "Error: No secret set in cookie-session settings"; + + try { + sessions(); + } catch (err) { + test.equals(err.toString(), errMsg) + } + sessions({ + onError: function(err) { + test.equals(err.toString(), errMsg); + test.done(); + } + }); +}; + + +exports['onError: bad path'] = function(test){ + test.expect(2); + sessions.Events.onError = null; + var errMsg = "Error: Invalid cookie path, must start with \"/\""; + + try { + sessions({ + secret: "test", + path: "o" + }); + } catch (err) { + test.equals(err.toString(), errMsg) + } + + sessions({ + secret: "test", + path: "o", + onError: function(err) { + test.equals(err.toString(), errMsg); + test.done(); + } + }); +}; + + + +exports['onError: bad cookie string'] = function(test){ + test.expect(4); + sessions.Events.onError = null; + var errMsg = "Error: Invalid cookie"; + + try { + sessions.deserialize("test", 100, "blabla"); + } catch (err) { + test.equals(err.type, "InvalidCookieError"); + test.equals(err.toString(), errMsg) + } + + sessions({ + secret: "test", + onError: function (err) { + test.equals(err.type, "InvalidCookieError"); + test.equals(err.toString(), errMsg); + test.done(); + } + }); + sessions.deserialize("test", 100, "blabla"); +}; + + + +exports['onError: data too long'] = function(test){ + test.expect(2); + sessions.Events.onError = null; + var errMsg = "Error: Data too long to store in a cookie"; + + var data = ''; + for(var i=0; i<4089; i++){ + data = data + 'x'; + } + + + try { + sessions.serialize("test", data); + } catch (err) { + test.equals(err.toString(), errMsg) + } + + sessions({ + secret: "test", + onError: function (err) { + test.equals(err.toString(), errMsg); + test.done(); + } + }); + sessions.serialize("test", data); }; \ No newline at end of file From dfd508ef1c3a6a7dab03759506728e6ead9f4271 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Fri, 13 Jan 2012 00:51:58 +0000 Subject: [PATCH 13/14] Don't export MAX_LENGTH value. --- lib/cookie-sessions.js | 8 ++++---- src/cookie-sessions.coffee | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index a527f3a..f45fc7b 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -1,11 +1,13 @@ (function() { - var crypto, exports, url; + var MAX_LENGTH, crypto, exports, url; var __hasProp = Object.prototype.hasOwnProperty, _this = this; crypto = require('crypto'); url = require('url'); + MAX_LENGTH = 4096; + exports = module.exports = function(settings) { var k, s, v; var _this = this; @@ -85,8 +87,6 @@ }; }; - exports.MAX_LENGTH = 4096; - exports.readSession = function(session_key, secret, timeout, req) { var cookies; cookies = exports.readCookies(req); @@ -183,7 +183,7 @@ }; exports.checkLength = function(str) { - return str.length <= exports.MAX_LENGTH; + return str.length <= MAX_LENGTH; }; exports.expires = function(timeout) { diff --git a/src/cookie-sessions.coffee b/src/cookie-sessions.coffee index 70675a5..22294ef 100644 --- a/src/cookie-sessions.coffee +++ b/src/cookie-sessions.coffee @@ -1,6 +1,9 @@ crypto = require('crypto') url = require('url') +# max allowable length of a cookie (4KB) +MAX_LENGTH = 4096 + exports = module.exports = (settings) -> s = @@ -111,9 +114,6 @@ exports = module.exports = (settings) -> -# max allowable length of a cookie (4KB) -exports.MAX_LENGTH = 4096 - # read session from given request cookie # @return undefined if session data wasn't found or couldn't be read @@ -226,7 +226,7 @@ exports.encrypt = (secret, str) -> exports.checkLength = (str) -> # Test if a string is within the maximum length allowed for a cookie (4KB. - return str.length <= exports.MAX_LENGTH + return str.length <= MAX_LENGTH # Generates an expires date From d32cb191b2ca5276cb980c2cd9772a12d32f2325 Mon Sep 17 00:00:00 2001 From: Ramesh Nair Date: Fri, 13 Jan 2012 00:58:33 +0000 Subject: [PATCH 14/14] Get rid of unnecessary 'this' references. --- lib/cookie-sessions.js | 4 ++-- src/cookie-sessions.coffee | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/cookie-sessions.js b/lib/cookie-sessions.js index f45fc7b..839675e 100644 --- a/lib/cookie-sessions.js +++ b/lib/cookie-sessions.js @@ -1,6 +1,6 @@ (function() { var MAX_LENGTH, crypto, exports, url; - var __hasProp = Object.prototype.hasOwnProperty, _this = this; + var __hasProp = Object.prototype.hasOwnProperty; crypto = require('crypto'); @@ -10,7 +10,6 @@ exports = module.exports = function(settings) { var k, s, v; - var _this = this; s = { session_key: '_node', timeout: 24 * 60 * 60 * 1000, @@ -36,6 +35,7 @@ } return function(req, res, next) { var _writeHead; + var _this = this; if (0 !== url.parse(req.url).pathname.indexOf(s.path)) return next(); req.session = exports.readSession(s.session_key, s.secret, s.timeout, req); _writeHead = res.writeHead; diff --git a/src/cookie-sessions.coffee b/src/cookie-sessions.coffee index 22294ef..b08beba 100644 --- a/src/cookie-sessions.coffee +++ b/src/cookie-sessions.coffee @@ -43,7 +43,7 @@ exports = module.exports = (settings) -> # Handle a request - the main method! - return (req, res, next) => + return (req, res, next) -> # if the request is not under the specified path, do nothing. if 0 isnt url.parse(req.url).pathname.indexOf(s.path) return next() @@ -117,7 +117,7 @@ exports = module.exports = (settings) -> # read session from given request cookie # @return undefined if session data wasn't found or couldn't be read -exports.readSession = (session_key, secret, timeout, req) => +exports.readSession = (session_key, secret, timeout, req) -> # Reads the session data stored in the cookie named 'key' if it validates, # otherwise returns an empty object. cookies = exports.readCookies(req) @@ -160,7 +160,7 @@ exports.headersToArray = (headers) -> # parse cookie data # @return undefined if data couldn't be parsed -exports.deserialize = (secret, timeout, str) => +exports.deserialize = (secret, timeout, str) -> # Parses a secure cookie string, returning the object stored within it. # Returns undefined (and sends out an error) if the secure cookie string does not validate. if not exports.valid(secret, timeout, str) @@ -172,7 +172,7 @@ exports.deserialize = (secret, timeout, str) => # construct cookie data # @return undefined if data couldn't be constructed -exports.serialize = (secret, data) => +exports.serialize = (secret, data) -> # Turns a JSON-compatibile object literal into a secure cookie string data_str = JSON.stringify(data) data_enc = exports.encrypt(secret, data_str) @@ -202,7 +202,7 @@ exports.hmac_signature = (secret, timestamp, data) -> hmac.digest('hex') -exports.valid = (secret, timeout, str) => +exports.valid = (secret, timeout, str) -> # Tests the validity of a cookie string. Returns true if the HMAC # signature of the secret, timestamp and data blob matches the HMAC in the # cookie string, and the cookie's age is less than the timeout value.