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:
@@ -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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user