Jose Salazar commited on
Commit
cbd824d
Β·
unverified Β·
2 Parent(s): 88594454ec305b

Merge pull request #27 from josesalazar2025/feature/refactor-backend

Browse files
backend/docs/AUTH.md CHANGED
@@ -47,6 +47,14 @@ Sembrados por `prisma/seed.js` (idempotente, se puede re-ejecutar):
47
 
48
  ## 4. Endpoints
49
 
 
 
 
 
 
 
 
 
50
  ### `GET /api/v1/health`
51
 
52
  Sanity check. Respuesta:
@@ -107,6 +115,26 @@ Errores:
107
  |---|---|---|
108
  | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  ## 5. Ejemplos con `curl`
111
 
112
  ```bash
@@ -117,15 +145,24 @@ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
117
  | jq -r '.data.token')
118
 
119
  # 2) Llamar a /me con el token
 
 
 
 
 
 
 
 
120
  curl -s http://localhost:7860/api/v1/auth/me \
121
  -H "Authorization: Bearer $TOKEN" | jq
122
  ```
123
 
124
- ## 6. Login desde el frontend (referencia)
125
 
126
- El JWT es opaco para el front: basta con guardarlo (sessionStorage o estado en memoria) y enviarlo en cada request protegido.
127
 
128
  ```js
 
129
  const res = await fetch('/api/v1/auth/login', {
130
  method: 'POST',
131
  headers: { 'Content-Type': 'application/json' },
@@ -134,10 +171,17 @@ const res = await fetch('/api/v1/auth/login', {
134
  const json = await res.json();
135
  if (!json.ok) throw new Error(json.error.code);
136
  const { token, user } = json.data;
137
- // guardar token y user
138
 
139
- // requests autenticados
140
  fetch('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
 
 
 
 
 
 
 
141
  ```
142
 
143
  > En dev, Vite proxea `/api/*` al backend (`localhost:7860`); no hace falta CORS si va por el proxy, pero ya estΓ‘ configurado por si el front llama directo.
 
47
 
48
  ## 4. Endpoints
49
 
50
+ > Resumen rΓ‘pido:
51
+ >
52
+ > | MΓ©todo | Path | Auth | DescripciΓ³n |
53
+ > |---|---|---|---|
54
+ > | `POST` | `/api/v1/auth/login` | No | Obtener JWT |
55
+ > | `GET` | `/api/v1/auth/me` | Bearer | Perfil del usuario autenticado |
56
+ > | `POST` | `/api/v1/auth/logout` | Bearer | Invalidar token activo |
57
+
58
  ### `GET /api/v1/health`
59
 
60
  Sanity check. Respuesta:
 
115
  |---|---|---|
116
  | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
117
 
118
+ ### `POST /api/v1/auth/logout`
119
+
120
+ Requiere header `Authorization: Bearer <token>`. Invalida el token activo aΓ±adiendo su `jti` a la denylist en memoria β€” cualquier request posterior con ese token recibirΓ‘ `401 UNAUTHORIZED`.
121
+
122
+ El cliente debe descartar el token en cuanto reciba la respuesta.
123
+
124
+ Respuesta `200`:
125
+
126
+ ```json
127
+ { "ok": true, "data": { "message": "Logged out successfully" } }
128
+ ```
129
+
130
+ Errores:
131
+
132
+ | HTTP | code | cuΓ‘ndo |
133
+ |---|---|---|
134
+ | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o ya invalidado |
135
+
136
+ > **Nota:** la denylist es en memoria; si el servidor se reinicia los tokens previos al reinicio quedan tecnicamente activos, pero expirarΓ‘n solos en mΓ‘x 1 h (TTL del JWT).
137
+
138
  ## 5. Ejemplos con `curl`
139
 
140
  ```bash
 
145
  | jq -r '.data.token')
146
 
147
  # 2) Llamar a /me con el token
148
+ curl -s http://localhost:7860/api/v1/auth/me \
149
+ -H "Authorization: Bearer $TOKEN" | jq
150
+
151
+ # 3) Logout (invalida el token)
152
+ curl -s -X POST http://localhost:7860/api/v1/auth/logout \
153
+ -H "Authorization: Bearer $TOKEN" | jq
154
+
155
+ # 4) Verificar que el token ya no funciona (debe devolver 401)
156
  curl -s http://localhost:7860/api/v1/auth/me \
157
  -H "Authorization: Bearer $TOKEN" | jq
158
  ```
159
 
160
+ ## 6. Login / Logout desde el frontend (referencia)
161
 
