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.
This commit is contained in:
arsam
2026-09-18 23:19:49 +03:30
parent ff48a0a3c2
commit 379837d1fb
+127
View File
@@ -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<void> {
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<number> {
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<SessionData, 'csrfToken' | 'iat' | 'passwordChangedAt'>,
passwordChangedAt?: number,
): Promise<ResolvedSession> {
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<SessionData, 'csrfToken' | 'iat' | 'passwordChangedAt'>,
): Promise<ResolvedSession> {
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' });
}
};
}