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}