162
+ El JWT es opaco para el front: basta con guardarlo (sessionStorage o estado en memoria) y enviarlo en cada request protegido. Al hacer logout, llamar al endpoint **antes** de descartar el token localmente para que quede invalidado en el servidor.
163
 
164
  ```js
165
+ // Login
166
  const res = await fetch('/api/v1/auth/login', {
167
  method: 'POST',
168
  headers: { 'Content-Type': 'application/json' },
 
171
  const json = await res.json();
172
  if (!json.ok) throw new Error(json.error.code);
173
  const { token, user } = json.data;
174
+ // guardar token y user en estado / sessionStorage
175
 
176
+ // Requests autenticados
177
  fetch('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
178
+
179
+ // Logout
180
+ await fetch('/api/v1/auth/logout', {
181
+ method: 'POST',
182
+ headers: { Authorization: `Bearer ${token}` },
183
+ });
184
+ // descartar token del estado / sessionStorage
185
  ```
186
 
187
  > En dev, Vite proxea `/api/*` al backend (`localhost:7860`); no hace falta CORS si va por el proxy, pero ya estΓ‘ configurado por si el front llama directo.
backend/package.json CHANGED
@@ -14,6 +14,7 @@
14
  "db:migrate": "prisma migrate dev",
15
  "db:generate": "prisma generate",
16
  "db:seed": "node prisma/seed.js",
 
17
  "db:studio": "prisma studio"
18
  },
19
  "dependencies": {
 
14
  "db:migrate": "prisma migrate dev",
15
  "db:generate": "prisma generate",
16
  "db:seed": "node prisma/seed.js",
17
+ "db:reset": "prisma migrate reset --force",
18
  "db:studio": "prisma studio"
19
  },
