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