You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

74 lines
2.4 KiB
Rust

//! 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<String>,
}
/// 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(),
))
}
}