Separate rotation and expiry of sessions
If a session is rotate in the middle of a server side rendering then some random portions of requests made on the server side will fail with a session taken error as the server is not going to update the cookies of the client during these requests. To avoid this pitfall extend the expiry time of sessions to be 10 seconds after the session has been rotated. This is accomplished by introducing a new timestamp on sessions called the rotateAt at time alongside the expiresAt time. Sessions used after rotateAt that haven't been rotated get rotated into a new session and the existing session gets the expiresAt time set to 10 seconds in the future. Sessions that are past the expiredAt time have no access. This makes the logic around session expiry simpler, and also makes it possible to audit when a session got rotated, and to mark sessions as expired without a chance to rotate to a new session without having to resort to a finished flag.
This commit is contained in:
parent
352362b9c3
commit
3f492edea2
10 changed files with 37 additions and 38 deletions
|
@ -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`.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -23,7 +23,7 @@ export default defineNuxtConfig({
|
|||
},
|
||||
runtimeConfig: {
|
||||
cookieSecretKeyFile: "",
|
||||
sessionExpiresTimeout: 1 * oneHourSeconds,
|
||||
sessionRotatesTimeout: 1 * oneHourSeconds,
|
||||
sessionDiscardTimeout: 14 * oneDaySeconds,
|
||||
vapidSubject: "",
|
||||
vapidPrivateKeyFile: "",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -33,7 +33,7 @@ function sendMessageAndClose(
|
|||
;
|
||||
}
|
||||
|
||||
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, expiresAtMs: number }>();
|
||||
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, rotatesAtMs: number }>();
|
||||
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | 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`);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue