# ADR-006: Seguridad y Autenticación **Estado:** Aceptado **Fecha:** 2025-12-06 **Decisores:** Tech Lead, Arquitecto, Security Engineer **Relacionado:** ADR-001, ADR-005 --- ## Contexto OrbiQuant IA maneja datos financieros críticos y transacciones reales. Necesitamos: 1. **Authentication**: Identificar usuarios de forma segura 2. **Authorization**: Controlar acceso a recursos (portfolios, predictions, pagos) 3. **Data Protection**: Encriptar datos sensibles (passwords, API keys, PII) 4. **Attack Prevention**: Proteger contra brute force, XSS, CSRF, SQL injection 5. **Compliance**: Preparar para GDPR, PCI-DSS (pagos con Stripe) 6. **Audit Trail**: Logs de acciones críticas (trades, transfers, config changes) Requisitos de Seguridad: - Zero-trust: Never trust, always verify - Defense in depth: Múltiples capas de seguridad - Fail securely: Errores deben denegar acceso por defecto --- ## Decisión ### Authentication Strategy **JWT (JSON Web Tokens) + Refresh Tokens** #### Access Token (Short-lived) ```typescript { "sub": "user_123abc", // User ID "email": "user@example.com", "role": "premium", // User tier "iat": 1701878400, // Issued at "exp": 1701882000 // Expires in 1 hour } ``` #### Refresh Token (Long-lived) - Stored in **httpOnly cookie** (no JavaScript access) - TTL: 7 days - Rotated on each use (automatic refresh) - Revocable via Redis blacklist ### Password Security **bcrypt** with cost factor 12 ```typescript // apps/backend/src/services/auth.service.ts import bcrypt from 'bcryptjs'; const SALT_ROUNDS = 12; // 2^12 iterations (~250ms) async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } async function verifyPassword( password: string, hash: string ): Promise { return bcrypt.compare(password, hash); } ``` ### Password Requirements ```typescript const PASSWORD_POLICY = { minLength: 12, requireUppercase: true, requireLowercase: true, requireNumber: true, requireSpecial: true, maxAge: 90 * 24 * 60 * 60 * 1000, // 90 days preventReuse: 5, // Last 5 passwords }; ``` ### Multi-Factor Authentication (MFA) **TOTP (Time-based One-Time Password)** using **speakeasy** ```typescript // apps/backend/src/services/mfa.service.ts import speakeasy from 'speakeasy'; import QRCode from 'qrcode'; async function generateMFASecret(userId: string) { const secret = speakeasy.generateSecret({ name: `OrbiQuant IA (${userId})`, issuer: 'OrbiQuant' }); const qrCode = await QRCode.toDataURL(secret.otpauth_url); // Store secret encrypted in DB await db.user.update({ where: { id: userId }, data: { mfaSecret: encrypt(secret.base32) } }); return { secret: secret.base32, qrCode }; } function verifyMFAToken(secret: string, token: string): boolean { return speakeasy.totp.verify({ secret, encoding: 'base32', token, window: 2 // Allow ±2 time steps (60 seconds tolerance) }); } ``` ### Rate Limiting **Redis-based rate limiting** (ADR-005) ```typescript // apps/backend/src/middleware/rateLimit.ts const RATE_LIMITS = { // Auth endpoints (aggressive) login: { max: 5, window: 60 * 15 }, // 5 attempts per 15 min register: { max: 3, window: 60 * 60 }, // 3 per hour resetPassword: { max: 3, window: 60 * 60 }, // API endpoints (generous) api: { max: 100, window: 60 }, // 100 per minute apiPremium: { max: 500, window: 60 }, // Premium users // ML predictions (moderate) mlPredictions: { max: 20, window: 60 }, // 20 per minute }; async function rateLimit( key: string, config: RateLimitConfig ): Promise { const current = await redis.incr(`ratelimit:${key}`); if (current === 1) { await redis.expire(`ratelimit:${key}`, config.window); } return current <= config.max; } ``` ### CORS Configuration **Strict CORS** for API ```typescript // apps/backend/src/config/cors.ts import cors from 'cors'; const ALLOWED_ORIGINS = process.env.NODE_ENV === 'production' ? ['https://app.orbiquant.com', 'https://orbiquant.com'] : ['http://localhost:5173', 'http://localhost:3000']; export const corsOptions: cors.CorsOptions = { origin: (origin, callback) => { if (!origin || ALLOWED_ORIGINS.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, // Allow cookies methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 86400, // 24 hours }; ``` ### SQL Injection Prevention **Parameterized Queries** (Prisma ORM) ```typescript // ✅ SAFE - Prisma uses parameterized queries const user = await db.user.findUnique({ where: { email: userInput } }); // ❌ NEVER DO THIS const users = await db.$queryRaw` SELECT * FROM users WHERE email = ${userInput} `; // Vulnerable to SQL injection ``` ### XSS Prevention ```typescript // apps/backend/src/middleware/security.ts import helmet from 'helmet'; import { sanitize } from 'dompurify'; // Helmet middleware (sets security headers) app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'"], // Avoid 'unsafe-inline' in prod styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'", "https://api.orbiquant.com"], }, }, hsts: { maxAge: 31536000, // 1 year includeSubDomains: true, preload: true, }, })); // Sanitize user input function sanitizeInput(input: string): string { return sanitize(input, { ALLOWED_TAGS: [], // Strip all HTML ALLOWED_ATTR: [], }); } ``` ### CSRF Protection **Double Submit Cookie Pattern** ```typescript // apps/backend/src/middleware/csrf.ts import { randomBytes } from 'crypto'; function generateCSRFToken(): string { return randomBytes(32).toString('hex'); } function csrfProtection(req: Request, res: Response, next: NextFunction) { // Skip for GET, HEAD, OPTIONS if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { return next(); } const tokenFromHeader = req.headers['x-csrf-token']; const tokenFromCookie = req.cookies['csrf-token']; if (!tokenFromHeader || tokenFromHeader !== tokenFromCookie) { return res.status(403).json({ error: 'Invalid CSRF token' }); } next(); } ``` ### Encryption **AES-256-GCM** for sensitive data at rest ```typescript // apps/backend/src/utils/crypto.ts import crypto from 'crypto'; const ALGORITHM = 'aes-256-gcm'; const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); // 32 bytes export function encrypt(plaintext: string): string { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv); let encrypted = cipher.update(plaintext, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); // Return: iv:authTag:ciphertext return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; } export function decrypt(ciphertext: string): string { const [ivHex, authTagHex, encrypted] = ciphertext.split(':'); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } ``` ### Secure Headers ```typescript // Set via Helmet app.use(helmet({ frameguard: { action: 'deny' }, // X-Frame-Options: DENY contentSecurityPolicy: true, // CSP hsts: { maxAge: 31536000 }, // Strict-Transport-Security noSniff: true, // X-Content-Type-Options: nosniff xssFilter: true, // X-XSS-Protection referrerPolicy: { policy: 'strict-origin' }, // Referrer-Policy })); ``` ### Audit Logging ```typescript // apps/backend/src/middleware/audit.ts import { logger } from './logger'; const AUDITABLE_ACTIONS = [ 'LOGIN', 'LOGOUT', 'REGISTER', 'PASSWORD_CHANGE', 'MFA_ENABLE', 'MFA_DISABLE', 'TRADE_EXECUTED', 'FUND_TRANSFER', 'API_KEY_CREATED', 'SUBSCRIPTION_CHANGED', 'PAYMENT_PROCESSED', ]; function auditLog(action: string, userId: string, metadata: object) { if (AUDITABLE_ACTIONS.includes(action)) { logger.info('audit_log', { action, userId, timestamp: new Date().toISOString(), ip: metadata.ip, userAgent: metadata.userAgent, metadata, }); } } // Example usage auditLog('TRADE_EXECUTED', user.id, { symbol: 'AAPL', quantity: 10, price: 150.25, ip: req.ip, userAgent: req.headers['user-agent'], }); ``` --- ## Consecuencias ### Positivas 1. **Zero Trust**: JWT stateless, cada request validado 2. **MFA Protection**: TOTP previene account takeover incluso con password leak 3. **Rate Limiting**: Brute force prácticamente imposible (5 attempts / 15 min) 4. **Defense in Depth**: Múltiples capas (bcrypt, JWT, MFA, rate limit, CORS) 5. **Audit Trail**: Compliance-ready logs de acciones críticas 6. **Encryption**: Datos sensibles encriptados at rest (AES-256-GCM) 7. **Secure Headers**: Helmet previene XSS, clickjacking, MIME sniffing ### Negativas 1. **UX Friction**: MFA agrega paso extra en login 2. **Complexity**: Refresh token rotation es complejo de implementar 3. **Performance**: bcrypt (250ms) y MFA verificación agregan latencia 4. **Token Management**: JWT blacklist requiere Redis storage 5. **Mobile Challenges**: httpOnly cookies no funcionan bien en mobile apps ### Riesgos y Mitigaciones | Riesgo | Mitigación | |--------|-----------| | JWT secret leak | Rotate secrets monthly, use strong random (32+ bytes) | | Brute force MFA | Rate limit: 5 attempts → lock account 1 hour | | Session fixation | Regenerate session ID on login | | MITM attacks | Enforce HTTPS only, HSTS header | | Replay attacks | Short JWT TTL (1 hour), nonce for critical actions | | Account enumeration | Generic error messages ("Invalid credentials") | --- ## Alternativas Consideradas ### 1. Session-Based Auth (Cookies) - **Pros**: Simple, server-side revocation fácil - **Contras**: Stateful, no funciona bien con mobile apps - **Decisión**: ❌ Descartada - JWT es más flexible para multi-platform ### 2. OAuth 2.0 + Third-Party (Google, Facebook) - **Pros**: No password management, MFA delegado - **Contras**: Vendor dependency, no apropiado para finanzas - **Decisión**: ⚠️ Complementario - Ofrecer como opción adicional ### 3. Argon2 para Password Hashing - **Pros**: Más seguro que bcrypt, resistente a GPU attacks - **Contras**: Menos maduro en Node.js ecosystem - **Decisión**: ❌ Descartada - bcrypt es suficientemente seguro ### 4. SMS-based MFA - **Pros**: User-friendly, no app required - **Contras**: SIM swapping attacks, costo por SMS - **Decisión**: ❌ Descartada - TOTP es más seguro y gratis ### 5. WebAuthn / FIDO2 - **Pros**: Phishing-resistant, passwordless future - **Contras**: Browser support limitado, complejo de implementar - **Decisión**: ❌ Pospuesta - Evaluar post-MVP ### 6. No Rate Limiting - **Pros**: Menos complejidad - **Contras**: Vulnerable a brute force y DDoS - **Decisión**: ❌ Descartada - Rate limiting es crítico --- ## Security Checklist ### Development - [ ] No hardcoded secrets (use .env) - [ ] All inputs sanitized - [ ] SQL injection tests - [ ] XSS tests - [ ] CSRF protection enabled - [ ] Rate limiting configured - [ ] Error messages don't leak info ### Deployment - [ ] HTTPS enforced (no HTTP) - [ ] Secrets in environment variables - [ ] Database credentials rotated - [ ] JWT secret rotated monthly - [ ] Helmet middleware enabled - [ ] CORS configured strictly - [ ] Security headers validated ### Monitoring - [ ] Failed login attempts tracked - [ ] Rate limit violations alerted - [ ] Suspicious activity flagged - [ ] Audit logs reviewed weekly --- ## Compliance Roadmap ### GDPR (EU) - [ ] User consent tracking - [ ] Right to deletion (account deletion) - [ ] Data export functionality - [ ] Privacy policy - [ ] Cookie consent banner ### PCI-DSS (Payments) - [ ] Stripe handles card data (we never store cards) - [ ] Encryption at rest and in transit - [ ] Access controls - [ ] Audit logging --- ## Referencias - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - [JWT Best Practices](https://datatracker.ietf.org/doc/html/rfc8725) - [bcrypt Security](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - [Helmet.js Documentation](https://helmetjs.github.io/) - [TOTP RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)