ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
672 lines
20 KiB
Markdown
672 lines
20 KiB
Markdown
---
|
|
id: "ET-AUTH-002"
|
|
title: "JWT Tokens Implementation"
|
|
type: "Specification"
|
|
status: "Done"
|
|
rf_parent: "RF-AUTH-002"
|
|
epic: "OQI-001"
|
|
version: "1.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# ET-AUTH-002: Especificación Técnica - JWT Tokens
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Estado:** ✅ Implementado
|
|
**Épica:** [OQI-001](../_MAP.md)
|
|
**Requerimiento:** [RF-AUTH-002](../requerimientos/RF-AUTH-002-email.md), [RF-AUTH-005](../requerimientos/RF-AUTH-005-sessions.md)
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
Esta especificación detalla la implementación del sistema de tokens JWT para autenticación y autorización en Trading Platform.
|
|
|
|
---
|
|
|
|
## Arquitectura de Tokens
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ TOKEN ARCHITECTURE │
|
|
├─────────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌───────────────────────┐ ┌───────────────────────┐ │
|
|
│ │ ACCESS TOKEN │ │ REFRESH TOKEN │ │
|
|
│ │ │ │ │ │
|
|
│ │ TTL: 15 minutes │ │ TTL: 7 days │ │
|
|
│ │ Algorithm: RS256 │ │ Algorithm: RS256 │ │
|
|
│ │ Storage: Memory │ │ Storage: HttpOnly │ │
|
|
│ │ │ │ Cookie │ │
|
|
│ │ Contains: │ │ │ │
|
|
│ │ - User ID │ │ Contains: │ │
|
|
│ │ - Email │ │ - User ID │ │
|
|
│ │ - Role │ │ - Token ID (jti) │ │
|
|
│ │ - Permissions │ │ - Session ID │ │
|
|
│ │ │ │ │ │
|
|
│ └───────────────────────┘ └───────────────────────┘ │
|
|
│ │ │ │
|
|
│ │ Used for API calls │ Used to refresh │
|
|
│ │ │ access token │
|
|
│ ▼ ▼ │
|
|
│ ┌───────────────────────┐ ┌───────────────────────┐ │
|
|
│ │ API Middleware │ │ /auth/refresh │ │
|
|
│ │ (authenticate) │ │ Endpoint │ │
|
|
│ └───────────────────────┘ └───────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Token Service
|
|
|
|
**Ubicación:** `apps/backend/src/modules/auth/services/token.service.ts`
|
|
|
|
### Interfaz
|
|
|
|
```typescript
|
|
export interface TokenPayload {
|
|
sub: string; // User ID
|
|
email: string;
|
|
role: UserRole;
|
|
permissions: string[];
|
|
sessionId?: string;
|
|
}
|
|
|
|
export interface RefreshTokenPayload {
|
|
sub: string; // User ID
|
|
jti: string; // Unique token ID
|
|
sessionId: string;
|
|
type: 'refresh';
|
|
}
|
|
|
|
export interface TokenPair {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresIn: number; // seconds
|
|
tokenType: 'Bearer';
|
|
}
|
|
|
|
export class TokenService {
|
|
generateTokens(user: User, sessionId?: string): Promise<TokenPair>;
|
|
verifyAccessToken(token: string): Promise<TokenPayload>;
|
|
verifyRefreshToken(token: string): Promise<RefreshTokenPayload>;
|
|
revokeRefreshToken(tokenId: string): Promise<void>;
|
|
rotateRefreshToken(oldToken: string): Promise<TokenPair>;
|
|
}
|
|
```
|
|
|
|
### Implementación
|
|
|
|
```typescript
|
|
import jwt from 'jsonwebtoken';
|
|
import crypto from 'crypto';
|
|
|
|
export class TokenService {
|
|
private readonly accessTokenSecret: string;
|
|
private readonly refreshTokenSecret: string;
|
|
private readonly accessTokenExpiry = '15m';
|
|
private readonly refreshTokenExpiry = '7d';
|
|
|
|
constructor() {
|
|
this.accessTokenSecret = process.env.JWT_ACCESS_SECRET!;
|
|
this.refreshTokenSecret = process.env.JWT_REFRESH_SECRET!;
|
|
}
|
|
|
|
async generateTokens(user: User, sessionId?: string): Promise<TokenPair> {
|
|
const tokenId = crypto.randomUUID();
|
|
const sid = sessionId || crypto.randomUUID();
|
|
|
|
const accessToken = jwt.sign(
|
|
{
|
|
sub: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
permissions: this.getPermissions(user.role),
|
|
},
|
|
this.accessTokenSecret,
|
|
{
|
|
expiresIn: this.accessTokenExpiry,
|
|
algorithm: 'RS256',
|
|
issuer: 'trading.com',
|
|
audience: 'trading.com',
|
|
}
|
|
);
|
|
|
|
const refreshToken = jwt.sign(
|
|
{
|
|
sub: user.id,
|
|
jti: tokenId,
|
|
sessionId: sid,
|
|
type: 'refresh',
|
|
},
|
|
this.refreshTokenSecret,
|
|
{
|
|
expiresIn: this.refreshTokenExpiry,
|
|
algorithm: 'RS256',
|
|
issuer: 'trading.com',
|
|
}
|
|
);
|
|
|
|
// Almacenar hash del refresh token en sesión
|
|
await this.storeRefreshToken(user.id, tokenId, sid);
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresIn: 900, // 15 minutes
|
|
tokenType: 'Bearer',
|
|
};
|
|
}
|
|
|
|
async verifyAccessToken(token: string): Promise<TokenPayload> {
|
|
try {
|
|
const payload = jwt.verify(token, this.accessTokenSecret, {
|
|
algorithms: ['RS256'],
|
|
issuer: 'trading.com',
|
|
audience: 'trading.com',
|
|
}) as TokenPayload;
|
|
|
|
return payload;
|
|
} catch (error) {
|
|
if (error instanceof jwt.TokenExpiredError) {
|
|
throw new UnauthorizedError('Token expired');
|
|
}
|
|
throw new UnauthorizedError('Invalid token');
|
|
}
|
|
}
|
|
|
|
async rotateRefreshToken(oldToken: string): Promise<TokenPair> {
|
|
const payload = await this.verifyRefreshToken(oldToken);
|
|
|
|
// Verificar que el token no haya sido revocado
|
|
const isValid = await this.validateRefreshToken(payload.jti);
|
|
if (!isValid) {
|
|
// Posible token reuse - revocar todas las sesiones
|
|
await this.revokeAllUserSessions(payload.sub);
|
|
throw new UnauthorizedError('Token has been revoked');
|
|
}
|
|
|
|
// Revocar token anterior
|
|
await this.revokeRefreshToken(payload.jti);
|
|
|
|
// Obtener usuario
|
|
const user = await this.userService.findById(payload.sub);
|
|
if (!user) {
|
|
throw new UnauthorizedError('User not found');
|
|
}
|
|
|
|
// Generar nuevos tokens
|
|
return this.generateTokens(user, payload.sessionId);
|
|
}
|
|
|
|
private getPermissions(role: UserRole): string[] {
|
|
const permissions: Record<UserRole, string[]> = {
|
|
investor: ['read:portfolio', 'trade:paper', 'read:education'],
|
|
trader: ['read:portfolio', 'trade:real', 'read:education', 'create:signals'],
|
|
student: ['read:education'],
|
|
admin: ['admin:users', 'admin:content', 'read:*', 'write:*'],
|
|
superadmin: ['*'],
|
|
};
|
|
return permissions[role] || [];
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Estructura de Tokens
|
|
|
|
### Access Token
|
|
|
|
```json
|
|
// Header
|
|
{
|
|
"alg": "RS256",
|
|
"typ": "JWT"
|
|
}
|
|
|
|
// Payload
|
|
{
|
|
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
|
"email": "usuario@example.com",
|
|
"role": "investor",
|
|
"permissions": ["read:portfolio", "trade:paper", "read:education"],
|
|
"iat": 1701792000,
|
|
"exp": 1701792900,
|
|
"iss": "trading.com",
|
|
"aud": "trading.com"
|
|
}
|
|
```
|
|
|
|
### Refresh Token
|
|
|
|
```json
|
|
// Header
|
|
{
|
|
"alg": "RS256",
|
|
"typ": "JWT"
|
|
}
|
|
|
|
// Payload
|
|
{
|
|
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
|
"jti": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
|
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
"type": "refresh",
|
|
"iat": 1701792000,
|
|
"exp": 1702396800,
|
|
"iss": "trading.com"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Middleware de Autenticación
|
|
|
|
**Ubicación:** `apps/backend/src/core/middleware/auth.middleware.ts`
|
|
|
|
```typescript
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import { TokenService } from '@/modules/auth/services/token.service';
|
|
|
|
export interface AuthenticatedRequest extends Request {
|
|
user: TokenPayload;
|
|
}
|
|
|
|
export const authenticate = async (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
try {
|
|
const authHeader = req.headers.authorization;
|
|
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
throw new UnauthorizedError('Missing or invalid authorization header');
|
|
}
|
|
|
|
const token = authHeader.substring(7);
|
|
const tokenService = new TokenService();
|
|
const payload = await tokenService.verifyAccessToken(token);
|
|
|
|
(req as AuthenticatedRequest).user = payload;
|
|
next();
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
export const requireRole = (...roles: UserRole[]) => {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
const authReq = req as AuthenticatedRequest;
|
|
|
|
if (!roles.includes(authReq.user.role)) {
|
|
throw new ForbiddenError('Insufficient permissions');
|
|
}
|
|
|
|
next();
|
|
};
|
|
};
|
|
|
|
export const requirePermission = (...permissions: string[]) => {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
const authReq = req as AuthenticatedRequest;
|
|
|
|
const hasPermission = permissions.every(
|
|
perm => authReq.user.permissions.includes(perm) ||
|
|
authReq.user.permissions.includes('*')
|
|
);
|
|
|
|
if (!hasPermission) {
|
|
throw new ForbiddenError('Insufficient permissions');
|
|
}
|
|
|
|
next();
|
|
};
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Flujo de Refresh Token
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ Client │ │ Backend │ │ Redis │ │ PostgreSQL │
|
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
|
│ │ │ │
|
|
│ Access token │ │ │
|
|
│ expired │ │ │
|
|
│ │ │ │
|
|
│ POST /auth/refresh│ │ │
|
|
│ { refreshToken } │ │ │
|
|
│──────────────────▶│ │ │
|
|
│ │ │ │
|
|
│ │ Verify JWT │ │
|
|
│ │ signature │ │
|
|
│ │ │ │
|
|
│ │ Check if revoked │ │
|
|
│ │──────────────────▶│ │
|
|
│ │◀──────────────────│ │
|
|
│ │ │ │
|
|
│ │ Get user data │ │
|
|
│ │───────────────────────────────────────▶
|
|
│ │◀───────────────────────────────────────
|
|
│ │ │ │
|
|
│ │ Revoke old token │ │
|
|
│ │──────────────────▶│ │
|
|
│ │ │ │
|
|
│ │ Generate new pair │ │
|
|
│ │ │ │
|
|
│ │ Store new token │ │
|
|
│ │──────────────────▶│ │
|
|
│ │ │ │
|
|
│◀──────────────────│ │ │
|
|
│ { accessToken, │ │ │
|
|
│ refreshToken } │ │ │
|
|
```
|
|
|
|
---
|
|
|
|
## Almacenamiento de Tokens
|
|
|
|
### Redis (Token Blacklist & Sessions)
|
|
|
|
```typescript
|
|
// Almacenar refresh token
|
|
await redis.setEx(
|
|
`refresh:${userId}:${tokenId}`,
|
|
7 * 24 * 60 * 60, // 7 días
|
|
JSON.stringify({
|
|
sessionId,
|
|
createdAt: Date.now(),
|
|
lastUsed: Date.now(),
|
|
})
|
|
);
|
|
|
|
// Revocar token
|
|
await redis.del(`refresh:${userId}:${tokenId}`);
|
|
|
|
// Revocar todas las sesiones de un usuario
|
|
const keys = await redis.keys(`refresh:${userId}:*`);
|
|
if (keys.length > 0) {
|
|
await redis.del(keys);
|
|
}
|
|
```
|
|
|
|
### Estructura en Redis
|
|
|
|
```
|
|
refresh:{userId}:{tokenId} => {
|
|
"sessionId": "uuid",
|
|
"createdAt": 1701792000000,
|
|
"lastUsed": 1701792000000
|
|
}
|
|
|
|
TTL: 7 días
|
|
```
|
|
|
|
---
|
|
|
|
## Configuración
|
|
|
|
### Variables de Entorno
|
|
|
|
```env
|
|
# JWT Secrets (RS256 - usar keys generadas)
|
|
JWT_ACCESS_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----...
|
|
JWT_ACCESS_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----...
|
|
JWT_REFRESH_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----...
|
|
JWT_REFRESH_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----...
|
|
|
|
# Token TTLs
|
|
JWT_ACCESS_EXPIRY=15m
|
|
JWT_REFRESH_EXPIRY=7d
|
|
|
|
# Issuer/Audience
|
|
JWT_ISSUER=trading.com
|
|
JWT_AUDIENCE=trading.com
|
|
```
|
|
|
|
### Generación de Keys RS256
|
|
|
|
```bash
|
|
# Generar key privada
|
|
openssl genrsa -out private.key 2048
|
|
|
|
# Extraer key pública
|
|
openssl rsa -in private.key -pubout -out public.key
|
|
```
|
|
|
|
---
|
|
|
|
## Manejo en Frontend
|
|
|
|
### Interceptor de Axios
|
|
|
|
```typescript
|
|
// apps/frontend/src/services/api.ts
|
|
import axios from 'axios';
|
|
import { useAuthStore } from '@/stores/auth.store';
|
|
|
|
const api = axios.create({
|
|
baseURL: import.meta.env.VITE_API_URL,
|
|
});
|
|
|
|
// Request interceptor - agregar token
|
|
api.interceptors.request.use((config) => {
|
|
const { accessToken } = useAuthStore.getState();
|
|
|
|
if (accessToken) {
|
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
return config;
|
|
});
|
|
|
|
// Response interceptor - refresh on 401
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config;
|
|
|
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
originalRequest._retry = true;
|
|
|
|
try {
|
|
const { refreshToken, setTokens, logout } = useAuthStore.getState();
|
|
|
|
if (!refreshToken) {
|
|
logout();
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
const response = await axios.post(
|
|
`${import.meta.env.VITE_API_URL}/auth/refresh`,
|
|
{ refreshToken }
|
|
);
|
|
|
|
setTokens(response.data);
|
|
|
|
originalRequest.headers.Authorization =
|
|
`Bearer ${response.data.accessToken}`;
|
|
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
useAuthStore.getState().logout();
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export default api;
|
|
```
|
|
|
|
### Auth Store
|
|
|
|
```typescript
|
|
// apps/frontend/src/stores/auth.store.ts
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
interface AuthState {
|
|
user: User | null;
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
isAuthenticated: boolean;
|
|
|
|
setUser: (user: User) => void;
|
|
setTokens: (tokens: TokenPair) => void;
|
|
logout: () => void;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set) => ({
|
|
user: null,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
isAuthenticated: false,
|
|
|
|
setUser: (user) => set({ user, isAuthenticated: true }),
|
|
|
|
setTokens: ({ accessToken, refreshToken }) =>
|
|
set({ accessToken, refreshToken }),
|
|
|
|
logout: () => set({
|
|
user: null,
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
isAuthenticated: false,
|
|
}),
|
|
}),
|
|
{
|
|
name: 'auth-storage',
|
|
partialize: (state) => ({
|
|
refreshToken: state.refreshToken,
|
|
// No persistir accessToken (memoria)
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Seguridad
|
|
|
|
### Protección contra Token Theft
|
|
|
|
1. **Short-lived access tokens** (15 min)
|
|
2. **Refresh token rotation** en cada uso
|
|
3. **Token binding** a device/IP (opcional)
|
|
4. **Revocación inmediata** posible
|
|
|
|
### Detección de Token Reuse
|
|
|
|
```typescript
|
|
async rotateRefreshToken(oldToken: string): Promise<TokenPair> {
|
|
const payload = await this.verifyRefreshToken(oldToken);
|
|
|
|
// Verificar si el token ya fue usado
|
|
const tokenData = await redis.get(`refresh:${payload.sub}:${payload.jti}`);
|
|
|
|
if (!tokenData) {
|
|
// Token ya no existe = fue rotado o revocado
|
|
// Posible robo de token
|
|
|
|
// Revocar TODAS las sesiones del usuario
|
|
await this.revokeAllUserSessions(payload.sub);
|
|
|
|
// Notificar al usuario
|
|
await this.notifySecurityAlert(payload.sub, 'token_reuse_detected');
|
|
|
|
// Log de seguridad
|
|
await this.securityLog.log({
|
|
event: 'TOKEN_REUSE_DETECTED',
|
|
userId: payload.sub,
|
|
tokenId: payload.jti,
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
throw new UnauthorizedError('Security alert: Token reuse detected');
|
|
}
|
|
|
|
// Proceder con rotación normal...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
```typescript
|
|
describe('TokenService', () => {
|
|
describe('generateTokens', () => {
|
|
it('should generate valid access and refresh tokens', async () => {
|
|
const user = createMockUser();
|
|
const tokens = await tokenService.generateTokens(user);
|
|
|
|
expect(tokens.accessToken).toBeDefined();
|
|
expect(tokens.refreshToken).toBeDefined();
|
|
expect(tokens.expiresIn).toBe(900);
|
|
expect(tokens.tokenType).toBe('Bearer');
|
|
});
|
|
});
|
|
|
|
describe('verifyAccessToken', () => {
|
|
it('should verify valid token', async () => {
|
|
const user = createMockUser();
|
|
const { accessToken } = await tokenService.generateTokens(user);
|
|
|
|
const payload = await tokenService.verifyAccessToken(accessToken);
|
|
|
|
expect(payload.sub).toBe(user.id);
|
|
expect(payload.email).toBe(user.email);
|
|
expect(payload.role).toBe(user.role);
|
|
});
|
|
|
|
it('should reject expired token', async () => {
|
|
const expiredToken = jwt.sign(
|
|
{ sub: 'test' },
|
|
process.env.JWT_ACCESS_SECRET,
|
|
{ expiresIn: '-1h' }
|
|
);
|
|
|
|
await expect(tokenService.verifyAccessToken(expiredToken))
|
|
.rejects.toThrow('Token expired');
|
|
});
|
|
});
|
|
|
|
describe('rotateRefreshToken', () => {
|
|
it('should generate new tokens and revoke old', async () => {
|
|
const user = createMockUser();
|
|
const { refreshToken } = await tokenService.generateTokens(user);
|
|
|
|
const newTokens = await tokenService.rotateRefreshToken(refreshToken);
|
|
|
|
expect(newTokens.refreshToken).not.toBe(refreshToken);
|
|
|
|
// Old token should be revoked
|
|
await expect(tokenService.rotateRefreshToken(refreshToken))
|
|
.rejects.toThrow();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519)
|
|
- [JWT Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-bcp)
|
|
- [OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009)
|