20
  "dependencies": {
backend/prisma/migrations/20260516071158_init_auth/migration.sql CHANGED
@@ -3,7 +3,6 @@ CREATE TABLE "User" (
3
  "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4
  "email" TEXT NOT NULL,
5
  "passwordHash" TEXT NOT NULL,
6
- "role" TEXT NOT NULL DEFAULT 'user',
7
  "isActive" BOOLEAN NOT NULL DEFAULT true,
8
  "telegramChatId" TEXT,
9
  "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -20,6 +19,11 @@ CREATE TABLE "Market" (
20
  "noPrice" REAL,
21
  "volumeEur" REAL,
22
  "liquidityEur" REAL,
 
 
 
 
 
23
  "status" TEXT NOT NULL DEFAULT 'active',
24
  "closesAt" DATETIME,
25
  "lastSynced" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -35,6 +39,9 @@ CREATE TABLE "AISignal" (
35
  "keyRisk" TEXT,
36
  "newsCount" INTEGER NOT NULL DEFAULT 0,
37
  "modelVersion" TEXT NOT NULL DEFAULT 'Qwen3-8B',
 
 
 
38
  "generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
39
  CONSTRAINT "AISignal_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
40
  );
 
3
  "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4
  "email" TEXT NOT NULL,
5
  "passwordHash" TEXT NOT NULL,
 
6
  "isActive" BOOLEAN NOT NULL DEFAULT true,
7
  "telegramChatId" TEXT,
8
  "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 
19
  "noPrice" REAL,
20
  "volumeEur" REAL,
21
  "liquidityEur" REAL,
22
+ "spread" REAL,
23
+ "bestBid" REAL,
24
+ "bestAsk" REAL,
25
+ "clobTokenId" TEXT,
26
+ "analyzable" BOOLEAN NOT NULL DEFAULT true,
27
  "status" TEXT NOT NULL DEFAULT 'active',
28
  "closesAt" DATETIME,
29
  "lastSynced" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
 
39
  "keyRisk" TEXT,
40
  "newsCount" INTEGER NOT NULL DEFAULT 0,
41
  "modelVersion" TEXT NOT NULL DEFAULT 'Qwen3-8B',
42
+ "impliedProb" REAL,
43
+ "fairProb" REAL,
44
+ "edgePoints" REAL,
45
  "generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
  CONSTRAINT "AISignal_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
47
  );
backend/src/auth/auth.controller.js CHANGED
@@ -2,17 +2,20 @@
2
  * Controladores del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
- * - login β†’ recibir credenciales, delegar validacion a auth.service.js,
6
- * responder con { token, user }.
7
- * - me β†’ devolver el usuario autenticado extraido del JWT (req.user).
 
 
8
  *
9
  * Errores:
10
  * - 401 INVALID_CREDENTIALS β†’ email o password incorrectos (mensaje generico).
11
- * - 401 UNAUTHORIZED β†’ token invalido o ausente (en requireAuth).
12
  */
13
 
14
  import * as authService from './auth.service.js';
15
  import { ok } from '../utils/apiResponse.js';
 
16
 
17
  export const login = async (req, res) => {
18
  const data = await authService.login(req.body);
@@ -27,3 +30,10 @@ export const register = async (req, res) => {
27
  export const me = async (req, res) => {
28
  ok(res, { user: req.user });
29
  };
 
 
 
 
 
 
 
 
2
  * Controladores del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
+ * - login β†’ recibir credenciales, delegar validacion a auth.service.js,
6
+ * responder con { token, user }.
7
+ * - me β†’ devolver el usuario autenticado extraido del JWT (req.user).
8
+ * - logout β†’ invalidar el token activo (jti + exp extraidos del payload JWT
9
+ * ya verificado por requireAuth); responder 200 con mensaje.
10
  *
11
  * Errores:
12
  * - 401 INVALID_CREDENTIALS β†’ email o password incorrectos (mensaje generico).
13
+ * - 401 UNAUTHORIZED β†’ token invalido, ausente o ya invalidado (en requireAuth).
14
  */
15
 
16
  import * as authService from './auth.service.js';
17
  import { ok } from '../utils/apiResponse.js';
18
+ import { verifyToken } from './jwt.js';
19
 
20
  export const login = async (req, res) => {
21
  const data = await authService.login(req.body);
 
30
  export const me = async (req, res) => {
31
  ok(res, { user: req.user });
32
  };
33
+
34
+ export const logout = (req, res) => {
35
+ const token = req.headers.authorization.slice('Bearer '.length).trim();
36
+ const payload = verifyToken(token);
37
+ authService.logout({ jti: payload.jti, exp: payload.exp });
38
+ ok(res, { message: 'Logged out successfully' });
39
+ };
backend/src/auth/auth.routes.js CHANGED
@@ -12,6 +12,11 @@
12
  * β†’ requireAuth
13
  * β†’ authController.me
14
  * β†’ Devuelve el usuario autenticado (req.user).
 
 
 
 
 
15
  */
16
 
17
  import { Router } from 'express';
@@ -26,5 +31,6 @@ const router = Router();
26
  router.post('/login', rateLimitLogin, validate(loginSchema), ctrl.login);
27
  router.post('/register', validate(registerSchema), ctrl.register);
28
  router.get('/me', requireAuth, ctrl.me);
 
29
 
30
  export default router;
 
12
  * β†’ requireAuth
13
  * β†’ authController.me
14
  * β†’ Devuelve el usuario autenticado (req.user).
15
+ *
16
+ * POST /api/v1/auth/logout
17
+ * β†’ requireAuth (verifica token valido y no bloqueado)
18
+ * β†’ authController.logout
19
+ * β†’ Invalida el jti del token en la denylist; responde 200.
20
  */
21
 
22
  import { Router } from 'express';
 
31
  router.post('/login', rateLimitLogin, validate(loginSchema), ctrl.login);
32
  router.post('/register', validate(registerSchema), ctrl.register);
33
  router.get('/me', requireAuth, ctrl.me);
34
+ router.post('/logout', requireAuth, ctrl.logout);
35
 
36
  export default router;
backend/src/auth/auth.service.js CHANGED
@@ -2,23 +2,27 @@
2
  * Logica de negocio del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
- * - login({ email, password }) β†’ buscar usuario, comparar hash con bcrypt,
6
  * verificar que este activo (isActive) y firmar JWT.
 
 
7
  *
8
  * Seguridad:
9
- * - Mensaje generico en fallo ("Email or password is incorrect")
10
  * para no revelar si el email existe.
11
  * - Bcrypt con salt rounds configurable (BCRYPT_ROUNDS, default 10).
12
  * - JWT firmado con HS256 y expiracion (JWT_EXPIRES_IN).
 
13
  *
14
  * Devuelve:
15
- * { token: string, user: { id, email } }
 
16
  */
17
 
18
  import bcrypt from 'bcryptjs';
19
  import { prisma } from '../utils/prisma.js';
20
  import { HttpError } from '../utils/apiResponse.js';
21
- import { signToken } from './jwt.js';
22
 
23
  const INVALID_CREDENTIALS = new HttpError(401, 'INVALID_CREDENTIALS', 'Email or password is incorrect');
24
 
@@ -55,3 +59,7 @@ export const register = async ({ email, password }) => {
55
  user: { id: user.id, email: user.email },
56
  };
57
  };
 
 
 
 
 
2
  * Logica de negocio del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
+ * - login({ email, password }) β†’ buscar usuario, comparar hash con bcrypt,
6
  * verificar que este activo (isActive) y firmar JWT.
7
+ * - logout({ jti, exp }) β†’ invalidar el token activo anadiendo su jti
8
+ * a la denylist en memoria hasta su expiracion original.
9
  *
10
  * Seguridad:
11
+ * - Mensaje generico en fallo de login ("Email or password is incorrect")
12
  * para no revelar si el email existe.
13
  * - Bcrypt con salt rounds configurable (BCRYPT_ROUNDS, default 10).
14
  * - JWT firmado con HS256 y expiracion (JWT_EXPIRES_IN).
15
+ * - Logout invalida el jti del token; el cliente debe descartar el token.
16
  *
17
  * Devuelve:
18
+ * login β†’ { token: string, user: { id, email } }
19
+ * logout β†’ void
20
  */
21
 
22
  import bcrypt from 'bcryptjs';
23
  import { prisma } from '../utils/prisma.js';
24
  import { HttpError } from '../utils/apiResponse.js';
25
+ import { signToken, addToDenylist } from './jwt.js';
26
 
27
  const INVALID_CREDENTIALS = new HttpError(401, 'INVALID_CREDENTIALS', 'Email or password is incorrect');
28
 
 
59
  user: { id: user.id, email: user.email },
60
  };
61
  };
62
+
63
+ export const logout = ({ jti, exp }) => {
64
+ if (jti && exp) addToDenylist(jti, exp);
65
+ };
backend/src/auth/jwt.js CHANGED
@@ -2,26 +2,58 @@
2
  * Helpers para firmar y verificar tokens JWT.
3
  *
4
  * Responsabilidades:
5
- * - signToken(payload) β†’ firma un token HS256 con expiracion configurable.
6
- * - verifyToken(token) β†’ verifica firma, expiracion y algoritmo (solo HS256).
 
 
 
 
 
 
 
 
 
7
  *
8
  * Consumido por:
9
- * - auth.service.js β†’ al hacer login exitoso.
10
- * - requireAuth.js β†’ en cada peticion protegida.
11
  *
12
  * Configuracion:
13
  * - JWT_SECRET : minimo 32 chars (validado en config.js).
14
  * - JWT_EXPIRES_IN: default '1h'.
15
  */
16
 
 
17
  import jwt from 'jsonwebtoken';
18
  import { config } from '../config.js';
19
 
 
 
 
20
  export const signToken = (payload) =>
21
- jwt.sign(payload, config.JWT_SECRET, {
22
  algorithm: 'HS256',
23
  expiresIn: config.JWT_EXPIRES_IN,
24
  });
25
 
26
  export const verifyToken = (token) =>
27
  jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  * Helpers para firmar y verificar tokens JWT.
3
  *
4
  * Responsabilidades:
5
+ * - signToken(payload) β†’ firma un token HS256 con expiracion configurable.
6
+ * Incluye claim `jti` (UUID v4) para poder invalidarlo.
7
+ * - verifyToken(token) β†’ verifica firma, expiracion y algoritmo (solo HS256).
8
+ * - addToDenylist(jti, exp) β†’ anota el jti como invalidado hasta su expiracion.
9
+ * - isBlocked(jti) β†’ true si el jti esta en la denylist.
10
+ * - pruneExpiredDenylist() β†’ elimina entradas ya expiradas (llamado en cada logout).
11
+ *
12
+ * Denylist:
13
+ * - En memoria (Map<jti, expEpochMs>). Sin persistencia entre reinicios.
14
+ * - Aceptable porque el TTL del token es 1h; al reiniciar el servidor
15
+ * cualquier token antiguo expirara por si solo antes de que importe.
16
  *
17
  * Consumido por:
18
+ * - auth.service.js β†’ signToken al hacer login; addToDenylist al hacer logout.
19
+ * - requireAuth.js β†’ verifyToken + isBlocked en cada peticion protegida.
20
  *
21
  * Configuracion:
22
  * - JWT_SECRET : minimo 32 chars (validado en config.js).
23
  * - JWT_EXPIRES_IN: default '1h'.
24
  */
25
 
26
+ import { randomUUID } from 'node:crypto';
27
  import jwt from 'jsonwebtoken';
28
  import { config } from '../config.js';
29
 
30
+ /** @type {Map<string, number>} jti β†’ expiry timestamp (ms) */
31
+ const denylist = new Map();
32
+
33
  export const signToken = (payload) =>
34
+ jwt.sign({ ...payload, jti: randomUUID() }, config.JWT_SECRET, {
35
  algorithm: 'HS256',
36
  expiresIn: config.JWT_EXPIRES_IN,
37
  });
38
 
39
  export const verifyToken = (token) =>
40
  jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
41
+
42
+ export const addToDenylist = (jti, exp) => {
43
+ pruneExpiredDenylist();
44
+ denylist.set(jti, exp * 1000);
45
+ };
46
+
47
+ export const isBlocked = (jti) => {
48
+ const exp = denylist.get(jti);
49
+ if (exp === undefined) return false;
50
+ if (Date.now() > exp) { denylist.delete(jti); return false; }
51
+ return true;
52
+ };
53
+
54
+ export const pruneExpiredDenylist = () => {
55
+ const now = Date.now();
56
+ for (const [jti, exp] of denylist) {
57
+ if (now > exp) denylist.delete(jti);
58
+ }
59
+ };
backend/src/middlewares/requireAuth.js CHANGED
@@ -4,17 +4,20 @@
4
  * Responsabilidades:
5
  * - Extraer el header Authorization: Bearer <token>.
6
  * - Verificar la firma y expiracion del token con jwt.verify().
 
7
  * - Buscar el usuario en la base de datos y comprobar que esta activo (isActive).
8
  * - Adjuntar req.user para que controladores y servicios posteriores lo usen.
9
  *
10
  * Rutas protegidas:
11
  * - Todas bajo /positions, /watchlist, /alerts.
12
  * - GET /auth/me.
 
13
  *
14
- * Si falta token, es invalido o el usuario no existe/inactivo β†’ 401 UNAUTHORIZED.
 
15
  */
16
 
17
- import { verifyToken } from '../auth/jwt.js';
18
  import { prisma } from '../utils/prisma.js';
19
  import { HttpError } from '../utils/apiResponse.js';
20
 
@@ -29,6 +32,9 @@ export const requireAuth = async (req, _res, next) => {
29
  if (!token) throw UNAUTHORIZED;
30
 
31
  const payload = verifyToken(token);
 
 
 
32
  const user = await prisma.user.findUnique({
33
  where: { id: payload.sub },
34
  select: { id: true, email: true, isActive: true, createdAt: true },
 
4
  * Responsabilidades:
5
  * - Extraer el header Authorization: Bearer <token>.
6
  * - Verificar la firma y expiracion del token con jwt.verify().
7
+ * - Comprobar que el jti no este en la denylist (logout previo).
8
  * - Buscar el usuario en la base de datos y comprobar que esta activo (isActive).
9
  * - Adjuntar req.user para que controladores y servicios posteriores lo usen.
10
  *
11
  * Rutas protegidas:
12
  * - Todas bajo /positions, /watchlist, /alerts.
13
  * - GET /auth/me.
14
+ * - POST /auth/logout.
15
  *
16
+ * Si falta token, es invalido, fue invalidado por logout, o el usuario
17
+ * no existe/inactivo β†’ 401 UNAUTHORIZED.
18
  */
19
 
20
+ import { verifyToken, isBlocked } from '../auth/jwt.js';
21
  import { prisma } from '../utils/prisma.js';
22
  import { HttpError } from '../utils/apiResponse.js';
23
 
 
32
  if (!token) throw UNAUTHORIZED;
33
 
34
  const payload = verifyToken(token);
35
+
36
+ if (payload.jti && isBlocked(payload.jti)) throw UNAUTHORIZED;
37
+
38
  const user = await prisma.user.findUnique({
39
  where: { id: payload.sub },
40
  select: { id: true, email: true, isActive: true, createdAt: true },
package.json CHANGED
@@ -16,6 +16,7 @@
16
  "preview:frontend": "npm run preview --workspace=frontend",
17
  "db:migrate": "npm run db:migrate --workspace=backend",
18
  "db:generate": "npm run db:generate --workspace=backend",
 
19
  "db:studio": "npm run db:studio --workspace=backend"
20
  },
21
  "engines": {
 
16
  "preview:frontend": "npm run preview --workspace=frontend",
17
  "db:migrate": "npm run db:migrate --workspace=backend",
18
  "db:generate": "npm run db:generate --workspace=backend",
19
+ "db:reset": "npm run db:reset --workspace=backend",
20
  "db:studio": "npm run db:studio --workspace=backend"
21
  },
22
  "engines": {