trading-platform/docs/97-adr/ADR-007-security.md

12 KiB

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)

{
  "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

// 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<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Password Requirements

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

// 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)

// 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<boolean> {
  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

// 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)

// ✅ 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

// 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

// 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

// 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

// 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

// 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