diff --git a/lib/fastifySession.js b/lib/fastifySession.js index 3b6da8b..fa90d05 100644 --- a/lib/fastifySession.js +++ b/lib/fastifySession.js @@ -64,7 +64,8 @@ function session (fastify, options, next) { idGenerator, cookieOpts, cookieSigner, - session + session, + decryptedSessionId ) restoredSession.destroy(err => { @@ -83,7 +84,8 @@ function session (fastify, options, next) { idGenerator, cookieOpts, cookieSigner, - session + session, + decryptedSessionId ) } else { request.session = Session.restore( @@ -91,7 +93,8 @@ function session (fastify, options, next) { idGenerator, cookieOpts, cookieSigner, - session + session, + decryptedSessionId ) } done() diff --git a/lib/session.js b/lib/session.js index 123895c..f259bcd 100644 --- a/lib/session.js +++ b/lib/session.js @@ -14,14 +14,16 @@ const requestKey = Symbol('request') const cookieOptsKey = Symbol('cookieOpts') const originalHash = Symbol('originalHash') const hash = Symbol('hash') +const sessionIdKey = Symbol('sessionId') module.exports = class Session { - constructor (request, idGenerator, cookieOpts, cookieSigner, prevSession) { + constructor (request, idGenerator, cookieOpts, cookieSigner, prevSession, sessionId = idGenerator(request)) { this[generateId] = idGenerator this[cookieOptsKey] = cookieOpts this[maxAge] = cookieOpts.maxAge this[cookieSignerKey] = cookieSigner this[requestKey] = request + this[sessionIdKey] = sessionId this.cookie = new Cookie(cookieOpts) if (prevSession) { @@ -32,8 +34,7 @@ module.exports = class Session { } this.touch() - if (!this.sessionId) { - this.sessionId = this[generateId](this[requestKey]) + if (!this.encryptedSessionId) { this.encryptedSessionId = cookieSigner.sign(this.sessionId) } this[originalHash] = this[hash]() @@ -81,14 +82,14 @@ module.exports = class Session { destroy (callback) { if (callback) { - this[requestKey].sessionStore.destroy(this.sessionId, error => { + this[requestKey].sessionStore.destroy(this[sessionIdKey], error => { this[requestKey].session = null callback(error) }) } else { return new Promise((resolve, reject) => { - this[requestKey].sessionStore.destroy(this.sessionId, error => { + this[requestKey].sessionStore.destroy(this[sessionIdKey], error => { this[requestKey].session = null if (error) { @@ -103,15 +104,15 @@ module.exports = class Session { reload (callback) { if (callback) { - this[requestKey].sessionStore.get(this.sessionId, (error, session) => { - this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[cookieSignerKey], session) + this[requestKey].sessionStore.get(this[sessionIdKey], (error, session) => { + this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[cookieSignerKey], session, this[sessionIdKey]) callback(error) }) } else { return new Promise((resolve, reject) => { - this[requestKey].sessionStore.get(this.sessionId, (error, session) => { - this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[cookieSignerKey], session) + this[requestKey].sessionStore.get(this[sessionIdKey], (error, session) => { + this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[cookieSignerKey], session, this[sessionIdKey]) if (error) { reject(error) @@ -125,12 +126,12 @@ module.exports = class Session { save (callback) { if (callback) { - this[requestKey].sessionStore.set(this.sessionId, this, error => { + this[requestKey].sessionStore.set(this[sessionIdKey], this, error => { callback(error) }) } else { return new Promise((resolve, reject) => { - this[requestKey].sessionStore.set(this.sessionId, this, error => { + this[requestKey].sessionStore.set(this[sessionIdKey], this, error => { if (error) { reject(error) } else { @@ -141,6 +142,10 @@ module.exports = class Session { } } + get sessionId () { + return this[sessionIdKey] + } + [hash] () { const sess = this const str = stringify(sess, function (key, val) { @@ -162,8 +167,8 @@ module.exports = class Session { return this[originalHash] !== this[hash]() } - static restore (request, idGenerator, cookieOpts, cookieSigner, prevSession) { - const restoredSession = new Session(request, idGenerator, cookieOpts, cookieSigner, prevSession) + static restore (request, idGenerator, cookieOpts, cookieSigner, prevSession, sessionId) { + const restoredSession = new Session(request, idGenerator, cookieOpts, cookieSigner, prevSession, sessionId) const restoredCookie = new Cookie(cookieOpts) restoredCookie.expires = new Date(prevSession.cookie.expires) restoredSession.cookie = restoredCookie diff --git a/test/base.test.js b/test/base.test.js index df9b5a6..59d7eaa 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -4,6 +4,7 @@ const test = require('tap').test const Signer = require('@fastify/cookie').Signer const fastifyPlugin = require('fastify-plugin') const { DEFAULT_OPTIONS, DEFAULT_COOKIE, DEFAULT_SESSION_ID, DEFAULT_SECRET, DEFAULT_ENCRYPTED_SESSION_ID, buildFastify } = require('./util') +const Store = require('../lib/fastifySession').Store test('should not set session cookie on post without params', async (t) => { t.plan(3) @@ -20,6 +21,36 @@ test('should not set session cookie on post without params', async (t) => { t.same(response.headers['set-cookie'], undefined) }) +test('should save the session properly', async (t) => { + t.plan(6) + const store = new Store() + const fastify = await buildFastify((request, reply) => { + request.session.test = true + + request.session.save(() => { + const storeMap = store.store + // Only one session + t.equal(storeMap.size, 1) + + const session = [...storeMap.entries()][0][1] + const keys = Object.keys(session) + + // Only storing three keys: cookie, encryptedSessionId and test + t.equal(keys.length, 3) + t.ok(session.cookie) + t.ok(session.encryptedSessionId) + t.equal(session.test, true) + }) + reply.send() + }, { ...DEFAULT_OPTIONS, store }) + t.teardown(() => fastify.close()) + + const response = await fastify.inject({ + url: '/' + }) + t.equal(response.statusCode, 200) +}) + test('should set session cookie', async (t) => { t.plan(4) const fastify = await buildFastify((request, reply) => { @@ -46,35 +77,62 @@ test('should set session cookie', async (t) => { }) test('should support multiple secrets', async (t) => { - t.plan(2) - const options = { - secret: ['geheim', DEFAULT_SECRET] - } + t.plan(10) + const sign = require('@fastify/cookie/signer').sign - const plugin = fastifyPlugin(async (fastify, opts) => { - fastify.addHook('onRequest', (request, reply, done) => { - request.sessionStore.set('aYb4uTIhdBXCfk_ylik4QN6-u26K0u0e', { - expires: Date.now() + 1000 - }, done) - }) + const newSecret = 'geheim' + + const sessionId = 'aYb4uTIhdBXCfk_ylik4QN6-u26K0u0e' + const sessionIdSignedWithOldSecret = sign(sessionId, DEFAULT_SECRET) + const sessionIdSignedWithNewSecret = sign(sessionId, newSecret) + + const storeMap = new Map() + const store = new Store(storeMap) + + storeMap.set(sessionId, { + test: 0, + cookie: {} }) - function handler (request, reply) { - request.session.test = {} - reply.send(200) + + const options = { + secret: [newSecret, DEFAULT_SECRET], + store } - const fastify = await buildFastify(handler, options, plugin) + + let counter = 0 + const fastify = await buildFastify( + async function handler (request, reply) { + t.equal(request.session.sessionId, sessionId) + t.equal(request.session.test, counter) + + request.session.test = ++counter + await request.session.save() + reply.send(200) + }, options) + t.teardown(() => fastify.close()) - const response = await fastify.inject({ + const response1 = await fastify.inject({ url: '/', headers: { 'x-forwarded-proto': 'https', - cookie: 'sessionId=aYb4uTIhdBXCfk_ylik4QN6-u26K0u0e.eiVu2YbrcqbTUYTYaANks%2Fjn%2Bjta7QgpsxLO%2BOLN%2F4U; Path=/; HttpOnly; Secure' + cookie: `sessionId=${sessionIdSignedWithOldSecret}; Path=/; HttpOnly; Secure` } }) + t.equal(response1.statusCode, 200) + t.ok(response1.headers['set-cookie'].includes(encodeURIComponent(sessionIdSignedWithNewSecret))) - t.equal(response.statusCode, 200) - t.equal(response.headers['set-cookie'].includes('aYb4uTIhdBXCfk_ylik4QN6-u26K0u0e'), false) + const response2 = await fastify.inject({ + url: '/', + headers: { + 'x-forwarded-proto': 'https', + cookie: `sessionId=${sessionIdSignedWithNewSecret}; Path=/; HttpOnly; Secure` + } + }) + t.not(storeMap.get(sessionId).sessionId) + t.equal(storeMap.get(sessionId).test, 2) + t.equal(response2.statusCode, 200) + t.equal(response2.headers['set-cookie'].includes(sessionId), true) }) test('should set session cookie using the specified cookie name', async (t) => { @@ -100,20 +158,11 @@ test('should set session cookie using the specified cookie name', async (t) => { test('should set session cookie using the default cookie name', async (t) => { t.plan(2) - const plugin = fastifyPlugin(async (fastify, opts) => { - fastify.addHook('onRequest', (request, reply, done) => { - request.sessionStore.set(DEFAULT_SESSION_ID, { - expires: Date.now() + 1000, - sessionId: DEFAULT_SESSION_ID, - cookie: { secure: true, httpOnly: true, path: '/' } - }, done) - }) - }) function handler (request, reply) { request.session.test = {} reply.send(200) } - const fastify = await buildFastify(handler, DEFAULT_OPTIONS, plugin) + const fastify = await buildFastify(handler, DEFAULT_OPTIONS) t.teardown(() => fastify.close()) const response = await fastify.inject({ @@ -125,7 +174,7 @@ test('should set session cookie using the default cookie name', async (t) => { }) t.equal(response.statusCode, 200) - t.match(response.headers['set-cookie'], /sessionId=undefined; Path=\/; HttpOnly; Secure/) + t.match(response.headers['set-cookie'], /sessionId=[\w-]{32}.[\w-%]{43,57}; Path=\/; HttpOnly; Secure/) }) test('should set express sessions using the specified cookiePrefix', async (t) => { @@ -163,11 +212,14 @@ test('should set express sessions using the specified cookiePrefix', async (t) = }) test('should create new session on expired session', async (t) => { - t.plan(2) + t.plan(3) + + const DateNow = Date.now + const now = Date.now() + Date.now = () => now const plugin = fastifyPlugin(async (fastify, opts) => { fastify.addHook('onRequest', (request, reply, done) => { request.sessionStore.set(DEFAULT_SESSION_ID, { - sessionId: DEFAULT_SESSION_ID, cookie: { secure: true, httpOnly: true, path: '/', expires: Date.now() - 1000 } }, done) }) @@ -180,7 +232,10 @@ test('should create new session on expired session', async (t) => { cookie: { maxAge: 100 } } const fastify = await buildFastify(handler, options, plugin) - t.teardown(() => fastify.close()) + t.teardown(() => { + fastify.close() + Date.now = DateNow + }) const response = await fastify.inject({ url: '/', @@ -191,7 +246,8 @@ test('should create new session on expired session', async (t) => { }) t.equal(response.statusCode, 200) - t.match(response.headers['set-cookie'], /sessionId=.*\..*; Path=\/; Expires=.*; HttpOnly; Secure/) + t.not(response.headers['set-cookie'].includes(DEFAULT_SESSION_ID)) + t.match(response.headers['set-cookie'], RegExp(`sessionId=.*; Path=/; Expires=${new Date(now + 100).toUTCString()}; HttpOnly; Secure`)) }) test('should set session.cookie.expires if maxAge', async (t) => { @@ -223,7 +279,7 @@ test('should set new session cookie if expired', async (t) => { fastify.addHook('onRequest', (request, reply, done) => { request.sessionStore.set(DEFAULT_SESSION_ID, { cookie: { - expires: Date.now() + 1000 + expires: Date.now() - 1000 } }, done) }) diff --git a/test/session.test.js b/test/session.test.js index 435fe7f..4bb1281 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -140,7 +140,7 @@ test('should keep user data in session throughout the time', async (t) => { reply.send(200) }) fastify.get('/check', (request, reply) => { - t.ok(request.session.foo === 'bar') + t.equal(request.session.foo, 'bar') reply.send(200) }) await fastify.listen({ port: 0 }) @@ -228,7 +228,6 @@ test('should decryptSession with custom request object', async (t) => { fastify.addHook('onRequest', (request, reply, done) => { request.sessionStore.set(DEFAULT_SESSION_ID, { testData: 'this is a test', - sessionId: DEFAULT_SESSION_ID, cookie: { secure: true, httpOnly: true, path: '/', expires: Date.now() + 1000 } }, done) }) @@ -248,8 +247,8 @@ test('should decryptSession with custom request object', async (t) => { const requestObj = {} fastify.decryptSession(sessionId, requestObj, () => { t.equal(requestObj.session.cookie.maxAge, null) - t.equal(requestObj.session.sessionId, DEFAULT_SESSION_ID) t.equal(requestObj.session.testData, 'this is a test') + t.equal(requestObj.session.sessionId, DEFAULT_SESSION_ID) }) }) @@ -388,7 +387,7 @@ test('should update the expires property of the session using Session#touch() ev }) t.equal(response2.statusCode, 200) - t.ok(response1.body !== response2.body) + t.not(response1.body, response2.body) }) test('should use custom sessionId generator if available (with request)', async (t) => { @@ -424,7 +423,7 @@ test('should use custom sessionId generator if available (with request)', async url: '/' }) t.equal(response1.statusCode, 200) - t.ok(response1.headers['set-cookie'] !== undefined) + t.not(response1.headers['set-cookie'], undefined) t.ok(response1.body.startsWith('custom-')) const response2 = await fastify.inject({ @@ -432,7 +431,7 @@ test('should use custom sessionId generator if available (with request)', async headers: { Cookie: response1.headers['set-cookie'] } }) t.equal(response2.statusCode, 200) - t.ok(response2.headers['set-cookie'] !== undefined) + t.not(response2.headers['set-cookie'], undefined) const response3 = await fastify.inject({ url: '/', @@ -486,7 +485,7 @@ test('should use custom sessionId generator if available (with request and rolli url: '/' }) t.equal(response1.statusCode, 200) - t.ok(response1.headers['set-cookie'] !== undefined) + t.not(response1.headers['set-cookie'], undefined) t.ok(response1.body.startsWith('custom-')) const response2 = await fastify.inject({ @@ -494,7 +493,7 @@ test('should use custom sessionId generator if available (with request and rolli headers: { Cookie: response1.headers['set-cookie'] } }) t.equal(response2.statusCode, 200) - t.ok(response2.headers['set-cookie'] !== undefined) + t.not(response2.headers['set-cookie'], undefined) const response3 = await fastify.inject({ url: '/',