diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..0c54f5d --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-02-03 - Incomplete Token Revocation Check +**Vulnerability:** Protected routes relied solely on `request.jwtVerify()` which validates signature/expiry but ignores the Redis-based token blacklist. +**Learning:** Fastify's JWT plugin does not automatically integrate with application-level revocation lists. Middleware must explicitly check the blacklist after signature verification. +**Prevention:** Use the centralized `authenticate` middleware for all protected routes; avoid direct `request.jwtVerify()` calls in route handlers. diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..bd07e7f --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + setupFiles: ['/jest.setup.js'], +}; diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..bd35bfb --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,16 @@ +process.env.NODE_ENV = 'test'; +process.env.DATABASE_URL = 'postgresql://user:password@localhost:5432/launchpad_test'; +process.env.JWT_SECRET = 'test-secret-12345678901234567890123456789012'; // > 32 chars +process.env.REDIS_URL = 'redis://localhost:6379'; +process.env.LUNES_RPC_URL = 'http://localhost:8545'; +process.env.CONTRACT_ADDRESS = '0x123'; +process.env.PRIVATE_KEY = '0xabc'; +process.env.SMTP_HOST = 'localhost'; +process.env.SMTP_USER = 'user'; +process.env.SMTP_PASS = 'pass'; +process.env.AWS_ACCESS_KEY_ID = 'key'; +process.env.AWS_SECRET_ACCESS_KEY = 'secret'; +process.env.AWS_BUCKET_NAME = 'bucket'; +process.env.AWS_REGION = 'us-east-1'; +process.env.KYC_API_KEY = 'key'; +process.env.PRICE_ORACLE_API = 'key'; diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts index 8d02460..006f5c1 100644 --- a/backend/src/modules/auth/auth.routes.ts +++ b/backend/src/modules/auth/auth.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { AuthController } from './auth.controller'; +import { authenticate } from '../../shared/middleware'; export async function authRoutes(fastify: FastifyInstance) { const authController = new AuthController(); @@ -181,6 +182,7 @@ export async function authRoutes(fastify: FastifyInstance) { }, }, }, + preHandler: authenticate }, authController.logout.bind(authController)); // GET /auth/me - Obter perfil do usuário autenticado @@ -219,24 +221,9 @@ export async function authRoutes(fastify: FastifyInstance) { }, }, }, - preHandler: [async (request, reply) => { - try { - await request.jwtVerify(); - } catch (err) { - reply.send(err); - } - }], + preHandler: authenticate, }, authController.getProfile.bind(authController)); - // 2FA Routes - const twoFactorAuth = async (request: any, reply: any) => { - try { - await request.jwtVerify(); - } catch (err) { - reply.send(err); - } - }; - // POST /auth/2fa/generate fastify.post('/2fa/generate', { schema: { @@ -244,7 +231,7 @@ export async function authRoutes(fastify: FastifyInstance) { tags: ['auth'], security: [{ bearerAuth: [] }], }, - preHandler: [twoFactorAuth] + preHandler: authenticate }, authController.generate2FA.bind(authController)); // POST /auth/2fa/enable @@ -259,7 +246,7 @@ export async function authRoutes(fastify: FastifyInstance) { properties: { token: { type: 'string' } } } }, - preHandler: [twoFactorAuth] + preHandler: authenticate }, authController.enable2FA.bind(authController)); // POST /auth/2fa/validate @@ -274,7 +261,7 @@ export async function authRoutes(fastify: FastifyInstance) { properties: { token: { type: 'string' } } } }, - preHandler: [twoFactorAuth] + preHandler: authenticate }, authController.validate2FA.bind(authController)); // POST /auth/2fa/disable @@ -289,6 +276,6 @@ export async function authRoutes(fastify: FastifyInstance) { properties: { token: { type: 'string' } } } }, - preHandler: [twoFactorAuth] + preHandler: authenticate }, authController.disable2FA.bind(authController)); } \ No newline at end of file diff --git a/backend/src/modules/users/user.routes.ts b/backend/src/modules/users/user.routes.ts index adda337..7a6b0b7 100644 --- a/backend/src/modules/users/user.routes.ts +++ b/backend/src/modules/users/user.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { UserController } from './user.controller'; +import { authenticate } from '../../shared/middleware'; /** * Rotas para gerenciamento de usuários @@ -97,25 +98,17 @@ export const userRoutes = async (server: FastifyInstance) => { } } }, - preHandler: async (request, reply) => { - try { - await request.jwtVerify(); - const user = (request as any).user; - - // Verificar se é admin - if (user.role !== 'ADMIN') { - return reply.status(403).send({ - success: false, - error: 'Acesso negado - apenas administradores' - }); - } - } catch (err) { - return reply.status(401).send({ + preHandler: [authenticate, async (request, reply) => { + const user = (request as any).user; + + // Verificar se é admin + if (user.role !== 'ADMIN') { + return reply.status(403).send({ success: false, - error: 'Token de autorização inválido' + error: 'Acesso negado - apenas administradores' }); } - } + }] }, userController.getUsers.bind(userController)); // GET /users/:id - Obter usuário por ID @@ -142,16 +135,7 @@ export const userRoutes = async (server: FastifyInstance) => { } } }, - preHandler: async (request, reply) => { - try { - await request.jwtVerify(); - } catch (err) { - return reply.status(401).send({ - success: false, - error: 'Token de autorização inválido' - }); - } - } + preHandler: authenticate }, userController.getUserById.bind(userController)); // PUT /users/:id - Atualizar perfil do usuário @@ -180,16 +164,7 @@ export const userRoutes = async (server: FastifyInstance) => { } } }, - preHandler: async (request, reply) => { - try { - await request.jwtVerify(); - } catch (err) { - return reply.status(401).send({ - success: false, - error: 'Token de autorização inválido' - }); - } - } + preHandler: authenticate }, userController.updateUser.bind(userController)); // GET /users/:id/stats - Obter estatísticas do usuário @@ -227,16 +202,7 @@ export const userRoutes = async (server: FastifyInstance) => { } } }, - preHandler: async (request, reply) => { - try { - await request.jwtVerify(); - } catch (err) { - return reply.status(401).send({ - success: false, - error: 'Token de autorização inválido' - }); - } - } + preHandler: authenticate }, userController.getUserStats.bind(userController)); // PUT /users/:id/kyc - Atualizar status KYC (admin only) @@ -265,25 +231,17 @@ export const userRoutes = async (server: FastifyInstance) => { } } }, - preHandler: async (request, reply) => { - try { - await request.jwtVerify(); - const user = (request as any).user; - - // Verificar se é admin - if (user.role !== 'ADMIN') { - return reply.status(403).send({ - success: false, - error: 'Acesso negado - apenas administradores' - }); - } - } catch (err) { - return reply.status(401).send({ + preHandler: [authenticate, async (request, reply) => { + const user = (request as any).user; + + // Verificar se é admin + if (user.role !== 'ADMIN') { + return reply.status(403).send({ success: false, - error: 'Token de autorização inválido' + error: 'Acesso negado - apenas administradores' }); } - } + }] }, userController.updateKycStatus.bind(userController)); await server.register(async function (server) { diff --git a/backend/src/shared/middleware/auth.middleware.spec.ts b/backend/src/shared/middleware/auth.middleware.spec.ts new file mode 100644 index 0000000..47d10e7 --- /dev/null +++ b/backend/src/shared/middleware/auth.middleware.spec.ts @@ -0,0 +1,68 @@ +import { authenticate } from './auth.middleware'; +import { AuthService } from '../../modules/auth/auth.service'; + +// Mock AuthService +jest.mock('../../modules/auth/auth.service'); + +describe('Auth Middleware', () => { + let mockRequest: any; + let mockReply: any; + let mockAuthServiceInstance: any; + + beforeEach(() => { + mockRequest = { + jwtVerify: jest.fn(), + headers: { + authorization: 'Bearer valid-token' + } + }; + mockReply = { + status: jest.fn().mockReturnThis(), + send: jest.fn() + }; + + mockAuthServiceInstance = { + isTokenBlacklisted: jest.fn() + }; + + (AuthService.getInstance as jest.Mock).mockReturnValue(mockAuthServiceInstance); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call jwtVerify', async () => { + mockAuthServiceInstance.isTokenBlacklisted.mockResolvedValue(false); + await authenticate(mockRequest, mockReply); + expect(mockRequest.jwtVerify).toHaveBeenCalled(); + }); + + it('should return 401 if jwtVerify fails', async () => { + mockRequest.jwtVerify.mockRejectedValue(new Error('Invalid token')); + await authenticate(mockRequest, mockReply); + expect(mockReply.status).toHaveBeenCalledWith(401); + expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ error: 'Token de autorização inválido' })); + }); + + it('should return 401 if token is blacklisted', async () => { + mockRequest.jwtVerify.mockResolvedValue({ userId: '123' }); + mockAuthServiceInstance.isTokenBlacklisted.mockResolvedValue(true); + + await authenticate(mockRequest, mockReply); + + expect(mockAuthServiceInstance.isTokenBlacklisted).toHaveBeenCalledWith('valid-token'); + expect(mockReply.status).toHaveBeenCalledWith(401); + expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ error: 'Token revogado. Faça login novamente.' })); + }); + + it('should pass if token is valid and not blacklisted', async () => { + mockRequest.jwtVerify.mockResolvedValue({ userId: '123' }); + mockAuthServiceInstance.isTokenBlacklisted.mockResolvedValue(false); + + await authenticate(mockRequest, mockReply); + + expect(mockReply.status).not.toHaveBeenCalled(); + expect(mockReply.send).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/shared/middleware/auth.middleware.ts b/backend/src/shared/middleware/auth.middleware.ts new file mode 100644 index 0000000..5a92521 --- /dev/null +++ b/backend/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,36 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { AuthService } from '../../modules/auth/auth.service'; + +export const authenticate = async (request: FastifyRequest, reply: FastifyReply) => { + try { + // 1. Verify signature and expiration (standard jwtVerify) + // This populates request.user + await request.jwtVerify(); + + // 2. Extract token to check blacklist + const authHeader = request.headers.authorization; + if (!authHeader) { + throw new Error('No token provided'); + } + + // Remove 'Bearer ' prefix to get the raw token + const token = authHeader.replace(/^Bearer\s+/i, ''); + + // 3. Check blacklist + const authService = AuthService.getInstance(); + const isBlacklisted = await authService.isTokenBlacklisted(token); + + if (isBlacklisted) { + return reply.status(401).send({ + success: false, + error: 'Token revogado. Faça login novamente.', + }); + } + + } catch (err) { + return reply.status(401).send({ + success: false, + error: 'Token de autorização inválido', + }); + } +}; diff --git a/backend/src/shared/middleware/index.ts b/backend/src/shared/middleware/index.ts index 224dd5d..57c5ab0 100644 --- a/backend/src/shared/middleware/index.ts +++ b/backend/src/shared/middleware/index.ts @@ -1,3 +1,4 @@ export { errorHandler, createAppError, setupGlobalErrorHandlers } from './error.middleware'; export { setupCors } from './cors.middleware'; -export { loggingMiddleware } from './logging.middleware'; \ No newline at end of file +export { loggingMiddleware } from './logging.middleware'; +export { authenticate } from './auth.middleware';