feat: enhance session security and freshness
- Sliding expiration: reset 24h TTL on every Redis session access - SameSite=Strict on login and logout cookies (was Lax) - Secure flag on logout cookie in production (was missing) - Re-fetch user from DB on every request in buildContext so role/avatar/ admin changes take effect immediately without requiring re-login Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import type { YogaInitialContext } from "graphql-yoga";
|
import type { YogaInitialContext } from "graphql-yoga";
|
||||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||||
import type { Context } from "./builder";
|
import type { Context } from "./builder";
|
||||||
import { getSession } from "../lib/auth";
|
import { getSession, setSession } from "../lib/auth";
|
||||||
import { db } from "../db/connection";
|
import { db } from "../db/connection";
|
||||||
import { redis } from "../lib/auth";
|
import { redis } from "../lib/auth";
|
||||||
|
import { users } from "../db/schema/index";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
type ServerContext = {
|
type ServerContext = {
|
||||||
req: FastifyRequest;
|
req: FastifyRequest;
|
||||||
@@ -25,7 +27,30 @@ export async function buildContext(ctx: YogaInitialContext & ServerContext): Pro
|
|||||||
);
|
);
|
||||||
|
|
||||||
const token = cookies["session_token"];
|
const token = cookies["session_token"];
|
||||||
const currentUser = token ? await getSession(token) : null;
|
let currentUser = null;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const session = await getSession(token); // also slides TTL
|
||||||
|
if (session) {
|
||||||
|
const dbInstance = ctx.db || db;
|
||||||
|
const [dbUser] = await dbInstance.select().from(users).where(eq(users.id, session.id)).limit(1);
|
||||||
|
if (dbUser) {
|
||||||
|
currentUser = {
|
||||||
|
id: dbUser.id,
|
||||||
|
email: dbUser.email,
|
||||||
|
role: (dbUser.role === "admin" ? "viewer" : dbUser.role) as "model" | "viewer",
|
||||||
|
is_admin: dbUser.is_admin,
|
||||||
|
first_name: dbUser.first_name,
|
||||||
|
last_name: dbUser.last_name,
|
||||||
|
artist_name: dbUser.artist_name,
|
||||||
|
slug: dbUser.slug,
|
||||||
|
avatar: dbUser.avatar,
|
||||||
|
};
|
||||||
|
// Refresh cached session with up-to-date data
|
||||||
|
await setSession(token, currentUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: ctx.db || db,
|
db: ctx.db || db,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ builder.mutationField("login", (t) =>
|
|||||||
|
|
||||||
// Set session cookie
|
// Set session cookie
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||||
|
|
||||||
// For graphql-yoga response
|
// For graphql-yoga response
|
||||||
@@ -74,7 +74,8 @@ builder.mutationField("logout", (t) =>
|
|||||||
await deleteSession(token);
|
await deleteSession(token);
|
||||||
}
|
}
|
||||||
// Clear cookie
|
// Clear cookie
|
||||||
const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const cookieValue = `session_token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isProduction ? "; Secure" : ""}`;
|
||||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export async function setSession(token: string, user: SessionUser): Promise<void
|
|||||||
export async function getSession(token: string): Promise<SessionUser | null> {
|
export async function getSession(token: string): Promise<SessionUser | null> {
|
||||||
const data = await redis.get(`session:${token}`);
|
const data = await redis.get(`session:${token}`);
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
// Slide the expiration window on every access
|
||||||
|
await redis.expire(`session:${token}`, 86400);
|
||||||
return JSON.parse(data) as SessionUser;
|
return JSON.parse(data) as SessionUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user