diff --git a/README.md b/README.md index fc775f4..bdbcadd 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,11 @@ app.addHook('preHandler', (request, reply, next) => { }) ``` **NOTE**: For all unencrypted (HTTP) connections, you need to set the `secure` cookie option to `false`. See below for all cookie options and their details. -The `sessionStore` decorator of the `request` allows to get, save and delete sessions. +The `session` object has methods that allow you to get, save, reload and delete sessions. ```js app.register(fastifySession, {secret: 'a secret with minimum length of 32 characters'}); app.addHook('preHandler', (request, reply, next) => { - const session = request.session; - request.sessionStore.destroy(session.sessionId, next); + request.session.destroy(next); }) ``` @@ -103,7 +102,7 @@ idGenerator: (request) => { Allows to access or modify the session data. -#### request.destroySession(callback) +#### Session#destroy(callback) Allows to destroy the session in the store @@ -111,16 +110,29 @@ Allows to destroy the session in the store Updates the `expires` property of the session. -#### Session#regenerate() +#### Session#regenerate(callback) -Regenerates the session by generating a new `sessionId`. +Regenerates the session by generating a new `sessionId` and persist it to the store. ```js -fastify.get('/regenerate', (request, reply) => { - request.session.regenerate(); - reply.send(request.session.sessionId); +fastify.get('/regenerate', (request, reply, done) => { + request.session.regenerate(error => { + if (error) { + done(error); + return; + } + reply.send(request.session.sessionId); + }); }); ``` +#### Session#reload(callback) + +Reloads the session data from the store and re-populates the `request.session` object. + +#### Session#save(callback) + +Save the session back to the store, replacing the contents on the store with the contents in memory. + #### Session#get(key) Gets a value from the session diff --git a/lib/fastifySession.js b/lib/fastifySession.js index 420d2b1..e27d898 100644 --- a/lib/fastifySession.js +++ b/lib/fastifySession.js @@ -28,7 +28,6 @@ function session (fastify, options, next) { }) fastify.decorateRequest('sessionStore', { getter: () => options.store }) fastify.decorateRequest('session', null) - fastify.decorateRequest('destroySession', destroySession) fastify.addHook('onRequest', onRequest(options)) fastify.addHook('onSend', onSend(options)) next() @@ -65,7 +64,22 @@ function decryptSession (sessionId, options, request, done) { return } if (session && session.expires && session.expires <= Date.now()) { - options.store.destroy(sessionId, getDestroyCallback(secret, request, done, cookieOpts, idGenerator)) + const restoredSession = Session.restore( + request, + idGenerator, + cookieOpts, + secret, + session + ) + + restoredSession.destroy(err => { + if (err) { + done(err) + return + } + + restoredSession.regenerate(done) + }) return } if (options.rolling) { @@ -118,7 +132,7 @@ function onSend (options) { done() return } - options.store.set(session.sessionId, session, (err) => { + session.save((err) => { if (err) { done(err) return @@ -133,29 +147,11 @@ function onSend (options) { } } -function getDestroyCallback (secret, request, done, cookieOpts, idGenerator) { - return function destroyCallback (err) { - if (err) { - done(err) - return - } - newSession(secret, request, cookieOpts, idGenerator, done) - } -} - function newSession (secret, request, cookieOpts, idGenerator, done) { request.session = new Session(request, idGenerator, cookieOpts, secret) done() } -function destroySession (done) { - const request = this - request.sessionStore.destroy(request.session.sessionId, (err) => { - request.session = null - done(err) - }) -} - function checkOptions (options) { if (!options.secret) { return new Error('the secret option is required!') diff --git a/lib/session.js b/lib/session.js index 8c933db..b309eb8 100644 --- a/lib/session.js +++ b/lib/session.js @@ -9,19 +9,22 @@ const sign = Symbol('sign') const addDataToSession = Symbol('addDataToSession') const generateId = Symbol('generateId') const requestKey = Symbol('request') +const cookieOptsKey = Symbol('cookieOpts') module.exports = class Session { constructor (request, idGenerator, cookieOpts, secret, prevSession = {}) { this[generateId] = idGenerator this.expires = null this.cookie = new Cookie(cookieOpts) + this[cookieOptsKey] = cookieOpts this[maxAge] = cookieOpts.maxAge this[secretKey] = secret this[addDataToSession](prevSession) this[requestKey] = request this.touch() if (!this.sessionId) { - this.regenerate() + this.sessionId = this[generateId](this[requestKey]) + this.encryptedSessionId = this[sign]() } } @@ -32,9 +35,14 @@ module.exports = class Session { } } - regenerate () { - this.sessionId = this[generateId](this[requestKey]) - this.encryptedSessionId = this[sign]() + regenerate (callback) { + const session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[secretKey]) + + this[requestKey].sessionStore.set(session.sessionId, session, error => { + this[requestKey].session = session + + callback(error) + }) } [addDataToSession] (prevSession) { @@ -53,6 +61,28 @@ module.exports = class Session { this[key] = value } + destroy (callback) { + this[requestKey].sessionStore.destroy(this.sessionId, error => { + this[requestKey].session = null + + callback(error) + }) + } + + reload (callback) { + this[requestKey].sessionStore.get(this.sessionId, (error, session) => { + this[requestKey].session = new Session(this[requestKey], this[generateId], this[cookieOptsKey], this[secretKey], session) + + callback(error) + }) + } + + save (callback) { + this[requestKey].sessionStore.set(this.sessionId, this, error => { + callback(error) + }) + } + [sign] () { return cookieSignature.sign(this.sessionId, this[secretKey]) } diff --git a/test/session.test.js b/test/session.test.js index 3cb183d..1632281 100644 --- a/test/session.test.js +++ b/test/session.test.js @@ -21,7 +21,7 @@ test('should add session object to request', async (t) => { test('should destroy the session', async (t) => { t.plan(3) const port = await testServer((request, reply) => { - request.destroySession((err) => { + request.session.destroy((err) => { t.falsy(err) t.is(request.session, null) reply.send(200) @@ -167,8 +167,13 @@ test('should generate new sessionId', async (t) => { fastify.register(fastifySession, options) fastify.get('/', (request, reply) => { oldSessionId = request.session.sessionId - request.session.regenerate() - reply.send(200) + request.session.regenerate(error => { + if (error) { + reply.status(500).send('Error ' + error) + } else { + reply.send(200) + } + }) }) fastify.get('/check', (request, reply) => { t.not(request.session.sessionId, oldSessionId) @@ -271,6 +276,40 @@ test('should decryptSession with custom cookie options', async (t) => { }) }) +test('should bubble up errors with destroy call if session expired', async (t) => { + t.plan(2) + const fastify = Fastify() + const store = { + set (id, data, cb) { cb(null) }, + get (id, cb) { + cb(null, { expires: Date.now() - 1000, cookie: { expires: Date.now() - 1000 } }) + }, + destroy (id, cb) { cb(new Error('No can do')) } + } + + const options = { + secret: 'cNaoPYAwF60HZJzkcNaoPYAwF60HZJzk', + store, + cookie: { secure: false } + } + + fastify.register(fastifyCookie) + fastify.register(fastifySession, options) + + fastify.get('/', (request, reply) => { + reply.send(200) + }) + await fastify.listen(0) + fastify.server.unref() + + const { statusCode, body } = await request({ + url: 'http://localhost:' + fastify.server.address().port, + headers: { cookie: 'sessionId=_TuQsCBgxtHB3bu6wsRpTXfjqR5sK-q_.3mu5mErW+QI7w+Q0V2fZtrztSvqIpYgsnnC8LQf6ERY;' } + }) + t.is(statusCode, 500) + t.is(JSON.parse(body).message, 'No can do') +}) + test('should not reset session cookie expiration if rolling is false', async (t) => { t.plan(3) @@ -361,8 +400,13 @@ test('should use custom sessionId generator if available (with request)', async }) fastify.get('/login', (request, reply) => { request.session.returningVisitor = true - request.session.regenerate() - reply.status(200).send('OK ' + request.session.sessionId) + request.session.regenerate(error => { + if (error) { + reply.status(500).send('Error ' + error) + } else { + reply.status(200).send('OK ' + request.session.sessionId) + } + }) }) await fastify.listen(0) fastify.server.unref() @@ -417,8 +461,13 @@ test('should use custom sessionId generator if available (with request and rolli }) fastify.get('/login', (request, reply) => { request.session.returningVisitor = true - request.session.regenerate() - reply.status(200).send('OK ' + request.session.sessionId) + request.session.regenerate(error => { + if (error) { + reply.status(500).send('Error ' + error) + } else { + reply.status(200).send('OK ' + request.session.sessionId) + } + }) }) await fastify.listen(0) fastify.server.unref() @@ -444,3 +493,50 @@ test('should use custom sessionId generator if available (with request and rolli t.is(response3.statusCode, 200) t.true(sessionBody3.startsWith('returningVisitor-')) }) + +test('should reload the session', async (t) => { + t.plan(4) + const port = await testServer((request, reply) => { + request.session.someData = 'some-data' + t.is(request.session.someData, 'some-data') + + request.session.reload((err) => { + t.falsy(err) + + t.is(request.session.someData, undefined) + + reply.send(200) + }) + }, DEFAULT_OPTIONS) + + const { response } = await request(`http://localhost:${port}`) + + t.is(response.statusCode, 200) +}) + +test('should save the session', async (t) => { + t.plan(6) + const port = await testServer((request, reply) => { + request.session.someData = 'some-data' + t.is(request.session.someData, 'some-data') + + request.session.save((err) => { + t.falsy(err) + + t.is(request.session.someData, 'some-data') + + // unlike previous test, here the session data remains after a save + request.session.reload((err) => { + t.falsy(err) + + t.is(request.session.someData, 'some-data') + + reply.send(200) + }) + }) + }, DEFAULT_OPTIONS) + + const { response } = await request(`http://localhost:${port}`) + + t.is(response.statusCode, 200) +}) diff --git a/types/types.d.ts b/types/types.d.ts index 7961f26..ae9b068 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -10,9 +10,6 @@ declare module 'fastify' { /** A session store. */ sessionStore: Readonly; - - /** Allows to destroy the session in the store. */ - destroySession(callback: (err: Error) => void): void; } interface Session extends SessionData {} @@ -29,7 +26,16 @@ interface SessionData extends ExpressSessionData { /** * Regenerates the session by generating a new `sessionId`. */ - regenerate(): void; + regenerate(callback: (err?: Error) => void): void; + + /** Allows to destroy the session in the store. */ + destroy(callback: (err?: Error) => void): void; + + /** Reloads the session data from the store and re-populates the request.session object. */ + reload(callback: (err?: Error) => void): void; + + /** Save the session back to the store, replacing the contents on the store with the contents in memory. */ + save(callback: (err?: Error) => void): void; /** sets values in the session. */ set(key: string, value: unknown): void; diff --git a/types/types.test-d.ts b/types/types.test-d.ts index febd047..b4ce8dc 100644 --- a/types/types.test-d.ts +++ b/types/types.test-d.ts @@ -60,7 +60,7 @@ app.route({ method: 'GET', url: '/', preHandler(req, _rep, next) { - req.destroySession(next); + expectType(req.session.destroy(next)); }, async handler(request, reply) { expectType(request); @@ -69,7 +69,6 @@ app.route({ expectError((request.sessionStore = null)); expectError(request.session.doesNotExist()); expectType<{ id: number } | undefined>(request.session.user); - request.session.regenerate(); request.sessionStore.set('session-set-test', request.session, () => {}) request.sessionStore.get('', (err, session) => { expectType(err); @@ -79,5 +78,9 @@ app.route({ expectType(request.session.set('foo', 'bar')); expectType(request.session.get('foo')); expectType(request.session.touch()); + expectType(request.session.reload(() => {})); + expectType(request.session.destroy(() => {})); + expectType(request.session.regenerate(() => {})); + expectType(request.session.save(() => {})); } });