diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index f98b646c7..d502c7d78 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -1,5 +1,6 @@ import axios from "axios"; import bcrypt from "bcrypt"; +import crypto from "crypto"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; import { LRUCache } from "lru-cache"; @@ -320,22 +321,31 @@ export function authRoutes(fastify: FastifyInstance) { throw new Error("Password is not valid"); } - var b64string = process.env.SECRET; - var buf = new Buffer(b64string!, "base64"); // Ta-da - - let token = jwt.sign( + // Generate a secure session token + var secret = Buffer.from(process.env.SECRET!, "base64"); + const token = jwt.sign( { - data: { id: user!.id }, + data: { + id: user!.id, + // Add a unique identifier for this session + sessionId: crypto.randomBytes(32).toString('hex') + } }, - buf, - { expiresIn: "7d" } + secret, + { + expiresIn: "8h", + algorithm: 'HS256' + } ); + // Store session with additional security info await prisma.session.create({ data: { userId: user!.id, sessionToken: token, - expires: new Date(Date.now() + 60 * 60 * 1000), + expires: new Date(Date.now() + 8 * 60 * 60 * 1000), // 8 hours + userAgent: request.headers['user-agent'] || '', + ipAddress: request.ip, }, }); @@ -763,18 +773,12 @@ export function authRoutes(fastify: FastifyInstance) { password: string; }; - const bearer = request.headers.authorization!.split(" ")[1]; - - let session = await prisma.session.findUnique({ - where: { - sessionToken: bearer, - }, - }); + const session = await checkSession(request); const hashedPass = await bcrypt.hash(password, 10); await prisma.user.update({ - where: { id: session?.userId }, + where: { id: session?.id }, data: { password: hashedPass, }, @@ -831,13 +835,7 @@ export function authRoutes(fastify: FastifyInstance) { fastify.put( "/api/v1/auth/profile", async (request: FastifyRequest, reply: FastifyReply) => { - const bearer = request.headers.authorization!.split(" ")[1]; - - let session = await prisma.session.findUnique({ - where: { - sessionToken: bearer, - }, - }); + const session = await checkSession(request); const { name, email, language } = request.body as { name: string; @@ -846,7 +844,7 @@ export function authRoutes(fastify: FastifyInstance) { }; let user = await prisma.user.update({ - where: { id: session?.userId }, + where: { id: session?.id }, data: { name: name, email: email, @@ -864,12 +862,7 @@ export function authRoutes(fastify: FastifyInstance) { fastify.put( "/api/v1/auth/profile/notifcations/emails", async (request: FastifyRequest, reply: FastifyReply) => { - const bearer = request.headers.authorization!.split(" ")[1]; - let session = await prisma.session.findUnique({ - where: { - sessionToken: bearer, - }, - }); + const session = await checkSession(request); const { notify_ticket_created, @@ -879,7 +872,7 @@ export function authRoutes(fastify: FastifyInstance) { } = request.body as any; let user = await prisma.user.update({ - where: { id: session?.userId }, + where: { id: session?.id }, data: { notify_ticket_created: notify_ticket_created, notify_ticket_assigned: notify_ticket_assigned, @@ -912,28 +905,37 @@ export function authRoutes(fastify: FastifyInstance) { fastify.put( "/api/v1/auth/user/role", async (request: FastifyRequest, reply: FastifyReply) => { - const { id, role } = request.body as { id: string; role: boolean }; - // check for atleast one admin on role downgrade - if (role === false) { - const admins = await prisma.user.findMany({ - where: { isAdmin: true }, - }); - if (admins.length === 1) { - reply.code(400).send({ - message: "Atleast one admin is required", - success: false, + const session = await checkSession(request); + + if (session?.isAdmin) { + const { id, role } = request.body as { id: string; role: boolean }; + // check for atleast one admin on role downgrade + if (role === false) { + const admins = await prisma.user.findMany({ + where: { isAdmin: true }, }); - return; + if (admins.length === 1) { + reply.code(400).send({ + message: "Atleast one admin is required", + success: false, + }); + return; + } } - } - await prisma.user.update({ - where: { id }, - data: { - isAdmin: role, - }, - }); + await prisma.user.update({ + where: { id }, + data: { + isAdmin: role, + }, + }); - reply.send({ success: true }); + reply.send({ success: true }); + } else { + reply.code(401).send({ + message: "Unauthorized", + success: false, + }); + } } ); @@ -955,4 +957,57 @@ export function authRoutes(fastify: FastifyInstance) { reply.send({ success: true }); } ); + + // Add a new endpoint to list and manage active sessions + fastify.get("/api/v1/auth/sessions", + async (request: FastifyRequest, reply: FastifyReply) => { + const currentUser = await checkSession(request); + if (!currentUser) { + return reply.code(401).send({ message: "Unauthorized" }); + } + + const sessions = await prisma.session.findMany({ + where: { userId: currentUser.id }, + select: { + id: true, + userAgent: true, + ipAddress: true, + createdAt: true, + expires: true + } + }); + + reply.send({ sessions }); + } + ); + + // Add ability to revoke specific sessions + fastify.delete("/api/v1/auth/sessions/:sessionId", + async (request: FastifyRequest, reply: FastifyReply) => { + const currentUser = await checkSession(request); + if (!currentUser) { + return reply.code(401).send({ message: "Unauthorized" }); + } + + const { sessionId } = request.params as { sessionId: string }; + + // Only allow users to delete their own sessions + const session = await prisma.session.findFirst({ + where: { + id: sessionId, + userId: currentUser.id + } + }); + + if (!session) { + return reply.code(404).send({ message: "Session not found" }); + } + + await prisma.session.delete({ + where: { id: sessionId } + }); + + reply.send({ success: true }); + } + ); } diff --git a/apps/api/src/lib/session.ts b/apps/api/src/lib/session.ts index c302e7795..ed2dfa3a3 100644 --- a/apps/api/src/lib/session.ts +++ b/apps/api/src/lib/session.ts @@ -1,18 +1,63 @@ +import { FastifyRequest } from "fastify"; +import jwt from "jsonwebtoken"; import { prisma } from "../prisma"; // Checks session token and returns user object -export async function checkSession(request: any) { - const token = request.headers.authorization!.split(" ")[1]; +export async function checkSession(request: FastifyRequest) { + try { + const bearer = request.headers.authorization?.split(" ")[1]; + if (!bearer) { + return null; + } - let session = await prisma.session.findUnique({ - where: { - sessionToken: token, - }, - }); + // Verify JWT token is valid + var b64string = process.env.SECRET; + var secret = Buffer.from(b64string!, "base64"); - let user = await prisma.user.findUnique({ - where: { id: session!.userId }, - }); + try { + jwt.verify(bearer, secret); + } catch (e) { + // Token is invalid or expired + await prisma.session.delete({ + where: { sessionToken: bearer }, + }); + return null; + } - return user; + // Check if session exists and is not expired + const session = await prisma.session.findUnique({ + where: { sessionToken: bearer }, + include: { user: true }, + }); + + if (!session || session.expires < new Date()) { + // Session expired or doesn't exist + if (session) { + await prisma.session.delete({ + where: { id: session.id }, + }); + } + return null; + } + + // Verify the request is coming from the same client + const currentUserAgent = request.headers["user-agent"]; + const currentIp = request.ip; + + if ( + session.userAgent !== currentUserAgent && + session.ipAddress !== currentIp + ) { + // Potential session hijacking attempt - invalidate the session + await prisma.session.delete({ + where: { id: session.id }, + }); + + return null; + } + + return session.user; + } catch (error) { + return null; + } } diff --git a/apps/api/src/prisma/migrations/20241114164206_sessions/migration.sql b/apps/api/src/prisma/migrations/20241114164206_sessions/migration.sql new file mode 100644 index 000000000..d0d9480e0 --- /dev/null +++ b/apps/api/src/prisma/migrations/20241114164206_sessions/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "apiKey" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "ipAddress" TEXT, +ADD COLUMN "userAgent" TEXT; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 50450db8d..50a49029a 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -48,7 +48,12 @@ model Session { sessionToken String @unique userId String expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + userAgent String? + ipAddress String? + apiKey Boolean @default(false) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model PasswordResetToken {