Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFiles: ['<rootDir>/jest.setup.js'],
};
16 changes: 16 additions & 0 deletions backend/jest.setup.js
Original file line number Diff line number Diff line change
@@ -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';
27 changes: 7 additions & 20 deletions backend/src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -219,32 +221,17 @@ 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: {
description: 'Gerar segredo 2FA',
tags: ['auth'],
security: [{ bearerAuth: [] }],
},
preHandler: [twoFactorAuth]
preHandler: authenticate
}, authController.generate2FA.bind(authController));

// POST /auth/2fa/enable
Expand All @@ -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
Expand All @@ -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
Expand All @@ -289,6 +276,6 @@ export async function authRoutes(fastify: FastifyInstance) {
properties: { token: { type: 'string' } }
}
},
preHandler: [twoFactorAuth]
preHandler: authenticate
}, authController.disable2FA.bind(authController));
}
82 changes: 20 additions & 62 deletions backend/src/modules/users/user.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FastifyInstance } from 'fastify';
import { UserController } from './user.controller';
import { authenticate } from '../../shared/middleware';

/**
* Rotas para gerenciamento de usuários
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions backend/src/shared/middleware/auth.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 36 additions & 0 deletions backend/src/shared/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
};
3 changes: 2 additions & 1 deletion backend/src/shared/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { errorHandler, createAppError, setupGlobalErrorHandlers } from './error.middleware';
export { setupCors } from './cors.middleware';
export { loggingMiddleware } from './logging.middleware';
export { loggingMiddleware } from './logging.middleware';
export { authenticate } from './auth.middleware';
Loading