diff --git a/docs/admin/config.md b/docs/admin/config.md index 1dbc6c8..8e61c75 100644 --- a/docs/admin/config.md +++ b/docs/admin/config.md @@ -6,12 +6,12 @@ ## Environment Variables -### NUXT_SESSION_EXPIRES_TIMEOUT +### NUXT_SESSION_ROTATES_TIMEOUT -Time in seconds before a session is considered expired and need to be rotated over into a new session. When an endpoint using a session is hit after the session expires but before the session is discarded a new session is created as the successor with a new expiry and discard timeout. The old session then considered to have been superceeded and any requests using the old session will result in a 403 Forbidden with the message the session has been taken. +Time in seconds before a session need to be rotated over into a new session. When an endpoint using a session is hit after the session rotates timeout but before the session is discarded a new session is created as the successor with a new rotates and discard timeout. The old session then marked to expire in 10 seconds any requests using the old session will result in a 403 Forbidden with the message the session has been taken after the expiry. ### NUXT_SESSION_DISCARD_TIMEOUT Time in seconds before a session is deleted from the client and server, resulting in the user having to authenticate again if the session wasn't rotated over into a new session before this timeout. -This should be several times greater that `NUXT_SESSION_EXPIRES_TIMEOUT`. +This should be several times greater that `NUXT_SESSION_ROTATES_TIMEOUT`. diff --git a/docs/dev/server-sent-events.md b/docs/dev/server-sent-events.md index 14c1813..82ff99b 100644 --- a/docs/dev/server-sent-events.md +++ b/docs/dev/server-sent-events.md @@ -4,7 +4,7 @@ --> # Server-sent events -To update in real time this application sends a `text/event-source` stream using [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). These streams use the current session if any to filter restricted resources and ends when the session expires, necessitating a reconnect by the user agent. (If there are no session associated with the connection it ends after the session expiry timeout). +To update in real time this application sends a `text/event-source` stream using [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). These streams use the current session if any to filter restricted resources and ends when the session rotates timeout is hit, necessitating a reconnect by the user agent. (If there are no session associated with the connection it ends after the session rotates timeout). ## Events diff --git a/docs/dev/sessions.md b/docs/dev/sessions.md index 6ae7967..ccf5eb9 100644 --- a/docs/dev/sessions.md +++ b/docs/dev/sessions.md @@ -6,8 +6,8 @@ When a user creates a new account or logs in to an existing account a session is created on the server and linked to the user's browser via a session cookie. This cookie contains a unique identifier along with a HMAC signature created using the server's cookie secret key. Since this unique identifier stored on the user's device is a technical requirement to securely do what the user is requesting the user's consent to its storage can be assumed. -Sessions have two future times associated with them: The expiration time is the point in time after the session will be recreated and the cookie reassigned, and the discard time is when the session is deleted from both the client and the server. The expiriation time is short, by default 1 hour, while the discard time is long, by default 2 weeks. +Sessions have three future times associated with them: The rotates time is the point in time after the session will be recreated and the cookie reassigned, the expiry time is the point in time after which use of the session will be rejected, and the discard time is when the session is deleted from both the client and the server. The rotation time is short, by default 1 hour, while the discard time is long, by default 2 weeks. -When a request is made to a session that's past the expiration time a new session is created to replace the existing one, and the session cookie is updated with the new session. The purpose of this is to reduce the time window a stolen session can be used in without being detected. If a request is made using a session that has already been replaced the server responds with a message saying the "session has been taken". +When a request is made to a session that's past the rotates time a new session is created to replace the existing one, the expiry time is set on the existing session to 10 seconds later, and the session cookie is updated with the new session. The purpose of this is to reduce the time window a stolen session can be used in without being detected. If a request is made using a session that has expired the server responds with a message saying the "session has been taken". The reason for having the session expire 10 seconds after the rotation is to prevent race conditions from triggering the session taken error. Sessions are created for a limited timespan, purpose and access level, and expires after the timespan is over, the purpose is fulfilled or the access level changes. For example if the user's account is promoted from regular to crew the session will no longer be valid and will be recreated as a new session with the new access level on the next request. diff --git a/nuxt.config.ts b/nuxt.config.ts index 71b5233..6ff336a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -23,7 +23,7 @@ export default defineNuxtConfig({ }, runtimeConfig: { cookieSecretKeyFile: "", - sessionExpiresTimeout: 1 * oneHourSeconds, + sessionRotatesTimeout: 1 * oneHourSeconds, sessionDiscardTimeout: 14 * oneDaySeconds, vapidSubject: "", vapidPrivateKeyFile: "", diff --git a/server/api/admin/user.patch.ts b/server/api/admin/user.patch.ts index 42b08ad..25c559f 100644 --- a/server/api/admin/user.patch.ts +++ b/server/api/admin/user.patch.ts @@ -58,12 +58,13 @@ export default defineEventHandler(async (event) => { data: serverUserToApi(user), }); - // Expire sessions with the user in it if the access changed + // Rotate sessions with the user in it if the access changed if (accessChanged) { const sessions = await readSessions(); + const nowMs = Date.now(); for (const session of sessions) { if (session.accountId === user.id) { - session.expiresAtMs = 0; + session.rotatesAtMs = nowMs; broadcastEvent({ type: "session-expired", sessionId: session.id, diff --git a/server/api/auth/account.delete.ts b/server/api/auth/account.delete.ts index f75d067..2e66d14 100644 --- a/server/api/auth/account.delete.ts +++ b/server/api/auth/account.delete.ts @@ -18,9 +18,8 @@ export default defineEventHandler(async (event) => { const nowMs = Date.now(); for (const session of sessions) { if ( - !session.finished - && session.successor !== undefined - && session.expiresAtMs < nowMs + session.successor !== undefined + && (session.expiresAtMs === undefined || session.expiresAtMs < nowMs) && session.accountId === serverSession.accountId ) { session.expiresAtMs = nowMs; diff --git a/server/api/auth/session.delete.ts b/server/api/auth/session.delete.ts index 4cbfa47..df4bfd9 100644 --- a/server/api/auth/session.delete.ts +++ b/server/api/auth/session.delete.ts @@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => { if (session) { const users = await readUsers(); const account = users.find(user => user.id === session.accountId); - if (account?.type === "anonymous" && session.successor === undefined) { + if (account?.type === "anonymous") { throw createError({ status: 409, message: "Cannot log out of an anonymous account", diff --git a/server/database.ts b/server/database.ts index 24e30a5..e3b5c0b 100644 --- a/server/database.ts +++ b/server/database.ts @@ -10,10 +10,10 @@ export interface ServerSession { id: Id, access: ApiUserType, accountId?: number, - expiresAtMs: number, + rotatesAtMs: number, + expiresAtMs?: number, discardAtMs: number, successor?: Id, - finished: boolean, }; export interface ServerUser { diff --git a/server/streams.ts b/server/streams.ts index 0f7709f..6daf522 100644 --- a/server/streams.ts +++ b/server/streams.ts @@ -33,7 +33,7 @@ function sendMessageAndClose( ; } -const streams = new Map, { sessionId?: number, accountId?: number, expiresAtMs: number }>(); +const streams = new Map, { sessionId?: number, accountId?: number, rotatesAtMs: number }>(); let keepaliveInterval: ReturnType | null = null export async function addStream( @@ -49,7 +49,7 @@ export async function addStream( streams.set(stream, { sessionId: session?.id, accountId: session?.accountId, - expiresAtMs: session?.expiresAtMs ?? Date.now() + runtimeConfig.sessionExpiresTimeout * 1000, + rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000, }); // Produce a response immediately to avoid the reply waiting for content. const update: ApiEvent = { @@ -162,7 +162,7 @@ export async function broadcastEvent(event: ApiEvent) { function sendKeepalive() { const now = Date.now(); for (const [stream, streamData] of streams) { - if (streamData.expiresAtMs > now) { + if (streamData.rotatesAtMs > now) { sendMessage(stream, ": keepalive\n"); } else { sendMessageAndClose(stream, `data: cancelled\n\n`); diff --git a/server/utils/session.ts b/server/utils/session.ts index 0aeeebe..f89a6b6 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -31,7 +31,7 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio const sessionId = parseInt(existingSessionCookie, 10); const session = sessions.find(session => session.id === sessionId); if (session) { - session.finished = true; + session.expiresAtMs = Date.now(); broadcastEvent({ type: "session-expired", sessionId, @@ -58,11 +58,10 @@ export async function setServerSession(event: H3Event, account: ServerUser) { const now = Date.now(); const newSession: ServerSession = { - accountId: account.id, - access: account.type, - expiresAtMs: now + runtimeConfig.sessionExpiresTimeout * 1000, + access: account?.type ?? "anonymous", + accountId: account?.id, + rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000, discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000, - finished: false, id: await nextSessionId(), }; @@ -79,41 +78,41 @@ async function rotateSession(event: H3Event, sessions: ServerSession[], session: const newSession: ServerSession = { accountId: account?.id, access: account?.type ?? "anonymous", - expiresAtMs: now + runtimeConfig.sessionExpiresTimeout * 1000, + rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000, discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000, - finished: false, id: await nextSessionId(), }; session.successor = newSession.id; + session.expiresAtMs = Date.now() + 10 * 1000; sessions.push(newSession); await writeSessions(sessions); await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout) return newSession; } -export async function getServerSession(event: H3Event, ignoreExpired: boolean) { +export async function getServerSession(event: H3Event, ignoreTaken: boolean) { const sessionCookie = await getSignedCookie(event, "session"); if (sessionCookie) { const sessionId = parseInt(sessionCookie, 10); const sessions = await readSessions(); const session = sessions.find(session => session.id === sessionId); if (session) { - if (session.finished) { + const nowMs = Date.now(); + if (nowMs >= session.discardAtMs) { return undefined; } - if (!ignoreExpired && session.successor !== undefined) { - throw createError({ - statusCode: 403, - statusMessage: "Forbidden", - message: "Session has been taken by another agent.", - data: { code: "SESSION_TAKEN" }, - }); - } - const now = Date.now(); - if (now >= session?.discardAtMs) { + if (session.expiresAtMs !== undefined && nowMs >= session.expiresAtMs) { + if (!ignoreTaken && session.successor !== undefined) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + message: "Session has been taken by another agent.", + data: { code: "SESSION_TAKEN" }, + }); + } return undefined; } - if (!ignoreExpired && now >= session?.expiresAtMs) { + if (nowMs >= session.rotatesAtMs && session.successor === undefined) { return await rotateSession(event, sessions, session); } }