From 0371af601cd4f5d47189fad682176c781d214696 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:28:15 +0000 Subject: [PATCH] feat(auth): enforce token revocation check in middleware - Created `backend/src/shared/middleware/auth.middleware.ts` to centralize JWT verification and blacklist checking. - Refactored `auth`, `user`, `project`, `ama`, and `analytics` routes to use the new `authenticate` middleware. - Removed insecure inline `preHandler` blocks that skipped revocation checks. - Added `backend/jest.config.js` and `backend/jest.setup.js` to enable testing. - Added unit tests for the authentication middleware. - Documented vulnerability in `.jules/sentinel.md`. This fixes a critical vulnerability where revoked tokens (e.g., after logout) could still be used to access protected endpoints. --- .jules/sentinel.md | 7 ++ backend/jest.config.js | 9 ++ backend/jest.setup.js | 18 ++++ backend/package-lock.json | 26 ++--- backend/src/modules/ama/ama.routes.ts | 100 ++---------------- .../src/modules/analytics/analytics.routes.ts | 93 ++++------------ backend/src/modules/auth/auth.routes.ts | 24 ++--- .../src/modules/projects/project.routes.ts | 7 +- backend/src/modules/users/user.routes.ts | 82 ++++---------- .../shared/middleware/auth.middleware.test.ts | 83 +++++++++++++++ .../src/shared/middleware/auth.middleware.ts | 53 ++++++++++ 11 files changed, 239 insertions(+), 263 deletions(-) create mode 100644 .jules/sentinel.md create mode 100644 backend/jest.config.js create mode 100644 backend/jest.setup.js create mode 100644 backend/src/shared/middleware/auth.middleware.test.ts create mode 100644 backend/src/shared/middleware/auth.middleware.ts diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..3a8ddd2 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,7 @@ +## 2024-05-22 - Incomplete Token Revocation Bypass +**Vulnerability:** The application implemented a token blacklist mechanism in `AuthService` but failed to enforce it in the API routes. The `preHandler` hooks only called `request.jwtVerify()`, which validates the signature and expiration but ignores the blacklist. This meant that a "logged out" token (present in the blacklist) could still be used to access protected endpoints until it naturally expired. +**Learning:** Security features must be enforced at the gate (middleware). Implementing a service method (`isTokenBlacklisted`) is useless if it's not invoked during the request lifecycle. Code duplication in route definitions (`preHandler` blocks) makes it easy to miss security checks and hard to update them globally. +**Prevention:** +1. Centralize authentication logic in a single middleware (e.g., `auth.middleware.ts`). +2. Avoid inline `preHandler` definitions that repeat boilerplate code. +3. Ensure that "logout" actions actually invalidate the session on the server side (via blacklist/revocation list) and that this list is checked on *every* request. diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..72bda0e --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + setupFiles: ['/jest.setup.js'], +}; diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..d7f885c --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,18 @@ +process.env.NODE_ENV = 'test'; +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'; +process.env.JWT_SECRET = 'test-secret'; +process.env.JWT_EXPIRES_IN = '1h'; +process.env.JWT_REFRESH_EXPIRES_IN = '1d'; +process.env.LUNES_RPC_URL = 'http://localhost:8545'; +process.env.CONTRACT_ADDRESS = '0x123'; +process.env.PRIVATE_KEY = '0x123'; +process.env.SMTP_HOST = 'localhost'; +process.env.AWS_ACCESS_KEY_ID = 'test'; +process.env.AWS_SECRET_ACCESS_KEY = 'test'; +process.env.AWS_BUCKET_NAME = 'test'; +process.env.AWS_REGION = 'us-east-1'; +process.env.KYC_API_KEY = 'test'; +process.env.KYC_API_URL = 'http://localhost'; +process.env.PRICE_ORACLE_API = 'test'; +process.env.PRICE_ORACLE_URL = 'http://localhost'; +process.env.REDIS_URL = 'redis://localhost:6379'; diff --git a/backend/package-lock.json b/backend/package-lock.json index c5266d5..c12d7e7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -781,7 +781,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3181,7 +3180,6 @@ "resolved": "https://registry.npmjs.org/@polkadot/util/-/util-12.6.2.tgz", "integrity": "sha512-l8TubR7CLEY47240uki0TQzFvtnxFIO7uI/0GoWzpYD/O62EIAMRsuY01N4DuwgKq2ZWD59WhzsLYmA5K6ksdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@polkadot/x-bigint": "12.6.2", "@polkadot/x-global": "12.6.2", @@ -3367,7 +3365,6 @@ "resolved": "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-12.6.2.tgz", "integrity": "sha512-Vr8uG7rH2IcNJwtyf5ebdODMcr0XjoCpUbI91Zv6AlKVYOGKZlKLYJHIwpTaKKB+7KPWyQrk4Mlym/rS7v9feg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@polkadot/x-global": "12.6.2", "tslib": "^2.6.2" @@ -3497,7 +3494,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4630,7 +4626,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4850,7 +4845,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -5041,7 +5035,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5541,7 +5534,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -6459,7 +6451,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7978,7 +7969,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9945,7 +9935,6 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -10383,7 +10372,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10699,6 +10687,16 @@ "node": ">=8" } }, + "node_modules/smoldot": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-2.0.22.tgz", + "integrity": "sha512-B50vRgTY6v3baYH6uCgL15tfaag5tcS2o/P5q1OiXcKGv1axZDfz2dzzMuIkVpyMR2ug11F6EAtQlmYBQd292g==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", + "optional": true, + "dependencies": { + "ws": "^8.8.1" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -11241,7 +11239,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11369,7 +11366,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12067,7 +12063,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -12178,7 +12173,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/src/modules/ama/ama.routes.ts b/backend/src/modules/ama/ama.routes.ts index 44af5d0..76f4380 100644 --- a/backend/src/modules/ama/ama.routes.ts +++ b/backend/src/modules/ama/ama.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { AmaController } from './ama.controller'; +import { authenticate } from '../../shared/middleware/auth.middleware'; /** * Rotas para AMA (Ask Me Anything) @@ -133,16 +134,7 @@ export const amaRoutes = 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] }, amaController.getAmaSessions.bind(amaController)); // GET /ama/sessions/:id - Obter sessão AMA específica @@ -163,16 +155,7 @@ export const amaRoutes = async (server: FastifyInstance) => { 200: { $ref: 'amaSessionResponse#' } } }, - 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] }, amaController.getAmaSession.bind(amaController)); // POST /ama/sessions - Criar nova sessão AMA @@ -187,16 +170,7 @@ export const amaRoutes = async (server: FastifyInstance) => { 201: { $ref: 'amaSessionResponse#' } } }, - 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] }, amaController.createAmaSession.bind(amaController)); // PUT /ama/sessions/:id - Atualizar sessão AMA @@ -231,16 +205,7 @@ export const amaRoutes = async (server: FastifyInstance) => { 200: { $ref: 'amaSessionResponse#' } } }, - 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] }, amaController.updateAmaSession.bind(amaController)); // GET /ama/sessions/:sessionId/questions - Listar perguntas de uma sessão @@ -291,16 +256,7 @@ export const amaRoutes = 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] }, amaController.getAmaQuestions.bind(amaController)); // POST /ama/sessions/:sessionId/questions - Criar pergunta em uma sessão @@ -329,16 +285,7 @@ export const amaRoutes = 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] }, amaController.createAmaQuestion.bind(amaController)); // PUT /ama/questions/:questionId/answer - Responder pergunta @@ -367,16 +314,7 @@ export const amaRoutes = 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] }, amaController.answerAmaQuestion.bind(amaController)); // POST /ama/questions/:questionId/vote - Votar em pergunta @@ -405,16 +343,7 @@ export const amaRoutes = 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] }, amaController.voteAmaQuestion.bind(amaController)); // GET /ama/sessions/:sessionId/stats - Estatísticas da sessão @@ -449,15 +378,6 @@ export const amaRoutes = 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] }, amaController.getAmaStats.bind(amaController)); }; \ No newline at end of file diff --git a/backend/src/modules/analytics/analytics.routes.ts b/backend/src/modules/analytics/analytics.routes.ts index f79b939..339e426 100644 --- a/backend/src/modules/analytics/analytics.routes.ts +++ b/backend/src/modules/analytics/analytics.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { AnalyticsController } from './analytics.controller'; +import { authenticate } from '../../shared/middleware/auth.middleware'; /** * Rotas para Analytics @@ -64,16 +65,7 @@ export const analyticsRoutes = async (server: FastifyInstance) => { 200: { $ref: 'dashboardMetricsResponse#' } } }, - 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] }, analyticsController.getDashboardMetrics.bind(analyticsController)); // GET /analytics/projects - Métricas de projetos @@ -108,16 +100,7 @@ export const analyticsRoutes = 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] }, analyticsController.getProjectsMetrics.bind(analyticsController)); // GET /analytics/users - Métricas de usuários (admin only) @@ -160,25 +143,17 @@ export const analyticsRoutes = 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' }); } - } + }] }, analyticsController.getUsersMetrics.bind(analyticsController)); // GET /analytics/revenue - Métricas de receita (admin only) @@ -228,25 +203,17 @@ export const analyticsRoutes = 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' }); } - } + }] }, analyticsController.getRevenueMetrics.bind(analyticsController)); // GET /analytics/performance - Métricas de performance @@ -287,16 +254,7 @@ export const analyticsRoutes = 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] }, analyticsController.getPerformanceMetrics.bind(analyticsController)); // POST /analytics/events - Registrar evento de analytics @@ -317,16 +275,7 @@ export const analyticsRoutes = 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] }, analyticsController.trackEvent.bind(analyticsController)); await server.register(async function (server) {}, { prefix: '/analytics' }); diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts index 8d02460..402739d 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/auth.middleware'; export async function authRoutes(fastify: FastifyInstance) { const authController = new AuthController(); @@ -219,23 +220,10 @@ 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', { @@ -244,7 +232,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 +247,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 +262,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 +277,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/projects/project.routes.ts b/backend/src/modules/projects/project.routes.ts index 81bde97..87287e4 100644 --- a/backend/src/modules/projects/project.routes.ts +++ b/backend/src/modules/projects/project.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { logger } from '../../shared/logger'; +import { authenticate } from '../../shared/middleware/auth.middleware'; /** * Rotas para gerenciamento de projetos @@ -151,11 +152,7 @@ export const projectRoutes = async (server: FastifyInstance) => { server.addHook('preHandler', async (request, reply) => { // Aplicar apenas para métodos que precisam de autenticação if (['POST', 'PUT', 'DELETE'].includes(request.method)) { - try { - await request.jwtVerify(); - } catch (err) { - reply.send(err); - } + await authenticate(request, reply); } }); diff --git a/backend/src/modules/users/user.routes.ts b/backend/src/modules/users/user.routes.ts index adda337..7a6dc3c 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/auth.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.test.ts b/backend/src/shared/middleware/auth.middleware.test.ts new file mode 100644 index 0000000..5046da5 --- /dev/null +++ b/backend/src/shared/middleware/auth.middleware.test.ts @@ -0,0 +1,83 @@ +import { authenticate } from './auth.middleware'; +import { AuthService } from '../../modules/auth/auth.service'; +import { Logger } from '../logger'; + +// Mocks +jest.mock('../../modules/auth/auth.service'); +jest.mock('../logger'); + +describe('Auth Middleware', () => { + let mockRequest: any; + let mockReply: any; + let mockAuthService: any; + + beforeEach(() => { + mockRequest = { + jwtVerify: jest.fn(), + headers: { + authorization: 'Bearer valid-token', + }, + ip: '127.0.0.1', + url: '/test', + method: 'GET', + }; + + mockReply = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + mockAuthService = { + isTokenBlacklisted: jest.fn(), + }; + + // Mock static getInstance + (AuthService.getInstance as jest.Mock).mockReturnValue(mockAuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should proceed if token is valid and not blacklisted', async () => { + mockAuthService.isTokenBlacklisted.mockResolvedValue(false); + + await authenticate(mockRequest, mockReply); + + expect(mockRequest.jwtVerify).toHaveBeenCalled(); + expect(mockAuthService.isTokenBlacklisted).toHaveBeenCalledWith('valid-token'); + // If successful, it just returns undefined (void), so we expect no error response + expect(mockReply.status).not.toHaveBeenCalled(); + }); + + it('should return 401 if token is blacklisted', async () => { + mockAuthService.isTokenBlacklisted.mockResolvedValue(true); + + await authenticate(mockRequest, mockReply); + + expect(mockReply.status).toHaveBeenCalledWith(401); + expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ + code: 'TOKEN_REVOKED' + })); + }); + + it('should return 401 if jwtVerify throws', async () => { + mockRequest.jwtVerify.mockRejectedValue(new Error('Invalid token')); + + await authenticate(mockRequest, mockReply); + + expect(mockReply.status).toHaveBeenCalledWith(401); + expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ + code: 'UNAUTHORIZED' + })); + }); + + it('should throw/return 401 if authorization header is missing', async () => { + mockRequest.headers.authorization = undefined; + mockRequest.jwtVerify.mockResolvedValue(undefined); // Verify passes (maybe cookie?) but we check header + + await authenticate(mockRequest, mockReply); + + expect(mockReply.status).toHaveBeenCalledWith(401); + }); +}); diff --git a/backend/src/shared/middleware/auth.middleware.ts b/backend/src/shared/middleware/auth.middleware.ts new file mode 100644 index 0000000..449d968 --- /dev/null +++ b/backend/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,53 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { AuthService } from '../../modules/auth/auth.service'; +import { Logger } from '../logger'; + +/** + * Middleware para autenticação JWT + * Verifica assinatura, expiração e se o token foi revogado (blacklist) + */ +export const authenticate = async (request: FastifyRequest, reply: FastifyReply) => { + try { + // 1. Verifica assinatura e expiração (JWT padrão) + await request.jwtVerify(); + + // 2. Extrai o token do header + const authHeader = request.headers.authorization; + if (!authHeader) { + throw new Error('No authorization header'); + } + + // Remove 'Bearer ' se existir + const token = authHeader.replace(/^Bearer\s+/i, ''); + + // 3. Verifica se o token está na blacklist (revogado) + const isBlacklisted = await AuthService.getInstance().isTokenBlacklisted(token); + + if (isBlacklisted) { + Logger.auth('Tentativa de uso de token revogado', { + ip: request.ip, + path: request.url, + method: request.method + }); + + return reply.status(401).send({ + success: false, + error: 'Token revogado. Faça login novamente.', + code: 'TOKEN_REVOKED' + }); + } + + // Token é válido e não está na blacklist + } catch (err: any) { + Logger.auth('Falha na autenticação', { + error: err.message, + ip: request.ip + }); + + return reply.status(401).send({ + success: false, + error: 'Token de autorização inválido ou expirado', + code: 'UNAUTHORIZED' + }); + } +};