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