From 379837d1fbab12a9d225c56d1949605117574366 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:19:49 +0330 Subject: [PATCH] Import source: app/backend/src/plugins/session Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- app/backend/src/plugins/session.ts | 127 +++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 app/backend/src/plugins/session.ts diff --git a/app/backend/src/plugins/session.ts b/app/backend/src/plugins/session.ts new file mode 100644 index 0000000..ea5d7b8 --- /dev/null +++ b/app/backend/src/plugins/session.ts @@ -0,0 +1,127 @@ +import fp from 'fastify-plugin'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { randomBytes } from 'node:crypto'; +import { redis } from '../redis.js'; +import { config } from '../config.js'; +import { prisma } from '../db.js'; +import { logSecurityEvent } from '../lib/security-audit.js'; + +export const SESSION_COOKIE = 'tek_sid'; +export const CSRF_COOKIE = 'tek_csrf'; +export const SESSION_TTL_SECONDS = 14 * 24 * 60 * 60; + +export const sessionKey = (sid: string) => `sess:${sid}`; +const userSessionsSet = (userId: string) => `usess:${userId}`; +const randomB64Url = (bytes: number) => randomBytes(bytes).toString('base64url'); + +export const cookieOpts = { + httpOnly: true as const, + secure: config.isProd, + sameSite: 'lax' as const, + path: '/', + ...(config.COOKIE_DOMAIN ? { domain: config.COOKIE_DOMAIN } : {}), +}; + +export const csrfCookieOpts = { ...cookieOpts, httpOnly: false }; + +export async function invalidateAllUserSessions(userId: string): Promise { + const setKey = userSessionsSet(userId); + const sids = await redis.smembers(setKey); + if (sids.length > 0) { + await redis.del(...sids.map(sessionKey)); + await redis.del(setKey); + } + await logSecurityEvent({ + type: 'session_invalidated', + userId, + ip: 'system', + metadata: { sessionsInvalidated: sids.length }, + }); +} + +async function getPasswordChangedAt(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordChangedAt: true }, + }); + return user?.passwordChangedAt?.getTime() ?? 0; +} + +export default fp(async function sessionPlugin(app: FastifyInstance) { + app.addHook('onRequest', async (req: FastifyRequest, _reply: FastifyReply) => { + req.session = null; + const sid = req.cookies?.[SESSION_COOKIE]; + if (!sid) return; + const raw = await redis.get(sessionKey(sid)); + if (!raw) return; + try { + const data = JSON.parse(raw) as SessionData; + const currentPwChangedAt = await getPasswordChangedAt(data.userId); + if (currentPwChangedAt > data.passwordChangedAt) { + await redis.del(sessionKey(sid)); + await redis.srem(userSessionsSet(data.userId), sessionKey(sid)); + return; + } + req.session = { sid, ...data }; + await redis.expire(sessionKey(sid), SESSION_TTL_SECONDS); + } catch { + await redis.del(sessionKey(sid)); + } + }); + + app.decorateReply('createSession', async function ( + this: FastifyReply, + data: Omit, + passwordChangedAt?: number, + ): Promise { + const pca = passwordChangedAt ?? (await getPasswordChangedAt(data.userId)); + const sid = randomB64Url(32); + const csrfToken = randomB64Url(16); + const payload: SessionData = { ...data, csrfToken, passwordChangedAt: pca, iat: Date.now() }; + const sk = sessionKey(sid); + await redis.set(sk, JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS); + await redis.sadd(userSessionsSet(data.userId), sk); + this.setCookie(SESSION_COOKIE, sid, { ...cookieOpts, maxAge: SESSION_TTL_SECONDS }); + this.setCookie(CSRF_COOKIE, csrfToken, { ...csrfCookieOpts, maxAge: SESSION_TTL_SECONDS }); + return { sid, ...payload }; + }); + + app.decorateReply('destroySession', async function (this: FastifyReply) { + const req = this.request; + if (req.session?.sid) { + const sk = sessionKey(req.session.sid); + await redis.del(sk); + await redis.srem(userSessionsSet(req.session.userId), sk); + } + this.clearCookie(SESSION_COOKIE, cookieOpts); + this.clearCookie(CSRF_COOKIE, csrfCookieOpts); + req.session = null; + }); + + app.decorateReply('rotateSession', async function ( + this: FastifyReply, + data?: Omit, + ): Promise { + const req = this.request; + const oldSession = req.session; + if (oldSession?.sid) { + const sk = sessionKey(oldSession.sid); + await redis.del(sk); + await redis.srem(userSessionsSet(oldSession.userId), sk); + } + const sessionData = data ?? (oldSession ? { userId: oldSession.userId, role: oldSession.role } : { userId: '', role: 'reader' as const }); + const pca = oldSession?.passwordChangedAt; + return this.createSession(sessionData, pca); + }); +}); + +export function requireAuth(roles?: SessionData['role'][]) { + return async (req: FastifyRequest, reply: FastifyReply) => { + if (!req.session) { + return reply.code(401).send({ error: 'unauthenticated' }); + } + if (roles && !roles.includes(req.session.role)) { + return reply.code(403).send({ error: 'forbidden' }); + } + }; +}