Files
dify/api/services/turnstile_service.py

94 lines
3.2 KiB
Python

from __future__ import annotations
import httpx
from pydantic import BaseModel, Field, SecretStr, ValidationError
from configs import dify_config
from core.helper.http_client_pooling import get_pooled_http_client
_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
EMAIL_CODE_SEND_ACTION = "signin_code"
EMAIL_CODE_VERIFY_ACTION = "signin_code_verify"
_MAX_TOKEN_LENGTH = 2048
_CLIENT_ERROR_CODES = frozenset(
{
"bad-request",
"invalid-input-response",
"missing-input-response",
"timeout-or-duplicate",
}
)
_http_client = get_pooled_http_client(
"cloudflare:turnstile",
lambda: httpx.Client(
timeout=httpx.Timeout(5.0, connect=3.0),
limits=httpx.Limits(max_keepalive_connections=20, max_connections=50),
),
)
class TurnstileChallengeRejectedError(Exception):
"""The submitted challenge is missing, invalid, expired, or not valid for this site."""
class TurnstileUpstreamError(Exception):
"""Turnstile could not be called or returned an unusable response."""
class _TurnstileResponse(BaseModel):
success: bool
hostname: str | None = None
action: str | None = None
error_codes: list[str] = Field(default_factory=list, alias="error-codes")
class TurnstileService:
@classmethod
def verify(
cls,
*,
token: str | None,
remote_ip: str | None,
expected_action: str = EMAIL_CODE_SEND_ACTION,
) -> None:
normalized_token = token.strip() if token else ""
if not normalized_token or len(normalized_token) > _MAX_TOKEN_LENGTH:
raise TurnstileChallengeRejectedError
secret_key = dify_config.TURNSTILE_SECRET_KEY
allowed_hostnames = dify_config.TURNSTILE_ALLOWED_HOSTNAME_SET
if not isinstance(secret_key, SecretStr) or not allowed_hostnames:
raise TurnstileUpstreamError("Turnstile is not configured")
payload = {
"secret": secret_key.get_secret_value(),
"response": normalized_token,
}
if remote_ip:
payload["remoteip"] = remote_ip
try:
response = _http_client.post(_SITEVERIFY_URL, data=payload)
response.raise_for_status()
result = _TurnstileResponse.model_validate(response.json())
except (httpx.HTTPError, ValidationError, ValueError) as exc:
raise TurnstileUpstreamError("Turnstile verification request failed") from exc
if not result.success:
error_codes = frozenset(result.error_codes)
if error_codes and error_codes.issubset(_CLIENT_ERROR_CODES):
raise TurnstileChallengeRejectedError
raise TurnstileUpstreamError("Turnstile returned a server-side verification error")
if result.action != expected_action or not cls._is_allowed_hostname(result.hostname, allowed_hostnames):
raise TurnstileChallengeRejectedError
@staticmethod
def _is_allowed_hostname(hostname: str | None, allowed_hostnames: frozenset[str]) -> bool:
normalized_hostname = hostname.lower().strip(".") if hostname else ""
return any(
normalized_hostname == allowed or normalized_hostname.endswith(f".{allowed}")
for allowed in allowed_hostnames
)