From a34d5d8b0a60fe737fd87857a5ae8832e0b8b8d1 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Fri, 25 Nov 2022 18:54:23 +0100 Subject: [PATCH 1/4] Add saveUninitialized option --- src/module.ts | 3 ++- .../server/middleware/session/index.ts | 23 +++++++++++++++++-- .../server/middleware/session/resEndProxy.ts | 14 +++++++++++ src/types.ts | 8 +++++++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/runtime/server/middleware/session/resEndProxy.ts diff --git a/src/module.ts b/src/module.ts index efcc69f..8aab0ca 100644 --- a/src/module.ts +++ b/src/module.ts @@ -23,7 +23,8 @@ const defaults: FilledModuleOptions = { options: {} }, domain: null, - ipPinning: false as boolean|SessionIpPinningOptions + ipPinning: false as boolean|SessionIpPinningOptions, + saveUninitialized: true }, api: { isEnabled: true, diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 5ccb4e7..2fe7415 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -5,6 +5,7 @@ import { SameSiteOptions, Session, SessionOptions } from '../../../../types' import { dropStorageSession, getStorageSession, setStorageSession } from './storage' import { processSessionIp, getHashedIpAddress } from './ipPinning' import { SessionExpired } from './exceptions' +import { resEndProxy } from './resEndProxy' import { useRuntimeConfig } from '#imports' const SESSION_COOKIE_NAME = 'sessionId' @@ -58,7 +59,7 @@ export const deleteSession = async (event: H3Event) => { deleteCookie(event, SESSION_COOKIE_NAME) } -const newSession = async (event: H3Event) => { +const newSession = async (event: H3Event, copyContextSession?: boolean) => { const runtimeConfig = useRuntimeConfig() const sessionOptions = runtimeConfig.session.session @@ -72,6 +73,10 @@ const newSession = async (event: H3Event) => { createdAt: new Date(), ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined } + // Copy the session object from the event context to the new session + if (copyContextSession) { + Object.assign(session, event.context.session) + } await setStorageSession(sessionId, session) return session @@ -118,9 +123,23 @@ function isSession (shape: unknown): shape is Session { } const ensureSession = async (event: H3Event) => { + const sessionConfig = useRuntimeConfig().session.session + let session = await getSession(event) if (!session) { - session = await newSession(event) + if (sessionConfig.saveUninitialized) { + session = await newSession(event) + } else { + // 1. Create an empty session object + event.context.session = {} + // 2. Create a new session if the object has been modified by any event handler + resEndProxy(event.res, async () => { + if (Object.keys(event.context.session).length) { + await newSession(event, true) + } + }) + return null + } } event.context.sessionId = session.id diff --git a/src/runtime/server/middleware/session/resEndProxy.ts b/src/runtime/server/middleware/session/resEndProxy.ts new file mode 100644 index 0000000..e930f92 --- /dev/null +++ b/src/runtime/server/middleware/session/resEndProxy.ts @@ -0,0 +1,14 @@ +import type { ServerResponse } from 'node:http' + +type MiddleWare = () => Promise + +// Proxy res.end() to get a callback at the end of all event handlers +export const resEndProxy = (res: ServerResponse, middleWare: MiddleWare) => { + const _end = res.end + + // @ts-ignore Replacing res.end() will lead to type checking error + res.end = async (chunk: any, encoding: BufferEncoding) => { + await middleWare() + return _end.call(res, chunk, encoding) as ServerResponse + } +} diff --git a/src/types.ts b/src/types.ts index fe059c7..51ff4a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,14 @@ export interface SessionOptions { * @type {SessionIpPinningOptions|boolean} */ ipPinning: SessionIpPinningOptions|boolean, + /** + * Forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. + * Choosing false is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. + * @default true + * @example false + * @type boolean + */ + saveUninitialized: boolean } export interface ApiOptions { From 99ac27f48ae5f2c6294283e50fc05571af84d8ae Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 12:03:14 +0100 Subject: [PATCH 2/4] Update configuration example --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 140969f..3ddfd62 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,9 @@ Here's what the full _default_ module configuration looks like: // The request-domain is strictly used for the cookie, no sub-domains allowed domain: null, // Sessions aren't pinned to the user's IP address - ipPinning: false + ipPinning: false, + // Uninitialized, resp. unmodified sessions are saved to the store, so session cookies are set at the first response + saveUninitialized: true }, api: { // The API is enabled From efc7dc2329cba21f93a89349458c8f8f5b5c5a2a Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 15:05:54 +0100 Subject: [PATCH 3/4] Refactor saveUninitialized - Move newSessionIfModified logic to dedicated method - Use deep equal comparison to watch changes of the context session --- package-lock.json | 7 +++-- package.json | 1 + .../server/middleware/session/index.ts | 27 +++++++++++-------- src/types.ts | 4 +++ 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a80593..36d1ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "argon2": "^0.30.2", "dayjs": "^1.11.6", "defu": "^6.1.0", + "fast-deep-equal": "^3.1.3", "h3": "^1.0.1", "unstorage": "^1.0.1" }, @@ -4461,8 +4462,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.12", @@ -13218,8 +13218,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.12", diff --git a/package.json b/package.json index edb5d2c..4285630 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "argon2": "^0.30.2", "dayjs": "^1.11.6", "defu": "^6.1.0", + "fast-deep-equal": "^3.1.3", "h3": "^1.0.1", "unstorage": "^1.0.1" }, diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 2fe7415..f23755b 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -1,7 +1,8 @@ import { deleteCookie, eventHandler, H3Event, parseCookies, setCookie } from 'h3' import { nanoid } from 'nanoid' import dayjs from 'dayjs' -import { SameSiteOptions, Session, SessionOptions } from '../../../../types' +import equal from 'fast-deep-equal' +import { SameSiteOptions, Session, SessionOptions, SessionContent } from '../../../../types' import { dropStorageSession, getStorageSession, setStorageSession } from './storage' import { processSessionIp, getHashedIpAddress } from './ipPinning' import { SessionExpired } from './exceptions' @@ -59,7 +60,7 @@ export const deleteSession = async (event: H3Event) => { deleteCookie(event, SESSION_COOKIE_NAME) } -const newSession = async (event: H3Event, copyContextSession?: boolean) => { +const newSession = async (event: H3Event, sessionContent?: SessionContent) => { const runtimeConfig = useRuntimeConfig() const sessionOptions = runtimeConfig.session.session @@ -73,15 +74,23 @@ const newSession = async (event: H3Event, copyContextSession?: boolean) => { createdAt: new Date(), ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined } - // Copy the session object from the event context to the new session - if (copyContextSession) { - Object.assign(session, event.context.session) + if (sessionContent) { + Object.assign(session, sessionContent) } await setStorageSession(sessionId, session) return session } +const newSessionIfModified = (event: H3Event, sessionContent: SessionContent) => { + const source = { ...sessionContent } + resEndProxy(event.res, async () => { + if (!equal(sessionContent, source)) { + await newSession(event, sessionContent) + } + }) +} + const getSession = async (event: H3Event): Promise => { // 1. Does the sessionId cookie exist on the request? const existingSessionId = getCurrentSessionId(event) @@ -130,14 +139,10 @@ const ensureSession = async (event: H3Event) => { if (sessionConfig.saveUninitialized) { session = await newSession(event) } else { - // 1. Create an empty session object + // 1. Create an empty session object in the event context event.context.session = {} // 2. Create a new session if the object has been modified by any event handler - resEndProxy(event.res, async () => { - if (Object.keys(event.context.session).length) { - await newSession(event, true) - } - }) + newSessionIfModified(event, event.context.session) return null } } diff --git a/src/types.ts b/src/types.ts index 51ff4a2..f476697 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,3 +174,7 @@ export declare interface Session { [key: string]: any; } + +export declare interface SessionContent { + [key: string]: any; +} From 46d9d1f6ea4747b7cda399b02c9cce54c96df40c Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 19:50:58 +0100 Subject: [PATCH 4/4] Use types for session options --- src/runtime/server/middleware/session/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index f23755b..bd0096f 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -62,7 +62,7 @@ export const deleteSession = async (event: H3Event) => { const newSession = async (event: H3Event, sessionContent?: SessionContent) => { const runtimeConfig = useRuntimeConfig() - const sessionOptions = runtimeConfig.session.session + const sessionOptions = runtimeConfig.session.session as SessionOptions // (Re-)Set cookie const sessionId = nanoid(sessionOptions.idLength) @@ -132,11 +132,11 @@ function isSession (shape: unknown): shape is Session { } const ensureSession = async (event: H3Event) => { - const sessionConfig = useRuntimeConfig().session.session + const sessionOptions = useRuntimeConfig().session.session as SessionOptions let session = await getSession(event) if (!session) { - if (sessionConfig.saveUninitialized) { + if (sessionOptions.saveUninitialized) { session = await newSession(event) } else { // 1. Create an empty session object in the event context