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>
20 KiB
20 KiB
| id | title | type | status | rf_parent | epic | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|
| ET-AUTH-002 | JWT Tokens Implementation | Specification | Done | RF-AUTH-002 | OQI-001 | 1.0 | 2025-12-05 | 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 Requerimiento: RF-AUTH-002, RF-AUTH-005
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
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
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
// 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
// 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
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)
// 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
# 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
# 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
// 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
// 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
- Short-lived access tokens (15 min)
- Refresh token rotation en cada uso
- Token binding a device/IP (opcional)
- Revocación inmediata posible
Detección de Token Reuse
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
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();
});
});
});