client-sessions
Advanced tools
Comparing version 0.4.1 to 0.5.0
@@ -5,10 +5,15 @@ /* This Source Code Form is subject to the terms of the Mozilla Public | ||
var Cookies = require("cookies"); | ||
var Proxy_ = (typeof Proxy !== 'undefined') ? Proxy : require("node-proxy"); | ||
var Handler = require("./ProxyHandler.js"); | ||
var crypto = require("crypto"); | ||
const Cookies = require("cookies"); | ||
const crypto = require("crypto"); | ||
const util = require("util"); | ||
const COOKIE_NAME_SEP = '='; | ||
const ACTIVE_DURATION = 1000 * 60 * 5; | ||
function isObject(val) { | ||
return Object.prototype.toString.call(val) === '[object Object]'; | ||
} | ||
function base64urlencode(arg) { | ||
@@ -27,8 +32,13 @@ var s = arg.toString('base64'); | ||
s = s.replace(/_/g, '/'); // 63rd char of encoding | ||
switch (s.length % 4) // Pad with trailing '='s | ||
{ | ||
case 0: break; // No pad chars in this case | ||
case 2: s += "=="; break; // Two pad chars | ||
case 3: s += "="; break; // One pad char | ||
default: throw new Error("Illegal base64url string!"); | ||
switch (s.length % 4) { // Pad with trailing '='s | ||
case 0: | ||
break; // No pad chars in this case | ||
case 2: | ||
s += "=="; | ||
break; // Two pad chars | ||
case 3: | ||
s += "="; | ||
break; // One pad char | ||
default: | ||
throw new Error("Illegal base64url string!"); | ||
} | ||
@@ -48,4 +58,5 @@ return new Buffer(s, 'base64'); // Standard base64 decoder | ||
// JS engine might optimize. | ||
if (a.length != b.length) | ||
if (a.length !== b.length) { | ||
return false; | ||
} | ||
var ret = 0; | ||
@@ -55,54 +66,60 @@ for (var i = 0; i < a.length; i++) { | ||
} | ||
return ret == 0; | ||
return ret === 0; | ||
} | ||
function encode(opts, content, duration, createdAt){ | ||
// format will be: | ||
// iv.ciphertext.createdAt.duration.hmac | ||
// format will be: | ||
// iv.ciphertext.createdAt.duration.hmac | ||
if (!opts.cookieName) { | ||
throw new Error('cookieName option required'); | ||
} else if (String(opts.cookieName).indexOf(COOKIE_NAME_SEP) != -1) { | ||
throw new Error('cookieName cannot include "="'); | ||
} | ||
if (!opts.cookieName) { | ||
throw new Error('cookieName option required'); | ||
} else if (String(opts.cookieName).indexOf(COOKIE_NAME_SEP) !== -1) { | ||
throw new Error('cookieName cannot include "="'); | ||
} | ||
if (!opts.encryptionKey) { | ||
opts['encryptionKey'] = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
} | ||
if (!opts.encryptionKey) { | ||
opts.encryptionKey = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
} | ||
if (!opts.signatureKey) { | ||
opts['signatureKey'] = deriveKey(opts.secret, 'cookiesession-signature'); | ||
} | ||
if (!opts.signatureKey) { | ||
opts.signatureKey = deriveKey(opts.secret, 'cookiesession-signature'); | ||
} | ||
duration = duration || 24*60*60*1000; | ||
createdAt = createdAt || new Date().getTime(); | ||
duration = duration || 24*60*60*1000; | ||
createdAt = createdAt || new Date().getTime(); | ||
// generate iv | ||
var iv = crypto.randomBytes(16); | ||
// generate iv | ||
var iv = crypto.randomBytes(16); | ||
// encrypt with encryption key | ||
var plaintext = opts.cookieName + COOKIE_NAME_SEP + JSON.stringify(content); | ||
var cipher = crypto.createCipheriv('aes256', opts.encryptionKey, iv); | ||
var ciphertext = cipher.update(plaintext, 'utf8', 'binary'); | ||
ciphertext += cipher.final('binary'); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
ciphertext = new Buffer(ciphertext, 'binary'); | ||
// encrypt with encryption key | ||
var plaintext = opts.cookieName + COOKIE_NAME_SEP + JSON.stringify(content); | ||
var cipher = crypto.createCipheriv('aes256', opts.encryptionKey, iv); | ||
var ciphertext = cipher.update(plaintext, 'utf8', 'binary'); | ||
ciphertext += cipher.final('binary'); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
ciphertext = new Buffer(ciphertext, 'binary'); | ||
// hmac it | ||
var hmacAlg = crypto.createHmac('sha256', opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
// hmac it | ||
var hmacAlg = crypto.createHmac('sha256', opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
var hmac = hmacAlg.digest(); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
hmac = new Buffer(hmac, 'binary'); | ||
var hmac = hmacAlg.digest(); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
hmac = new Buffer(hmac, 'binary'); | ||
return base64urlencode(iv) + "." + base64urlencode(ciphertext) + "." + createdAt + "." + duration + "." + base64urlencode(hmac); | ||
return [ | ||
base64urlencode(iv), | ||
base64urlencode(ciphertext), | ||
createdAt, | ||
duration, | ||
base64urlencode(hmac) | ||
].join('.'); | ||
} | ||
@@ -112,66 +129,71 @@ | ||
// stop at any time if there's an issue | ||
var components = content.split("."); | ||
if (components.length != 5) | ||
return; | ||
// stop at any time if there's an issue | ||
var components = content.split("."); | ||
if (components.length !== 5) { | ||
return; | ||
} | ||
if (!opts.cookieName) { | ||
throw new Error("cookieName option required"); | ||
} | ||
if (!opts.cookieName) { | ||
throw new Error("cookieName option required"); | ||
} | ||
if (!opts.encryptionKey) { | ||
opts['encryptionKey'] = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
} | ||
if (!opts.encryptionKey) { | ||
opts.encryptionKey = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
} | ||
if (!opts.signatureKey) { | ||
opts['signatureKey'] = deriveKey(opts.secret, 'cookiesession-signature'); | ||
} | ||
if (!opts.signatureKey) { | ||
opts.signatureKey = deriveKey(opts.secret, 'cookiesession-signature'); | ||
} | ||
var iv = base64urldecode(components[0]); | ||
var ciphertext = base64urldecode(components[1]); | ||
var createdAt = parseInt(components[2], 10); | ||
var duration = parseInt(components[3], 10); | ||
var hmac = base64urldecode(components[4]); | ||
var iv = base64urldecode(components[0]); | ||
var ciphertext = base64urldecode(components[1]); | ||
var createdAt = parseInt(components[2], 10); | ||
var duration = parseInt(components[3], 10); | ||
var hmac = base64urldecode(components[4]); | ||
// make sure IV is right length | ||
if (iv.length != 16) | ||
return; | ||
// make sure IV is right length | ||
if (iv.length !== 16) { | ||
return; | ||
} | ||
// check hmac | ||
var hmacAlg = crypto.createHmac('sha256', opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
// check hmac | ||
var hmacAlg = crypto.createHmac('sha256', opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
var expected_hmac = hmacAlg.digest(); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
expected_hmac = new Buffer(expected_hmac, 'binary'); | ||
var expectedHmac = hmacAlg.digest(); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
expectedHmac = new Buffer(expectedHmac, 'binary'); | ||
if (!constantTimeEquals(hmac, expected_hmac)) | ||
return; | ||
if (!constantTimeEquals(hmac, expectedHmac)) { | ||
return; | ||
} | ||
// decrypt | ||
var cipher = crypto.createDecipheriv('aes256', opts.encryptionKey, iv); | ||
var plaintext = cipher.update(ciphertext, 'binary', 'utf8'); | ||
plaintext += cipher.final('utf8'); | ||
// decrypt | ||
var cipher = crypto.createDecipheriv('aes256', opts.encryptionKey, iv); | ||
var plaintext = cipher.update(ciphertext, 'binary', 'utf8'); | ||
plaintext += cipher.final('utf8'); | ||
var cookieName = plaintext.substring(0, plaintext.indexOf(COOKIE_NAME_SEP)); | ||
if (cookieName !== opts.cookieName) { | ||
return; | ||
} | ||
var cookieName = plaintext.substring(0, plaintext.indexOf(COOKIE_NAME_SEP)); | ||
if (cookieName !== opts.cookieName) { | ||
return; | ||
} | ||
try { | ||
return { | ||
content: JSON.parse(plaintext.substring(plaintext.indexOf(COOKIE_NAME_SEP) + 1)), | ||
createdAt: createdAt, | ||
duration: duration | ||
}; | ||
} catch (x) { | ||
return; | ||
} | ||
try { | ||
return { | ||
content: JSON.parse( | ||
plaintext.substring(plaintext.indexOf(COOKIE_NAME_SEP) + 1) | ||
), | ||
createdAt: createdAt, | ||
duration: duration | ||
}; | ||
} catch (x) { | ||
return; | ||
} | ||
} | ||
@@ -194,2 +216,3 @@ | ||
this.content = {}; | ||
this.json = JSON.stringify(this._content); | ||
this.loaded = false; | ||
@@ -212,5 +235,8 @@ this.dirty = false; | ||
// here, we check that the security bits are set correctly | ||
var secure = (res.socket && res.socket.encrypted) || (req.connection && req.connection.proxySecure); | ||
if (opts.cookie.secure && !secure) | ||
throw new Error("you cannot have a secure cookie unless the socket is secure or you declare req.connection.proxySecure to be true."); | ||
var secure = (res.socket && res.socket.encrypted) || | ||
(req.connection && req.connection.proxySecure); | ||
if (opts.cookie.secure && !secure) { | ||
throw new Error("you cannot have a secure cookie unless the socket is " + | ||
" secure or you declare req.connection.proxySecure to be true."); | ||
} | ||
} | ||
@@ -220,3 +246,5 @@ | ||
updateDefaultExpires: function() { | ||
if (this.opts.cookie.maxAge) return; | ||
if (this.opts.cookie.maxAge) { | ||
return; | ||
} | ||
@@ -228,3 +256,4 @@ if (this.opts.cookie.ephemeral) { | ||
// the cookie should expire when it becomes invalid | ||
// we add an extra second because the conversion to a date truncates the milliseconds | ||
// we add an extra second because the conversion to a date | ||
// truncates the milliseconds | ||
this.expires = new Date(time + this.duration + 1000); | ||
@@ -236,8 +265,9 @@ } | ||
var self = this; | ||
Object.keys(this.content).forEach(function(k) { | ||
Object.keys(this._content).forEach(function(k) { | ||
// exclude this key if it's meant to be preserved | ||
if (keysToPreserve && (keysToPreserve.indexOf(k) > -1)) | ||
if (keysToPreserve && (keysToPreserve.indexOf(k) > -1)) { | ||
return; | ||
} | ||
delete self.content[k]; | ||
delete self._content[k]; | ||
}); | ||
@@ -259,4 +289,5 @@ }, | ||
} | ||
if (!this.loaded) | ||
if (!this.loaded) { | ||
this.loadFromCookie(true); | ||
} | ||
this.dirty = true; | ||
@@ -272,3 +303,3 @@ this.duration = newDuration; | ||
box: function() { | ||
return encode(this.opts, this.content, this.duration, this.createdAt); | ||
return encode(this.opts, this._content, this.duration, this.createdAt); | ||
}, | ||
@@ -280,8 +311,11 @@ | ||
var unboxed = decode(this.opts, content); | ||
if (!unboxed) return; | ||
if (!unboxed) { | ||
return; | ||
} | ||
var self = this; | ||
Object.keys(unboxed.content).forEach(function(k) { | ||
self.content[k] = unboxed.content[k]; | ||
self._content[k] = unboxed.content[k]; | ||
}); | ||
@@ -295,3 +329,3 @@ | ||
updateCookie: function() { | ||
if (this.dirty) { | ||
if (this.isDirty()) { | ||
// support for adding/removing cookie expires | ||
@@ -309,3 +343,3 @@ this.opts.cookie.expires = this.expires; | ||
loadFromCookie: function(force_reset) { | ||
loadFromCookie: function(forceReset) { | ||
var cookie = this.cookies.get(this.opts.cookieName); | ||
@@ -318,6 +352,6 @@ if (cookie) { | ||
// should we reset this session? | ||
if (expiresAt < now) | ||
if (expiresAt < now) { | ||
this.reset(); | ||
// if expiration is soon, push back a few minutes to not interrupt user | ||
else if (expiresAt - now < this.activeDuration) { | ||
} else if (expiresAt - now < this.activeDuration) { | ||
this.createdAt += this.activeDuration; | ||
@@ -328,3 +362,3 @@ this.dirty = true; | ||
} else { | ||
if (force_reset) { | ||
if (forceReset) { | ||
this.reset(); | ||
@@ -337,75 +371,53 @@ } else { | ||
this.loaded = true; | ||
this.json = JSON.stringify(this._content); | ||
return true; | ||
}, | ||
// called to create a proxy that monitors the session | ||
// for new properties being set | ||
monitor: function() { | ||
// the target for the proxy will be the content | ||
// variable, to simplify the proxying | ||
var sessionHandler = new Handler(this.content); | ||
var raw_session = this; | ||
isDirty: function() { | ||
return this.dirty || (this.json !== JSON.stringify(this._content)); | ||
} | ||
// all values from content except special values | ||
sessionHandler.get = function(rcvr, name) { | ||
if (['reset'].indexOf(name) > -1) { | ||
return raw_session[name].bind(raw_session); | ||
} else if (['setDuration'].indexOf(name) > -1) { | ||
return raw_session[name].bind(raw_session); | ||
} else { | ||
if (!raw_session.loaded) { | ||
var didLoad = raw_session.loadFromCookie(); | ||
if (!didLoad) return undefined; | ||
} | ||
return this.target[name]; | ||
} | ||
}; | ||
}; | ||
// set all values to content | ||
sessionHandler.set = function(rcvr, name, value) { | ||
// we have to load existing content, otherwise it will later override | ||
// the content that is written. | ||
if (!raw_session.loaded) | ||
raw_session.loadFromCookie(true); | ||
Object.defineProperty(Session.prototype, 'content', { | ||
get: function getContent() { | ||
if (!this.loaded) { | ||
this.loadFromCookie(); | ||
} | ||
return this._content; | ||
}, | ||
set: function setContent(value) { | ||
Object.defineProperty(value, 'reset', { | ||
enumerable: false, | ||
value: this.reset.bind(this) | ||
}); | ||
Object.defineProperty(value, 'setDuration', { | ||
enumerable: false, | ||
value: this.setDuration.bind(this) | ||
}); | ||
this._content = value; | ||
} | ||
}); | ||
this.target[name] = value; | ||
raw_session.dirty = true; | ||
}; | ||
// if key is deleted | ||
sessionHandler.delete = function(name) { | ||
// we have to load existing content, otherwise it will later override | ||
// the content that is written. | ||
if (!raw_session.loaded) { | ||
var didLoad = raw_session.loadFromCookie(); | ||
if (!didLoad) return; | ||
} | ||
function clientSessionFactory(opts) { | ||
if (!opts) { | ||
throw new Error("no options provided, some are required"); | ||
} | ||
delete this.target[name]; | ||
raw_session.dirty = true; | ||
}; | ||
var proxySession = Proxy_.create(sessionHandler); | ||
return proxySession; | ||
if (!opts.secret) { | ||
throw new Error("cannot set up sessions without a secret"); | ||
} | ||
}; | ||
var cookieSession = function(opts) { | ||
if (!opts) | ||
throw "no options provided, some are required"; // XXX rename opts? | ||
if (!opts.secret) | ||
throw "cannot set up sessions without a secret"; | ||
// defaults | ||
opts.cookieName = opts.cookieName || "session_state"; | ||
opts.duration = opts.duration || 24*60*60*1000; | ||
opts.activeDuration = 'activeDuration' in opts ? opts.activeDuration : ACTIVE_DURATION; | ||
opts.activeDuration = 'activeDuration' in opts ? | ||
opts.activeDuration : ACTIVE_DURATION; | ||
// set up cookie defaults | ||
opts.cookie = opts.cookie || {}; | ||
if (typeof(opts.cookie.httpOnly) == 'undefined') | ||
if (typeof opts.cookie.httpOnly === 'undefined') { | ||
opts.cookie.httpOnly = true; | ||
} | ||
@@ -426,4 +438,4 @@ // let's not default to secure just yet, | ||
return function(req, res, next) { | ||
if (req[propertyName]) { | ||
return function clientSession(req, res, next) { | ||
if (propertyName in req) { | ||
return next(); //self aware | ||
@@ -433,24 +445,38 @@ } | ||
var cookies = new Cookies(req, res); | ||
var raw_session; | ||
var rawSession; | ||
try { | ||
raw_session = new Session(req, res, cookies, opts); | ||
rawSession = new Session(req, res, cookies, opts); | ||
} catch (x) { | ||
// this happens only if there's a big problem | ||
process.nextTick(function() {next("client-sessions error: " + x.toString());}); | ||
process.nextTick(function() { | ||
next("client-sessions error: " + x.toString()); | ||
}); | ||
return; | ||
} | ||
req[propertyName] = raw_session.monitor(); | ||
Object.defineProperty(req, propertyName, { | ||
get: function getSession() { | ||
return rawSession.content; | ||
}, | ||
set: function setSession(value) { | ||
if (isObject(value)) { | ||
rawSession.content = value; | ||
} else { | ||
throw new TypeError("cannot set client-session to non-object"); | ||
} | ||
} | ||
}); | ||
var writeHead = res.writeHead; | ||
res.writeHead = function () { | ||
raw_session.updateCookie(); | ||
rawSession.updateCookie(); | ||
return writeHead.apply(res, arguments); | ||
} | ||
}; | ||
next(); | ||
}; | ||
}; | ||
} | ||
module.exports = cookieSession; | ||
module.exports = clientSessionFactory; | ||
@@ -457,0 +483,0 @@ |
{ | ||
"name" : "client-sessions", | ||
"version" : "0.4.1", | ||
"version" : "0.5.0", | ||
"description" : "secure sessions stored in cookies", | ||
@@ -11,4 +11,3 @@ "main" : "lib/client-sessions", | ||
"dependencies" : { | ||
"cookies" : "0.3.6", | ||
"node-proxy": "0.6.0" | ||
"cookies" : "0.3.8" | ||
}, | ||
@@ -15,0 +14,0 @@ "devDependencies": { |
@@ -46,14 +46,22 @@ // a NODE_ENV of test will supress console output to stderr which | ||
suite.addBatch({ | ||
"a single request object" : { | ||
"middleware" : { | ||
topic: function() { | ||
var self = this; | ||
var middleware = cookieSessions({ | ||
cookieName: 'session', | ||
secret: 'yo', | ||
activeDuration: 0, | ||
cookie: { | ||
maxAge: 5000 | ||
} | ||
}); | ||
var app = create_app(); | ||
app.get("/foo", function(req, res) { | ||
self.callback(null, req); | ||
res.send("hello"); | ||
var req = { | ||
headers: {} | ||
}; | ||
var res = {}; | ||
middleware(req, res, function(err) { | ||
self.callback(err, req, res); | ||
}); | ||
var browser = tobi.createBrowser(app); | ||
browser.get("/foo", function(res, $) {}); | ||
}, | ||
@@ -70,2 +78,5 @@ "includes a session object": function(err, req) { | ||
}, | ||
"session object has setDuration function": function(err, req) { | ||
assert.isFunction(req.session.setDuration); | ||
}, | ||
"set variables and clear them yields no variables": function(err, req) { | ||
@@ -91,2 +102,17 @@ req.session.bar = 'baz'; | ||
assert.equal(req.session.foo, 'foobar'); | ||
}, | ||
"set session property absorbs set object": function(err, req) { | ||
req.session.reset(); | ||
req.session.foo = 'quux'; | ||
req.session = { bar: 'baz' }; | ||
assert.isUndefined(req.session.foo); | ||
assert.isFunction(req.session.reset); | ||
assert.isFunction(req.session.setDuration); | ||
assert.equal(req.session.bar, 'baz'); | ||
assert.throws(function() { | ||
req.session = 'blah'; | ||
}, TypeError); | ||
} | ||
@@ -237,2 +263,39 @@ } | ||
} | ||
}, | ||
"across three requests with deep objects" : { | ||
topic: function() { | ||
var self = this; | ||
// simple app | ||
var app = create_app(); | ||
app.get("/foo", function(req, res) { | ||
req.session.reset(); | ||
req.session.foo = 'foobar'; | ||
req.session.bar = { a: 'b' }; | ||
res.send("foo"); | ||
}); | ||
app.get("/bar", function(req, res) { | ||
req.session.bar.c = 'd'; | ||
res.send("bar"); | ||
}); | ||
app.get("/baz", function(req, res) { | ||
self.callback(null, req); | ||
res.send("baz"); | ||
}); | ||
var browser = tobi.createBrowser(app); | ||
browser.get("/foo", function(res, $) { | ||
browser.get("/bar", function(res, $) { | ||
browser.get("/baz", function(res, $) { | ||
}); | ||
}); | ||
}); | ||
}, | ||
"session maintains state": function(err, req) { | ||
assert.equal(req.session.foo, 'foobar'); | ||
assert.equal(req.session.bar.c, 'd'); | ||
} | ||
} | ||
@@ -239,0 +302,0 @@ }); |
Sorry, the diff of this file is not supported yet
1
67811
9
1425
+ Addedcookies@0.3.8(transitive)
- Removednode-proxy@0.6.0
- Removedcookies@0.3.6(transitive)
- Removednode-proxy@0.6.0(transitive)
Updatedcookies@0.3.8