Files
formeleon/services/recaptcha.py
T

226 lines
7.5 KiB
Python

# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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