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-005 | Security Implementation | Specification | Done | RF-AUTH-005 | OQI-001 | 1.0 | 2025-12-05 | 2026-01-04 |
ET-AUTH-005: Especificación Técnica - Seguridad
Version: 1.0.0 Fecha: 2025-12-05 Estado: ✅ Implementado Épica: OQI-001
Resumen
Esta especificación detalla las medidas de seguridad implementadas en el sistema de autenticación de Trading Platform.
Capas de Seguridad
┌─────────────────────────────────────────────────────────────────────────┐
│ SECURITY LAYERS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ LAYER 1: PERIMETER │ │
│ │ • Rate Limiting │ │
│ │ • WAF (Web Application Firewall) │ │
│ │ • DDoS Protection (Cloudflare) │ │
│ │ • IP Blacklisting │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ LAYER 2: TRANSPORT │ │
│ │ • TLS 1.3 only │ │
│ │ • HSTS (HTTP Strict Transport Security) │ │
│ │ • Certificate Pinning (mobile) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ LAYER 3: APPLICATION │ │
│ │ • JWT RS256 Tokens │ │
│ │ • CSRF Protection │ │
│ │ • Input Validation (Zod) │ │
│ │ • Output Encoding │ │
│ │ • Security Headers │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ LAYER 4: DATA │ │
│ │ • Password Hashing (bcrypt) │ │
│ │ • Token Encryption (AES-256-GCM) │ │
│ │ • PII Encryption │ │
│ │ • Database Encryption at Rest │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Rate Limiting
Configuración
// apps/backend/src/core/middleware/rate-limiter.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Rate limiter general
export const generalRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 100, // 100 requests por minuto
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
prefix: 'rl:general:',
client: redisClient,
}),
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Demasiadas solicitudes, intenta más tarde',
},
},
});
// Rate limiter estricto para auth
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 5, // 5 intentos
standardHeaders: true,
store: new RedisStore({
prefix: 'rl:auth:',
client: redisClient,
}),
keyGenerator: (req) => {
// Por IP + email
return `${req.ip}:${req.body?.email || 'unknown'}`;
},
handler: (req, res) => {
// Log intento sospechoso
securityLogger.warn('Rate limit exceeded', {
ip: req.ip,
email: req.body?.email,
path: req.path,
});
res.status(429).json({
success: false,
error: {
code: 'TOO_MANY_ATTEMPTS',
message: 'Demasiados intentos. Espera 15 minutos.',
retryAfter: 900,
},
});
},
});
// Rate limiter para OTP
export const otpRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
keyGenerator: (req) => req.body?.phone || req.ip,
store: new RedisStore({
prefix: 'rl:otp:',
client: redisClient,
}),
});
Límites por Endpoint
| Endpoint | Límite | Ventana | Key |
|---|---|---|---|
/auth/login |
5 | 15 min | IP + email |
/auth/register |
3 | 1 hora | IP |
/auth/phone/send |
3 | 15 min | phone |
/auth/forgot-password |
3 | 1 hora | IP + email |
/auth/2fa/verify |
5 | 15 min | IP + userId |
| General API | 100 | 1 min | IP |
Password Hashing
Implementación
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12; // ~300ms en hardware moderno
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Política de Contraseñas
import { z } from 'zod';
export const passwordSchema = z
.string()
.min(8, 'Mínimo 8 caracteres')
.max(128, 'Máximo 128 caracteres')
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
.regex(/[a-z]/, 'Debe contener al menos una minúscula')
.regex(/[0-9]/, 'Debe contener al menos un número')
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'Debe contener al menos un carácter especial');
// Validación adicional contra lista negra
const COMMON_PASSWORDS = new Set([
'password123',
'12345678',
'qwerty123',
// ... más passwords comunes
]);
export function isPasswordAllowed(password: string, email: string): boolean {
const lowerPassword = password.toLowerCase();
// No puede ser igual al email
if (lowerPassword === email.toLowerCase()) {
return false;
}
// No puede estar en lista negra
if (COMMON_PASSWORDS.has(lowerPassword)) {
return false;
}
// No puede contener partes del email
const emailParts = email.split('@')[0].toLowerCase();
if (lowerPassword.includes(emailParts) && emailParts.length > 3) {
return false;
}
return true;
}
Token Encryption
Encriptación de OAuth Tokens
import crypto from 'crypto';
const ENCRYPTION_KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY!, 'hex');
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
export function encryptToken(token: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
let encrypted = cipher.update(token, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export function decryptToken(encryptedToken: string): string {
const [ivHex, authTagHex, encrypted] = encryptedToken.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
Encriptación de 2FA Secret
export function encrypt2FASecret(secret: string): string {
// Usar la misma función de encriptación
return encryptToken(secret);
}
export function decrypt2FASecret(encrypted: string): string {
return decryptToken(encrypted);
}
Security Headers
Configuración Helmet
import helmet from 'helmet';
export const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Para OAuth popups
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.trading.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'self'", 'https://accounts.google.com'], // OAuth frames
},
},
crossOriginEmbedderPolicy: false, // Para OAuth
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
crossOriginResourcePolicy: { policy: 'cross-origin' },
dnsPrefetchControl: { allow: true },
frameguard: { action: 'sameorigin' },
hsts: {
maxAge: 31536000, // 1 año
includeSubDomains: true,
preload: true,
},
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
xssFilter: true,
});
Headers Personalizados
export const customSecurityHeaders = (req, res, next) => {
// Prevenir clickjacking adicional
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// Cache control para datos sensibles
if (req.path.startsWith('/auth/')) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
}
// Permissions policy
res.setHeader(
'Permissions-Policy',
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
);
next();
};
Input Validation
Sanitización
import { z } from 'zod';
import sanitizeHtml from 'sanitize-html';
// Sanitizar strings
const sanitizedString = z.string().transform((val) =>
sanitizeHtml(val, {
allowedTags: [], // Sin HTML
allowedAttributes: {},
})
);
// Email validation
const emailSchema = z
.string()
.email('Email inválido')
.max(255)
.toLowerCase()
.trim();
// Phone validation (E.164)
const phoneSchema = z
.string()
.regex(/^\+[1-9]\d{1,14}$/, 'Formato de teléfono inválido');
// Prevenir injection
const safeIdSchema = z
.string()
.uuid('ID inválido');
Validación de Request Body
// Middleware de validación
export const validate = (schema: z.ZodSchema) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
req.body = await schema.parseAsync(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Datos inválidos',
details: error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
},
});
}
next(error);
}
};
};
Account Lockout
Implementación
const LOCKOUT_THRESHOLD = 5;
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutos
export async function handleFailedLogin(userId: string): Promise<void> {
const user = await db.query(
'SELECT failed_login_attempts, locked_until FROM users WHERE id = $1',
[userId]
);
const attempts = user.rows[0].failed_login_attempts + 1;
if (attempts >= LOCKOUT_THRESHOLD) {
// Bloquear cuenta
await db.query(
`UPDATE users
SET failed_login_attempts = $1,
locked_until = NOW() + INTERVAL '${LOCKOUT_DURATION_MS / 1000} seconds'
WHERE id = $2`,
[attempts, userId]
);
// Notificar al usuario
await sendSecurityAlert(userId, 'account_locked');
// Log de seguridad
securityLogger.warn('Account locked due to failed attempts', {
userId,
attempts,
});
} else {
await db.query(
'UPDATE users SET failed_login_attempts = $1 WHERE id = $2',
[attempts, userId]
);
}
}
export async function resetFailedAttempts(userId: string): Promise<void> {
await db.query(
'UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1',
[userId]
);
}
export async function isAccountLocked(userId: string): Promise<boolean> {
const result = await db.query(
'SELECT locked_until FROM users WHERE id = $1',
[userId]
);
const lockedUntil = result.rows[0]?.locked_until;
return lockedUntil && new Date(lockedUntil) > new Date();
}
Progresión de Bloqueo
const LOCKOUT_PROGRESSION = [
{ threshold: 5, duration: 15 * 60 * 1000 }, // 5 intentos: 15 min
{ threshold: 10, duration: 60 * 60 * 1000 }, // 10 intentos: 1 hora
{ threshold: 15, duration: 24 * 60 * 60 * 1000 }, // 15 intentos: 24 horas
];
function getLockoutDuration(attempts: number): number {
for (let i = LOCKOUT_PROGRESSION.length - 1; i >= 0; i--) {
if (attempts >= LOCKOUT_PROGRESSION[i].threshold) {
return LOCKOUT_PROGRESSION[i].duration;
}
}
return 0;
}
Security Logging
Logger Configuration
import winston from 'winston';
export const securityLogger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
defaultMeta: { service: 'auth-security' },
transports: [
new winston.transports.File({
filename: 'logs/security.log',
level: 'warn',
}),
new winston.transports.File({
filename: 'logs/security-audit.log',
level: 'info',
}),
],
});
// Eventos a logear
export enum SecurityEvent {
LOGIN_SUCCESS = 'LOGIN_SUCCESS',
LOGIN_FAILED = 'LOGIN_FAILED',
LOGOUT = 'LOGOUT',
PASSWORD_CHANGED = 'PASSWORD_CHANGED',
TWO_FA_ENABLED = 'TWO_FA_ENABLED',
TWO_FA_DISABLED = 'TWO_FA_DISABLED',
ACCOUNT_LOCKED = 'ACCOUNT_LOCKED',
TOKEN_REUSE_DETECTED = 'TOKEN_REUSE_DETECTED',
SUSPICIOUS_ACTIVITY = 'SUSPICIOUS_ACTIVITY',
NEW_DEVICE_LOGIN = 'NEW_DEVICE_LOGIN',
NEW_LOCATION_LOGIN = 'NEW_LOCATION_LOGIN',
}
export function logSecurityEvent(
event: SecurityEvent,
data: {
userId?: string;
ip?: string;
userAgent?: string;
details?: Record<string, unknown>;
}
): void {
securityLogger.info({
event,
...data,
timestamp: new Date().toISOString(),
});
}
CSRF Protection
Implementación
import { doubleCsrf } from 'csrf-csrf';
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET!,
cookieName: '__Host-csrf',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: true,
path: '/',
},
getTokenFromRequest: (req) => req.headers['x-csrf-token'] as string,
});
// Aplicar a rutas que modifican datos
app.use('/api/v1/auth', doubleCsrfProtection);
// Endpoint para obtener token
app.get('/api/v1/csrf-token', (req, res) => {
res.json({ token: generateToken(req, res) });
});
Anomaly Detection
Detección de Acceso Sospechoso
interface LoginContext {
userId: string;
ip: string;
userAgent: string;
location: LocationInfo;
timestamp: Date;
}
export async function detectAnomalies(
context: LoginContext
): Promise<string[]> {
const anomalies: string[] = [];
// Obtener historial de logins
const history = await getLoginHistory(context.userId, 30); // últimos 30 días
// 1. Nueva ubicación (país)
const knownCountries = new Set(history.map((h) => h.location?.countryCode));
if (!knownCountries.has(context.location.countryCode)) {
anomalies.push('new_country');
}
// 2. Nuevo dispositivo
const knownDevices = new Set(history.map((h) => h.deviceFingerprint));
const currentFingerprint = generateFingerprint(context.userAgent);
if (!knownDevices.has(currentFingerprint)) {
anomalies.push('new_device');
}
// 3. IP en blacklist
if (await isIpBlacklisted(context.ip)) {
anomalies.push('blacklisted_ip');
}
// 4. Velocidad imposible (viaje)
const lastLogin = history[0];
if (lastLogin) {
const travelSpeed = calculateTravelSpeed(
lastLogin.location,
context.location,
lastLogin.timestamp,
context.timestamp
);
if (travelSpeed > 1000) {
// > 1000 km/h
anomalies.push('impossible_travel');
}
}
// 5. Horario inusual
const hour = context.timestamp.getHours();
const usualHours = calculateUsualHours(history);
if (!usualHours.includes(hour)) {
anomalies.push('unusual_time');
}
return anomalies;
}
export async function handleAnomalies(
userId: string,
anomalies: string[],
context: LoginContext
): Promise<void> {
if (anomalies.includes('blacklisted_ip')) {
// Bloquear inmediatamente
throw new ForbiddenError('Access denied');
}
if (anomalies.includes('impossible_travel')) {
// Posible cuenta comprometida
await revokeAllSessions(userId);
await sendSecurityAlert(userId, 'impossible_travel_detected');
throw new UnauthorizedError('Security check required');
}
if (anomalies.includes('new_country') || anomalies.includes('new_device')) {
// Notificar pero permitir
await sendNewLoginNotification(userId, context);
}
// Log todas las anomalías
logSecurityEvent(SecurityEvent.SUSPICIOUS_ACTIVITY, {
userId,
ip: context.ip,
details: { anomalies },
});
}
Secure Token Generation
import crypto from 'crypto';
// Token de verificación de email (64 chars hex)
export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// OTP numérico (6 dígitos)
export function generateOTP(): string {
return crypto.randomInt(100000, 999999).toString();
}
// Backup codes (8 chars alphanumeric)
export function generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
crypto.randomBytes(4).toString('hex').toUpperCase()
);
}
// State token para OAuth
export function generateStateToken(): string {
return crypto.randomBytes(16).toString('base64url');
}
// Session ID
export function generateSessionId(): string {
return crypto.randomUUID();
}