--- id: "ET-AUTH-005" title: "Security Implementation" type: "Specification" status: "Done" rf_parent: "RF-AUTH-005" epic: "OQI-001" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-AUTH-005: Especificación Técnica - Seguridad **Version:** 1.0.0 **Fecha:** 2025-12-05 **Estado:** ✅ Implementado **Épica:** [OQI-001](../_MAP.md) --- ## 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 ```typescript // 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 ```typescript import bcrypt from 'bcryptjs'; const SALT_ROUNDS = 12; // ~300ms en hardware moderno export async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } export async function verifyPassword( password: string, hash: string ): Promise { return bcrypt.compare(password, hash); } ``` ### Política de Contraseñas ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript // 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 ```typescript const LOCKOUT_THRESHOLD = 5; const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutos export async function handleFailedLogin(userId: string): Promise { 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 { await db.query( 'UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1', [userId] ); } export async function isAccountLocked(userId: string): Promise { 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 ```typescript 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 ```typescript 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; } ): void { securityLogger.info({ event, ...data, timestamp: new Date().toISOString(), }); } ``` --- ## CSRF Protection ### Implementación ```typescript 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 ```typescript interface LoginContext { userId: string; ip: string; userAgent: string; location: LocationInfo; timestamp: Date; } export async function detectAnomalies( context: LoginContext ): Promise { 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 { 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 ```typescript 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(); } ``` --- ## Referencias - [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) - [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - [NIST Digital Identity Guidelines](https://pages.nist.gov/800-63-3/)