From 5138f30658bc84163aaeca9b6574eb16e13de879 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Thu, 24 Nov 2022 13:22:20 +0100 Subject: [PATCH 01/18] Add rolling option --- src/module.ts | 3 +- .../server/middleware/session/index.ts | 47 +++++++++++++------ src/types.ts | 7 +++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/module.ts b/src/module.ts index efcc69f..4bf5473 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, + rolling: false }, api: { isEnabled: true, diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 5ccb4e7..65b08f7 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -8,18 +8,26 @@ import { SessionExpired } from './exceptions' import { useRuntimeConfig } from '#imports' const SESSION_COOKIE_NAME = 'sessionId' -const safeSetCookie = (event: H3Event, name: string, value: string) => setCookie(event, name, value, { - // Max age of cookie in seconds - maxAge: useRuntimeConfig().session.session.expiryInSeconds, - // Only send cookie via HTTPs to mitigate man-in-the-middle attacks - secure: true, - // Only send cookie via HTTP requests, do not allow access of cookie from JS to mitigate XSS attacks - httpOnly: true, - // Do not send cookies on many cross-site requests to mitigates CSRF and cross-site attacks, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax - sameSite: useRuntimeConfig().session.session.cookieSameSite as SameSiteOptions, - // Set cookie for subdomain - domain: useRuntimeConfig().session.session.domain -}) +const safeSetCookie = (event: H3Event, name: string, value: string, date: Date) => { + const sessionConfig = useRuntimeConfig().session.session + const expirationDate = sessionConfig.expiryInSeconds ? date : undefined + if (expirationDate) { + expirationDate.setSeconds(expirationDate.getSeconds() + sessionConfig.expiryInSeconds) + } + + setCookie(event, name, value, { + // Set cookie expiration date to now + expiryInSeconds + expires: expirationDate, + // Only send cookie via HTTPs to mitigate man-in-the-middle attacks + secure: true, + // Only send cookie via HTTP requests, do not allow access of cookie from JS to mitigate XSS attacks + httpOnly: true, + // Do not send cookies on many cross-site requests to mitigates CSRF and cross-site attacks, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax + sameSite: sessionConfig.cookieSameSite as SameSiteOptions, + // Set cookie for subdomain + domain: sessionConfig.domain + }) +} const checkSessionExpirationTime = (session: Session, sessionExpiryInSeconds: number) => { const now = dayjs() @@ -61,15 +69,16 @@ export const deleteSession = async (event: H3Event) => { const newSession = async (event: H3Event) => { const runtimeConfig = useRuntimeConfig() const sessionOptions = runtimeConfig.session.session + const now = new Date() // (Re-)Set cookie const sessionId = nanoid(sessionOptions.idLength) - safeSetCookie(event, SESSION_COOKIE_NAME, sessionId) + safeSetCookie(event, SESSION_COOKIE_NAME, sessionId, now) // Store session data in storage const session: Session = { id: sessionId, - createdAt: new Date(), + createdAt: now, ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined } await setStorageSession(sessionId, session) @@ -113,14 +122,24 @@ const getSession = async (event: H3Event): Promise => { return session } +const touchSession = (session: Session, event: H3Event) => { + const now = new Date() + session.createdAt = now + safeSetCookie(event, SESSION_COOKIE_NAME, session.id, now) +} + function isSession (shape: unknown): shape is Session { return typeof shape === 'object' && !!shape && 'id' in shape && 'createdAt' in shape } const ensureSession = async (event: H3Event) => { + const sessionConfig = useRuntimeConfig().session.session + let session = await getSession(event) if (!session) { session = await newSession(event) + } else if (sessionConfig.rolling) { + touchSession(session, event) } event.context.sessionId = session.id diff --git a/src/types.ts b/src/types.ts index fe059c7..3266407 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,13 @@ export interface SessionOptions { * @type {SessionIpPinningOptions|boolean} */ ipPinning: SessionIpPinningOptions|boolean, + /** + * Force the session identifier cookie to be set on every response. The expiration is reset to the original expiryInSeconds, resetting the expiration countdown. + * @default false + * @example true + * @type boolean + */ + rolling: boolean } export interface ApiOptions { From a34d5d8b0a60fe737fd87857a5ae8832e0b8b8d1 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Fri, 25 Nov 2022 18:54:23 +0100 Subject: [PATCH 02/18] 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 4dc1d99467f5a1749f82244d942628ab1567c307 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 11:40:42 +0100 Subject: [PATCH 03/18] Update configuration example --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 140969f..9c254c8 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, + // Expiration of the sessions are not reset to the original expiryInSeconds on every request + rolling: false }, api: { // The API is enabled From 1a261b7e4fe2e99a3589e7a6d4bdf039428ca284 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 11:49:35 +0100 Subject: [PATCH 04/18] Avoid modifying input arguments --- src/runtime/server/middleware/session/index.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 65b08f7..fad9553 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -8,12 +8,11 @@ import { SessionExpired } from './exceptions' import { useRuntimeConfig } from '#imports' const SESSION_COOKIE_NAME = 'sessionId' -const safeSetCookie = (event: H3Event, name: string, value: string, date: Date) => { +const safeSetCookie = (event: H3Event, name: string, value: string, createdAt: Date) => { const sessionConfig = useRuntimeConfig().session.session - const expirationDate = sessionConfig.expiryInSeconds ? date : undefined - if (expirationDate) { - expirationDate.setSeconds(expirationDate.getSeconds() + sessionConfig.expiryInSeconds) - } + const expirationDate = sessionConfig.expiryInSeconds + ? new Date(createdAt.getTime() + sessionConfig.expiryInSeconds * 1000) + : undefined setCookie(event, name, value, { // Set cookie expiration date to now + expiryInSeconds @@ -122,10 +121,10 @@ const getSession = async (event: H3Event): Promise => { return session } -const touchSession = (session: Session, event: H3Event) => { +const updateSessionExpirationDate = (session: Session, event: H3Event) => { const now = new Date() - session.createdAt = now safeSetCookie(event, SESSION_COOKIE_NAME, session.id, now) + return { ...session, createdAt: now } } function isSession (shape: unknown): shape is Session { @@ -139,7 +138,7 @@ const ensureSession = async (event: H3Event) => { if (!session) { session = await newSession(event) } else if (sessionConfig.rolling) { - touchSession(session, event) + session = updateSessionExpirationDate(session, event) } event.context.sessionId = session.id From 99ac27f48ae5f2c6294283e50fc05571af84d8ae Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 12:03:14 +0100 Subject: [PATCH 05/18] 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 06/18] 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 3629aa835d55d943464da128f8a665ff3365a947 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 17:23:58 +0100 Subject: [PATCH 07/18] Add resave option --- package-lock.json | 7 +++---- package.json | 1 + src/module.ts | 3 ++- src/runtime/server/middleware/session/index.ts | 13 ++++++++++--- src/types.ts | 7 +++++++ 5 files changed, 23 insertions(+), 8 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/module.ts b/src/module.ts index efcc69f..7c312fc 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, + resave: true }, api: { isEnabled: true, diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 5ccb4e7..02ad5d2 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -1,6 +1,7 @@ import { deleteCookie, eventHandler, H3Event, parseCookies, setCookie } from 'h3' import { nanoid } from 'nanoid' import dayjs from 'dayjs' +import equal from 'fast-deep-equal' import { SameSiteOptions, Session, SessionOptions } from '../../../../types' import { dropStorageSession, getStorageSession, setStorageSession } from './storage' import { processSessionIp, getHashedIpAddress } from './ipPinning' @@ -129,10 +130,14 @@ const ensureSession = async (event: H3Event) => { } export default eventHandler(async (event: H3Event) => { + const sessionConfig = useRuntimeConfig().session.session + // 1. Ensure that a session is present by either loading or creating one - await ensureSession(event) + const session = await ensureSession(event) + // 2. Save current state of the session + const source = { ...session } - // 2. Setup a hook that saves any changed made to the session by the subsequent endpoints & middlewares + // 3. Setup a hook that saves any changed made to the session by the subsequent endpoints & middlewares event.res.on('finish', async () => { // Session id may not exist if session was deleted const session = await getSession(event) @@ -140,6 +145,8 @@ export default eventHandler(async (event: H3Event) => { return } - await setStorageSession(session.id, event.context.session) + if (sessionConfig.resave || !equal(event.context.session, source)) { + await setStorageSession(session.id, event.context.session) + } }) }) diff --git a/src/types.ts b/src/types.ts index fe059c7..a3d3880 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,13 @@ export interface SessionOptions { * @type {SessionIpPinningOptions|boolean} */ ipPinning: SessionIpPinningOptions|boolean, + /** + * Forces the session to be saved back to the session store, even if the session was never modified during the request. + * @default true + * @example false + * @type boolean + */ + resave: boolean } export interface ApiOptions { From 8fbed4a12fd92a7857f147cd34b292fddcac0a3d Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 17:51:29 +0100 Subject: [PATCH 08/18] Update configuration example --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 140969f..4be8d8d 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, + // Sessions are saved to the store, even if they were never modified during the request + resave: true }, api: { // The API is enabled From 1cb021587159886441f93c0fb6ea9ae983901ae9 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 19:36:28 +0100 Subject: [PATCH 09/18] Use types for session options --- src/runtime/server/middleware/session/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 02ad5d2..257bce7 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -130,7 +130,7 @@ const ensureSession = async (event: H3Event) => { } export default eventHandler(async (event: H3Event) => { - const sessionConfig = useRuntimeConfig().session.session + const sessionOptions = useRuntimeConfig().session.session as SessionOptions // 1. Ensure that a session is present by either loading or creating one const session = await ensureSession(event) @@ -145,7 +145,7 @@ export default eventHandler(async (event: H3Event) => { return } - if (sessionConfig.resave || !equal(event.context.session, source)) { + if (sessionOptions.resave || !equal(event.context.session, source)) { await setStorageSession(session.id, event.context.session) } }) From dbd6bf44e24090fe0dbbaaec6b5edf457b6c2bcd Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 19:45:31 +0100 Subject: [PATCH 10/18] Use types for session options --- src/runtime/server/middleware/session/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index fad9553..ba82e56 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -9,9 +9,9 @@ import { useRuntimeConfig } from '#imports' const SESSION_COOKIE_NAME = 'sessionId' const safeSetCookie = (event: H3Event, name: string, value: string, createdAt: Date) => { - const sessionConfig = useRuntimeConfig().session.session - const expirationDate = sessionConfig.expiryInSeconds - ? new Date(createdAt.getTime() + sessionConfig.expiryInSeconds * 1000) + const sessionOptions = useRuntimeConfig().session.session as SessionOptions + const expirationDate = sessionOptions.expiryInSeconds + ? new Date(createdAt.getTime() + sessionOptions.expiryInSeconds * 1000) : undefined setCookie(event, name, value, { @@ -22,9 +22,9 @@ const safeSetCookie = (event: H3Event, name: string, value: string, createdAt: D // Only send cookie via HTTP requests, do not allow access of cookie from JS to mitigate XSS attacks httpOnly: true, // Do not send cookies on many cross-site requests to mitigates CSRF and cross-site attacks, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax - sameSite: sessionConfig.cookieSameSite as SameSiteOptions, + sameSite: sessionOptions.cookieSameSite as SameSiteOptions, // Set cookie for subdomain - domain: sessionConfig.domain + domain: sessionOptions.domain || undefined }) } @@ -67,7 +67,7 @@ export const deleteSession = async (event: H3Event) => { const newSession = async (event: H3Event) => { const runtimeConfig = useRuntimeConfig() - const sessionOptions = runtimeConfig.session.session + const sessionOptions = runtimeConfig.session.session as SessionOptions const now = new Date() // (Re-)Set cookie @@ -132,12 +132,12 @@ 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) { session = await newSession(event) - } else if (sessionConfig.rolling) { + } else if (sessionOptions.rolling) { session = updateSessionExpirationDate(session, event) } From 46d9d1f6ea4747b7cda399b02c9cce54c96df40c Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 28 Nov 2022 19:50:58 +0100 Subject: [PATCH 11/18] 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 From 9d39b4d44355fd08f976e78e714800e4690ee871 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Tue, 29 Nov 2022 12:36:38 +0100 Subject: [PATCH 12/18] Refactor handling of modified sessions --- .../server/middleware/session/index.ts | 64 +++++++------------ 1 file changed, 23 insertions(+), 41 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 29a6644..7bdaf5c 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -67,36 +67,23 @@ export const deleteSession = async (event: H3Event) => { deleteCookie(event, SESSION_COOKIE_NAME) } -const newSession = async (event: H3Event, sessionContent?: SessionContent) => { - const runtimeConfig = useRuntimeConfig() - const sessionOptions = runtimeConfig.session.session as SessionOptions +const newSession = async (event: H3Event) => { + const sessionOptions = useRuntimeConfig().session.session as SessionOptions const now = new Date() - // (Re-)Set cookie - const sessionId = nanoid(sessionOptions.idLength) - safeSetCookie(event, SESSION_COOKIE_NAME, sessionId, now) - - // Store session data in storage const session: Session = { - id: sessionId, + id: nanoid(sessionOptions.idLength), createdAt: now, ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined } - 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 setSession = async (session: Session, event: H3Event) => { + safeSetCookie(event, SESSION_COOKIE_NAME, session.id, session.createdAt) + await setStorageSession(session.id, session) + return session } const getSession = async (event: H3Event): Promise => { @@ -112,8 +99,7 @@ const getSession = async (event: H3Event): Promise => { return null } - const runtimeConfig = useRuntimeConfig() - const sessionOptions = runtimeConfig.session.session as SessionOptions + const sessionOptions = useRuntimeConfig().session.session as SessionOptions const sessionExpiryInSeconds = sessionOptions.expiryInSeconds try { @@ -150,21 +136,14 @@ const ensureSession = async (event: H3Event) => { let session = await getSession(event) if (!session) { - if (sessionOptions.saveUninitialized) { - session = await newSession(event) - } else { - // 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 - newSessionIfModified(event, event.context.session) - return null - } + session = await newSession(event) } else if (sessionOptions.rolling) { session = updateSessionExpirationDate(session, event) } event.context.sessionId = session.id event.context.session = session + return session } @@ -174,18 +153,21 @@ export default eventHandler(async (event: H3Event) => { // 1. Ensure that a session is present by either loading or creating one const session = await ensureSession(event) // 2. Save current state of the session - const source = { ...session } + const oldSession = { ...session } // 3. Setup a hook that saves any changed made to the session by the subsequent endpoints & middlewares - event.res.on('finish', async () => { - // Session id may not exist if session was deleted - const session = await getSession(event) - if (!session) { - return - } - - if (sessionOptions.resave || !equal(event.context.session, source)) { - await setStorageSession(session.id, event.context.session) + resEndProxy(event.node.res, async () => { + const newSession = event.context.session as Session + const storedSession = await getSession(event) + + if (!storedSession) { + // Save a new session if saveUninitialized is true, or if the session has been modified + if (sessionOptions.saveUninitialized || !equal(newSession, oldSession)) { + await setSession(newSession, event) + } + // Update the session in the storage if resave is true, or if the stored session has been modified + } else if (sessionOptions.resave || !equal(newSession, storedSession)) { + await setStorageSession(storedSession.id, newSession) } }) }) From 293ad80e71d25f29cf42ef6b4d597a9ad6e0c56a Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Tue, 29 Nov 2022 13:09:10 +0100 Subject: [PATCH 13/18] Remove unnecessary interface declaration --- src/runtime/server/middleware/session/index.ts | 2 +- src/types.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 7bdaf5c..60dd136 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -2,7 +2,7 @@ import { deleteCookie, eventHandler, H3Event, parseCookies, setCookie } from 'h3 import { nanoid } from 'nanoid' import dayjs from 'dayjs' import equal from 'fast-deep-equal' -import { SameSiteOptions, Session, SessionOptions, SessionContent } from '../../../../types' +import { SameSiteOptions, Session, SessionOptions } from '../../../../types' import { dropStorageSession, getStorageSession, setStorageSession } from './storage' import { processSessionIp, getHashedIpAddress } from './ipPinning' import { SessionExpired } from './exceptions' diff --git a/src/types.ts b/src/types.ts index e7b06bd..a3b53e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,7 +188,3 @@ export declare interface Session { [key: string]: any; } - -export declare interface SessionContent { - [key: string]: any; -} From 8238d43b6c2b934300be1041b78fef8d3100f658 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 5 Dec 2022 17:08:43 +0100 Subject: [PATCH 14/18] Rename variable --- src/runtime/server/middleware/session/resEndProxy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/middleware/session/resEndProxy.ts b/src/runtime/server/middleware/session/resEndProxy.ts index e930f92..1007b41 100644 --- a/src/runtime/server/middleware/session/resEndProxy.ts +++ b/src/runtime/server/middleware/session/resEndProxy.ts @@ -4,11 +4,11 @@ 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 + 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 + return end.call(res, chunk, encoding) as ServerResponse } } From 7b55c789ee4066e37f5c966aab87be171972eb4d Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Sat, 10 Dec 2022 21:54:27 +0100 Subject: [PATCH 15/18] Add better explanation to saveUninitialized Co-authored-by: valia fetisov --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d95ca99..ddf03a0 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ Here's what the full _default_ module configuration looks like: ipPinning: false, // Expiration of the sessions are not reset to the original expiryInSeconds on every request rolling: false, - // Uninitialized, resp. unmodified sessions are saved to the store, so session cookies are set at the first response + // Forces unmodified session to be saved to the store. Setting `false` is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie saveUninitialized: true, // Sessions are saved to the store, even if they were never modified during the request resave: true From 90d68bc975d852106853f59a778e8194e34f306e Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 12 Dec 2022 13:34:02 +0100 Subject: [PATCH 16/18] Provide better naming --- .../server/middleware/session/index.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 3c025f8..98de9d1 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -144,7 +144,7 @@ const ensureSession = async (event: H3Event) => { event.context.sessionId = session.id event.context.session = session - return session + return { ...session } } export default eventHandler(async (event: H3Event) => { @@ -152,22 +152,20 @@ export default eventHandler(async (event: H3Event) => { // 1. Ensure that a session is present by either loading or creating one const session = await ensureSession(event) - // 2. Save current state of the session - const oldSession = { ...session } - // 3. Setup a hook that saves any changed made to the session by the subsequent endpoints & middlewares + // 2. Setup a hook that saves any changed made to the session by the subsequent endpoints & middlewares resEndProxy(event.node.res, async () => { - const newSession = event.context.session as Session + const contextSession = event.context.session as Session const storedSession = await getSession(event) if (!storedSession) { - // Save a new session if saveUninitialized is true, or if the session has been modified - if (sessionOptions.saveUninitialized || !equal(newSession, oldSession)) { - await setSession(newSession, event) + // If there isn't a session in the storage yet, save a new session if saveUninitialized is true, or if the session in the event context has been modified + if (sessionOptions.saveUninitialized || !equal(contextSession, session)) { + await setSession(contextSession, event) } - // Update the session in the storage if resave is true, or if the stored session has been modified - } else if (sessionOptions.resave || !equal(newSession, storedSession)) { - await setStorageSession(storedSession.id, newSession) + // Update the session in the storage if resave is true, or if the session in the event context has been modified + } else if (sessionOptions.resave || !equal(contextSession, storedSession)) { + await setStorageSession(storedSession.id, contextSession) } }) }) From 0b5b25a3734b9959dfff01ebc16ef4e8033a246c Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 12 Dec 2022 13:41:10 +0100 Subject: [PATCH 17/18] Provide better explanation for resave --- README.md | 2 +- src/types.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ddf03a0..32af4b4 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ Here's what the full _default_ module configuration looks like: rolling: false, // Forces unmodified session to be saved to the store. Setting `false` is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie saveUninitialized: true, - // Sessions are saved to the store, even if they were never modified during the request + // Sessions are saved to the store, even if they were never modified during the request. Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server, and changes made to the session in one request may get overwritten when the other request ends resave: true }, api: { diff --git a/src/types.ts b/src/types.ts index 42ac51b..8fb5842 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,6 +115,8 @@ export interface SessionOptions { saveUninitialized: boolean, /** * Forces the session to be saved back to the session store, even if the session was never modified during the request. + * Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server, + * and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using). * @default true * @example false * @type boolean From 04188463cc739068672aed74ee04dec638e8faf4 Mon Sep 17 00:00:00 2001 From: Sebastian Rager Date: Mon, 12 Dec 2022 13:48:26 +0100 Subject: [PATCH 18/18] Fix indentation and comma --- src/types.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/types.ts b/src/types.ts index 8fb5842..4139877 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,7 +104,7 @@ export interface SessionOptions { * @example true * @type boolean */ - rolling: boolean, + rolling: 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. @@ -112,15 +112,15 @@ export interface SessionOptions { * @example false * @type boolean */ - saveUninitialized: boolean, + saveUninitialized: boolean /** - * Forces the session to be saved back to the session store, even if the session was never modified during the request. - * Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server, - * and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using). - * @default true - * @example false - * @type boolean - */ + * Forces the session to be saved back to the session store, even if the session was never modified during the request. + * Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server, + * and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using). + * @default true + * @example false + * @type boolean + */ resave: boolean }