axum_turnstile/
lib.rs

1//! # axum-turnstile
2//!
3//! Cloudflare Turnstile verification middleware for [Axum](https://github.com/tokio-rs/axum).
4//!
5//! This crate provides middleware for verifying [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/)
6//! tokens in Axum web applications. Turnstile is Cloudflare's privacy-first CAPTCHA alternative
7//! that helps protect your application from bots and abuse.
8//!
9//! ## Features
10//!
11//! - ๐Ÿ”’ Easy integration with Axum applications
12//! - ๐ŸŽฏ Tower middleware layer for flexible composition
13//! - โš™๏ธ Configurable header names and verification endpoints
14//! - ๐Ÿงช Support for Cloudflare's test keys
15//! - ๐Ÿ“ฆ Minimal dependencies
16//!
17//! ## Quick Start
18//!
19//! Add this to your `Cargo.toml`:
20//!
21//! ```toml
22//! [dependencies]
23//! axum-turnstile = "0.1"
24//! axum = "0.8"
25//! tokio = { version = "1", features = ["full"] }
26//! ```
27//!
28//! ## Basic Usage
29//!
30//! ```rust,no_run
31//! use axum::{routing::post, Router};
32//! use axum_turnstile::{TurnstileLayer, VerifiedTurnstile};
33//!
34//! #[tokio::main]
35//! async fn main() {
36//!     // Create a protected endpoint
37//!     let app = Router::new()
38//!         .route("/api/protected", post(protected_handler))
39//!         .layer(TurnstileLayer::from_secret("your-secret-key"));
40//!
41//!     let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
42//!         .await
43//!         .unwrap();
44//!     axum::serve(listener, app).await.unwrap();
45//! }
46//!
47//! // Handler that requires Turnstile verification
48//! async fn protected_handler(_verified: VerifiedTurnstile) -> &'static str {
49//!     "Success! Turnstile token verified."
50//! }
51//! ```
52//!
53//! ## How It Works
54//!
55//! 1. Client includes the Turnstile token in the `CF-Turnstile-Token` header
56//! 2. Middleware extracts and verifies the token with Cloudflare's API
57//! 3. If valid, the request proceeds and handlers can extract [`VerifiedTurnstile`]
58//! 4. If invalid or missing, the request is rejected with an appropriate status code
59//!
60//! ## Advanced Configuration
61//!
62//! ```rust
63//! use axum_turnstile::{TurnstileConfig, TurnstileLayer};
64//!
65//! let config = TurnstileConfig::new("your-secret-key")
66//!     .with_header_name("X-Custom-Turnstile-Token")
67//!     .with_verify_url("https://custom-endpoint.example.com/verify");
68//!
69//! let layer = TurnstileLayer::new(config);
70//! ```
71//!
72//! ## Testing
73//!
74//! Cloudflare provides test keys that always pass or fail verification:
75//!
76//! - **Always passes**: `1x0000000000000000000000000000000AA`
77//! - **Always fails**: `2x0000000000000000000000000000000AA`
78//!
79//! ```rust,no_run
80//! use axum_turnstile::TurnstileLayer;
81//!
82//! // Use the test key that always passes
83//! let layer = TurnstileLayer::from_secret("1x0000000000000000000000000000000AA");
84//! ```
85//!
86//! ## Response Codes
87//!
88//! - `400 Bad Request`: Turnstile token header is missing
89//! - `403 Forbidden`: Token verification failed
90//! - `500 Internal Server Error`: Error communicating with Cloudflare's API
91//!
92//! ## Extracting the Verified Marker
93//!
94//! The [`VerifiedTurnstile`] type implements [`FromRequestParts`],
95//! so you can use it as an extractor in your handlers:
96//!
97//! ```rust
98//! use axum_turnstile::VerifiedTurnstile;
99//!
100//! async fn handler(_verified: VerifiedTurnstile) -> &'static str {
101//!     "Only reached if Turnstile verification succeeded"
102//! }
103//! ```
104
105mod layer;
106mod middleware;
107mod verifier;
108
109pub use layer::TurnstileLayer;
110pub use middleware::TurnstileMiddleware;
111
112use axum::{
113    extract::FromRequestParts,
114    http::{request::Parts, StatusCode},
115};
116use serde::{Deserialize, Serialize};
117
118/// Configuration for Turnstile verification
119#[derive(Clone, Debug)]
120pub struct TurnstileConfig {
121    /// Cloudflare Turnstile secret key
122    pub secret: String,
123    /// Custom header name (default: "CF-Turnstile-Token")
124    pub header_name: String,
125    /// Verification endpoint (default: Cloudflare's endpoint)
126    pub verify_url: String,
127}
128
129impl TurnstileConfig {
130    /// Create a new config with the given secret
131    pub fn new(secret: impl Into<String>) -> Self {
132        Self {
133            secret: secret.into(),
134            header_name: "CF-Turnstile-Token".to_string(),
135            verify_url: "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_string(),
136        }
137    }
138
139    /// Set a custom header name
140    pub fn with_header_name(mut self, name: impl Into<String>) -> Self {
141        self.header_name = name.into();
142        self
143    }
144
145    /// Set a custom verification URL (for testing)
146    pub fn with_verify_url(mut self, url: impl Into<String>) -> Self {
147        self.verify_url = url.into();
148        self
149    }
150}
151
152#[derive(Serialize)]
153struct VerifyRequest {
154    secret: String,
155    response: String,
156}
157
158#[derive(Deserialize, Debug)]
159struct VerifyResponse {
160    success: bool,
161    #[serde(rename = "error-codes")]
162    error_codes: Option<Vec<String>>,
163}
164
165/// Marker type that can be extracted in handlers after successful verification
166#[derive(Clone, Debug)]
167pub struct VerifiedTurnstile;
168
169impl<S> FromRequestParts<S> for VerifiedTurnstile
170where
171    S: Send + Sync,
172{
173    type Rejection = StatusCode;
174
175    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
176        parts
177            .extensions
178            .get::<VerifiedTurnstile>()
179            .cloned()
180            .ok_or(StatusCode::UNAUTHORIZED)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use axum::{
188        body::Body,
189        http::{Request, StatusCode},
190        routing::get,
191        Router,
192    };
193    use tower::ServiceExt;
194
195    #[tokio::test]
196    async fn test_missing_token() {
197        let app = Router::new()
198            .route("/test", get(|| async { "OK" }))
199            .layer(TurnstileLayer::from_secret("test-secret"));
200
201        let response = app
202            .oneshot(Request::get("/test").body(Body::empty()).unwrap())
203            .await
204            .unwrap();
205
206        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
207    }
208
209    #[tokio::test]
210    async fn test_with_test_key() {
211        // Using Cloudflare's test key
212        let app = Router::new().route("/test", get(|| async { "OK" })).layer(
213            TurnstileLayer::from_secret("1x0000000000000000000000000000000AA"),
214        );
215
216        let response = app
217            .oneshot(
218                Request::get("/test")
219                    .header("CF-Turnstile-Token", "test-token")
220                    .body(Body::empty())
221                    .unwrap(),
222            )
223            .await
224            .unwrap();
225
226        assert_eq!(response.status(), StatusCode::OK);
227    }
228}