//! Cloudflare Turnstile server-side verification. //! //! Validates captcha tokens submitted by the frontend to prevent //! automated signups and brute-force attacks. use serde::Deserialize; use crate::errors::AppError; /// Turnstile verification endpoint. const TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; /// Response shape from the Turnstile verification API. #[derive(Debug, Deserialize)] struct TurnstileResponse { success: bool, #[serde(default, rename = "error-codes")] error_codes: Vec, } /// Test secret key that bypasses the external Cloudflare API call. /// /// When the configured Turnstile secret key equals this value, verification /// always succeeds without making any HTTP request. This allows integration /// tests to run without network access to Cloudflare. pub const TEST_SECRET_KEY: &str = "test-turnstile-secret-always-pass"; /// Verify a Turnstile captcha token with the Cloudflare API. /// /// Returns `Ok(())` if the token is valid, or an `AppError::BadRequest` /// if verification fails. /// /// When `secret_key` equals [`TEST_SECRET_KEY`], the external call is /// skipped and verification always succeeds (used in integration tests). pub async fn verify( client: &reqwest::Client, secret_key: &str, token: &str, ) -> Result<(), AppError> { // Bypass for integration tests — no external HTTP call if secret_key == TEST_SECRET_KEY { tracing::debug!("Turnstile verification bypassed (test mode)"); return Ok(()); } let response = client .post(TURNSTILE_VERIFY_URL) .form(&[("secret", secret_key), ("response", token)]) .send() .await .map_err(|e| { tracing::error!("Turnstile verification request failed: {:?}", e); AppError::Internal(anyhow::anyhow!("Captcha verification service unavailable")) })?; let result: TurnstileResponse = response.json().await.map_err(|e| { tracing::error!("Failed to parse Turnstile response: {:?}", e); AppError::Internal(anyhow::anyhow!("Captcha verification response invalid")) })?; if result.success { Ok(()) } else { tracing::warn!( error_codes = ?result.error_codes, "Turnstile verification failed" ); Err(AppError::BadRequest( "Captcha verification failed. Please try again.".into(), )) } }