# ============================================================================== # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: services/recaptcha.py # Description: reCAPTCHA v3 validation service with development mode mocking. # Provides score-based spam protection for form submissions. # ============================================================================== """ reCAPTCHA v3 validation service for Formeleon. This module provides: - reCAPTCHA token verification against Google's API - Development mode mocking with configurable scores - Score threshold configuration - Integration with Flask application context In development mode (DEV_MODE=true), the service returns mock scores without contacting Google's API, allowing local testing without API keys. Usage: from services.recaptcha import RecaptchaService # In a route handler: recaptcha_service = RecaptchaService(current_app._get_current_object()) is_valid, score = recaptcha_service.verify(token) if is_valid: # Process form submission pass else: # Reject as spam pass """ import requests import logging from typing import Optional, Tuple from flask import current_app logger = logging.getLogger(__name__) class RecaptchaService: """ Service for validating reCAPTCHA v3 tokens. This service handles: - Token verification with Google's reCAPTCHA API - Development mode mocking - Score threshold evaluation - Error handling and logging Attributes: app: Flask application instance secret_key: reCAPTCHA secret key from configuration site_key: reCAPTCHA site key from configuration threshold: Minimum score threshold for passing validation mock_score: Score to return in development mode verify_url: Google reCAPTCHA verification endpoint """ def __init__(self, app=None): """ Initialize the reCAPTCHA service. :param app: Flask application instance (optional, can use current_app) """ self.app = app or current_app._get_current_object() self.secret_key = self.app.config.get('RECAPTCHA_SECRET_KEY') self.site_key = self.app.config.get('RECAPTCHA_SITE_KEY') self.threshold = float(self.app.config.get('RECAPTCHA_THRESHOLD', 0.5)) self.mock_score = float(self.app.config.get('MOCK_RECAPTCHA_SCORE', 0.9)) self.verify_url = self.app.config.get( 'RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify' ) self.timeout = 5 # seconds for API requests def is_enabled(self) -> bool: """ Check if reCAPTCHA is enabled (keys configured). :return: True if both secret and site keys are configured """ return (self.secret_key is not None and self.site_key is not None) def is_dev_mode(self) -> bool: """ Check if application is in development mode. :return: True if DEV_MODE is enabled in configuration """ return self.app.config.get('DEV_MODE', False) def verify(self, token: str, remote_ip: Optional[str] = None) -> Tuple[bool, Optional[float]]: """ Verify a reCAPTCHA v3 token. :param token: reCAPTCHA response token from client :param remote_ip: Optional client IP address for verification :return: Tuple of (is_valid, score) where is_valid indicates if score >= threshold """ if not token: logger.warning('reCAPTCHA verification: No token provided') return False, None # Development mode: return mock score if self.is_dev_mode(): logger.debug(f'reCAPTCHA dev mode: Returning mock score {self.mock_score}') return self.mock_score >= self.threshold, self.mock_score # Production mode: verify with Google API if not self.is_enabled(): logger.warning('reCAPTCHA verification: Service not configured (missing keys)') # In production, if not configured, we might want to fail-open or fail-closed # For security, we fail-closed (treat as spam) return False, None return self._verify_with_google(token, remote_ip) def _verify_with_google(self, token: str, remote_ip: Optional[str] = None) -> Tuple[bool, Optional[float]]: """ Verify token with Google's reCAPTCHA API. :param token: reCAPTCHA response token :param remote_ip: Client IP address :return: Tuple of (is_valid, score) """ try: data = { 'secret': self.secret_key, 'response': token } if remote_ip: data['remoteip'] = remote_ip logger.debug(f'reCAPTCHA: Verifying token with Google API') response = requests.post( self.verify_url, data=data, timeout=self.timeout ) response.raise_for_status() result = response.json() if not result.get('success', False): error_codes = result.get('error-codes', []) logger.warning(f'reCAPTCHA verification failed: {error_codes}') return False, None score = float(result.get('score', 0)) action = result.get('action', 'unknown') logger.debug(f'reCAPTCHA: Score={score}, Action={action}') return score >= self.threshold, score except requests.exceptions.Timeout: logger.error('reCAPTCHA: Verification request timed out') # Fail-safe: allow submission on timeout to avoid blocking legitimate users return True, None except requests.exceptions.RequestException as e: logger.error(f'reCAPTCHA: Verification request failed: {e}') # Fail-safe: allow submission on network errors return True, None except (ValueError, TypeError, KeyError) as e: logger.error(f'reCAPTCHA: Invalid response format: {e}') return False, None def get_site_key(self) -> str: """ Get the reCAPTCHA site key for client-side rendering. :return: Site key string """ return self.site_key or '' def get_threshold(self) -> float: """ Get the current score threshold. :return: Threshold value """ return self.threshold def get_dev_mode_score(self) -> float: """ Get the mock score used in development mode. :return: Mock score value """ return self.mock_score # Singleton instance for convenience (can be used when app context is available) _recaptcha_service: Optional[RecaptchaService] = None def get_recaptcha_service() -> RecaptchaService: """ Get the reCAPTCHA service instance. Creates a new instance on first call, then returns the same instance. :return: RecaptchaService instance """ global _recaptcha_service if _recaptcha_service is None: _recaptcha_service = RecaptchaService() return _recaptcha_service def reset_recaptcha_service() -> None: """ Reset the reCAPTCHA service singleton. Useful for testing or when configuration changes. """ global _recaptcha_service _recaptcha_service = None