← back to Watches

security/captcha-handler.js

250 lines

/**
 * CAPTCHA HANDLER
 * Protection against automated attacks and bots
 *
 * Features:
 * - reCAPTCHA v3 integration
 * - Invisible verification
 * - Score-based validation
 * - Fallback to v2 for low scores
 */

import crypto from 'crypto';

class CaptchaHandler {
  constructor() {
    this.enabled = process.env.RECAPTCHA_ENABLED === 'true';
    this.secretKey = process.env.RECAPTCHA_SECRET_KEY || '';
    this.siteKey = process.env.RECAPTCHA_SITE_KEY || '';
    this.minScore = parseFloat(process.env.RECAPTCHA_MIN_SCORE) || 0.5;
    this.verifyEndpoint = 'https://www.google.com/recaptcha/api/siteverify';

    // Simple CAPTCHA for when reCAPTCHA is not configured
    this.simpleCaptchas = new Map();
  }

  /**
   * Verify reCAPTCHA token
   */
  async verifyRecaptcha(token, action = 'submit') {
    if (!this.enabled || !this.secretKey) {
      console.warn('reCAPTCHA not configured, skipping verification');
      return { success: true, score: 1.0, bypass: true };
    }

    try {
      const response = await fetch(this.verifyEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: `secret=${this.secretKey}&response=${token}`
      });

      const data = await response.json();

      if (!data.success) {
        return {
          success: false,
          error: 'CAPTCHA verification failed',
          errorCodes: data['error-codes']
        };
      }

      // Check score (v3 only)
      if (data.score !== undefined && data.score < this.minScore) {
        return {
          success: false,
          error: 'CAPTCHA score too low',
          score: data.score,
          requiresV2: true
        };
      }

      return {
        success: true,
        score: data.score || 1.0,
        action: data.action,
        hostname: data.hostname
      };
    } catch (error) {
      console.error('reCAPTCHA verification error:', error);
      return {
        success: false,
        error: 'CAPTCHA verification service unavailable'
      };
    }
  }

  /**
   * Generate simple math CAPTCHA (fallback)
   */
  generateSimpleCaptcha() {
    const num1 = Math.floor(Math.random() * 10) + 1;
    const num2 = Math.floor(Math.random() * 10) + 1;
    const operations = ['+', '-', '*'];
    const operation = operations[Math.floor(Math.random() * operations.length)];

    let answer;
    let question;

    switch (operation) {
      case '+':
        answer = num1 + num2;
        question = `${num1} + ${num2}`;
        break;
      case '-':
        answer = Math.max(num1, num2) - Math.min(num1, num2);
        question = `${Math.max(num1, num2)} - ${Math.min(num1, num2)}`;
        break;
      case '*':
        answer = num1 * num2;
        question = `${num1} × ${num2}`;
        break;
    }

    const captchaId = crypto.randomUUID();
    const expiresAt = Date.now() + 300000; // 5 minutes

    this.simpleCaptchas.set(captchaId, {
      answer: answer.toString(),
      expiresAt: expiresAt
    });

    // Clean expired captchas
    this.cleanExpiredCaptchas();

    return {
      id: captchaId,
      question: `What is ${question}?`,
      expiresAt: expiresAt
    };
  }

  /**
   * Verify simple CAPTCHA answer
   */
  verifySimpleCaptcha(captchaId, answer) {
    const captcha = this.simpleCaptchas.get(captchaId);

    if (!captcha) {
      return {
        success: false,
        error: 'CAPTCHA not found or expired'
      };
    }

    if (Date.now() > captcha.expiresAt) {
      this.simpleCaptchas.delete(captchaId);
      return {
        success: false,
        error: 'CAPTCHA expired'
      };
    }

    const isValid = answer.toString().trim() === captcha.answer;

    // Delete after verification (one-time use)
    this.simpleCaptchas.delete(captchaId);

    return {
      success: isValid,
      error: isValid ? null : 'Incorrect answer'
    };
  }

  /**
   * Clean expired simple captchas
   */
  cleanExpiredCaptchas() {
    const now = Date.now();
    for (const [id, captcha] of this.simpleCaptchas.entries()) {
      if (now > captcha.expiresAt) {
        this.simpleCaptchas.delete(id);
      }
    }
  }

  /**
   * Get site key for client-side implementation
   */
  getSiteKey() {
    return this.siteKey;
  }

  /**
   * Check if CAPTCHA is enabled
   */
  isEnabled() {
    return this.enabled;
  }
}

/**
 * Express middleware for CAPTCHA verification
 */
export const captchaMiddleware = (captchaHandler, options = {}) => {
  const {
    requireRecaptcha = false,
    action = 'submit',
    tokenField = 'recaptchaToken',
    simpleCaptchaFields = { id: 'captchaId', answer: 'captchaAnswer' }
  } = options;

  return async (req, res, next) => {
    // Skip if CAPTCHA not enabled
    if (!captchaHandler.isEnabled() && !requireRecaptcha) {
      return next();
    }

    // Check for reCAPTCHA token
    const recaptchaToken = req.body[tokenField] || req.query[tokenField];

    if (recaptchaToken) {
      const result = await captchaHandler.verifyRecaptcha(recaptchaToken, action);

      if (!result.success) {
        return res.status(400).json({
          error: 'CAPTCHA verification failed',
          message: result.error,
          requiresV2: result.requiresV2 || false
        });
      }

      req.captchaVerified = true;
      req.captchaScore = result.score;
      return next();
    }

    // Check for simple CAPTCHA
    const simpleCaptchaId = req.body[simpleCaptchaFields.id];
    const simpleCaptchaAnswer = req.body[simpleCaptchaFields.answer];

    if (simpleCaptchaId && simpleCaptchaAnswer) {
      const result = captchaHandler.verifySimpleCaptcha(simpleCaptchaId, simpleCaptchaAnswer);

      if (!result.success) {
        return res.status(400).json({
          error: 'CAPTCHA verification failed',
          message: result.error
        });
      }

      req.captchaVerified = true;
      return next();
    }

    // No CAPTCHA provided
    if (requireRecaptcha || captchaHandler.isEnabled()) {
      return res.status(400).json({
        error: 'CAPTCHA required',
        message: 'Please complete the CAPTCHA verification'
      });
    }

    next();
  };
};

export default CaptchaHandler;