Skip to main content

pylon_auth/
captcha.rs

1//! CAPTCHA token verification for hCaptcha, Cloudflare Turnstile,
2//! Google reCAPTCHA v3.
3//!
4//! Apps wire CAPTCHA into endpoints that bots love (magic-code send,
5//! password register, account creation) by:
6//!   1. Setting `PYLON_CAPTCHA_PROVIDER` (`hcaptcha` | `turnstile` |
7//!      `recaptcha`) and `PYLON_CAPTCHA_SECRET` (server-side secret
8//!      from the provider).
9//!   2. Frontend includes the CAPTCHA widget; user-supplied token
10//!      arrives in the `captchaToken` JSON field on the request.
11//!   3. Pylon endpoints check `CaptchaConfig::from_env()` and call
12//!      `verify()` before processing — failure returns 400.
13//!
14//! All three providers expose a similar shape: POST a token to a
15//! verify endpoint, get back `{"success": bool, …}`. We collapse them
16//! to one trait + one config so the host app decides "do I have CAPTCHA
17//! enabled?" not "which CAPTCHA?"
18
19use serde::Deserialize;
20
21/// Which CAPTCHA service the app uses. Reads `PYLON_CAPTCHA_PROVIDER`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CaptchaProvider {
24    HCaptcha,
25    Turnstile,
26    ReCaptcha,
27}
28
29impl CaptchaProvider {
30    fn endpoint(&self) -> &'static str {
31        match self {
32            // hCaptcha: https://docs.hcaptcha.com/#verify-the-user-response-server-side
33            Self::HCaptcha => "https://api.hcaptcha.com/siteverify",
34            // Turnstile: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
35            Self::Turnstile => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
36            // reCAPTCHA: https://developers.google.com/recaptcha/docs/verify
37            Self::ReCaptcha => "https://www.google.com/recaptcha/api/siteverify",
38        }
39    }
40
41    fn from_str(s: &str) -> Option<Self> {
42        match s.to_ascii_lowercase().as_str() {
43            "hcaptcha" => Some(Self::HCaptcha),
44            "turnstile" | "cloudflare" => Some(Self::Turnstile),
45            "recaptcha" | "google" => Some(Self::ReCaptcha),
46            _ => None,
47        }
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct CaptchaConfig {
53    pub provider: CaptchaProvider,
54    pub secret: String,
55    /// Minimum score for reCAPTCHA v3 (range 0.0..=1.0). Other
56    /// providers ignore this. Default 0.5 — Google's recommended
57    /// threshold for "probably human."
58    pub min_score: f64,
59}
60
61impl CaptchaConfig {
62    /// Pull config from `PYLON_CAPTCHA_PROVIDER` + `PYLON_CAPTCHA_SECRET`.
63    /// Returns `None` when CAPTCHA is not configured (apps treat
64    /// `None` as "skip CAPTCHA check entirely").
65    pub fn from_env() -> Option<Self> {
66        let provider = CaptchaProvider::from_str(&std::env::var("PYLON_CAPTCHA_PROVIDER").ok()?)?;
67        let secret = std::env::var("PYLON_CAPTCHA_SECRET").ok()?;
68        let min_score = std::env::var("PYLON_CAPTCHA_MIN_SCORE")
69            .ok()
70            .and_then(|s| s.parse().ok())
71            .unwrap_or(0.5);
72        Some(Self {
73            provider,
74            secret,
75            min_score,
76        })
77    }
78
79    /// Verify a user-supplied CAPTCHA token. Returns `Ok(())` on
80    /// success; `Err(reason)` on any failure (reason is safe to
81    /// surface as a generic message — don't leak it to clients).
82    pub fn verify(&self, token: &str, remote_ip: Option<&str>) -> Result<(), String> {
83        if token.is_empty() {
84            return Err("CAPTCHA token is empty".into());
85        }
86        let mut body = format!(
87            "secret={}&response={}",
88            url_encode(&self.secret),
89            url_encode(token)
90        );
91        if let Some(ip) = remote_ip {
92            body.push_str("&remoteip=");
93            body.push_str(&url_encode(ip));
94        }
95        let agent = ureq::AgentBuilder::new()
96            .timeout_connect(std::time::Duration::from_secs(5))
97            .timeout_read(std::time::Duration::from_secs(5))
98            .build();
99        let resp = agent
100            .post(self.provider.endpoint())
101            .set("Content-Type", "application/x-www-form-urlencoded")
102            .send_string(&body)
103            .map_err(|e| format!("captcha network: {e}"))?
104            .into_string()
105            .map_err(|e| format!("captcha body: {e}"))?;
106        let parsed: SiteVerifyResponse =
107            serde_json::from_str(&resp).map_err(|e| format!("captcha bad JSON: {e}"))?;
108        if !parsed.success {
109            return Err(format!(
110                "captcha rejected: {}",
111                parsed.error_codes.unwrap_or_default().join(",")
112            ));
113        }
114        if let CaptchaProvider::ReCaptcha = self.provider {
115            // reCAPTCHA v3 returns a score; v2 doesn't include the
116            // field at all. None → treat as v2 (any success passes).
117            if let Some(score) = parsed.score {
118                if score < self.min_score {
119                    return Err(format!(
120                        "captcha score {score:.2} below threshold {:.2}",
121                        self.min_score
122                    ));
123                }
124            }
125        }
126        // P3-8 (codex Wave-3 review): reject stale tokens to limit
127        // captured-token replay. 2-minute window is conservative;
128        // fresh sign-in flows complete in seconds.
129        if let Some(ts) = parsed.challenge_ts.as_deref() {
130            if let Ok(parsed_ts) = chrono::DateTime::parse_from_rfc3339(ts) {
131                let age_secs = chrono::Utc::now()
132                    .signed_duration_since(parsed_ts.with_timezone(&chrono::Utc))
133                    .num_seconds();
134                if age_secs > 120 {
135                    return Err(format!("captcha token stale ({age_secs}s old)"));
136                }
137            }
138        }
139        Ok(())
140    }
141}
142
143/// Common subset of fields all three providers return on success.
144#[derive(Debug, Deserialize)]
145struct SiteVerifyResponse {
146    success: bool,
147    /// reCAPTCHA v3 only.
148    #[serde(default)]
149    score: Option<f64>,
150    /// Issued-at timestamp from the provider — present on hCaptcha
151    /// + Turnstile + reCAPTCHA. ISO-8601. Used to defeat replay
152    /// (an attacker who captures a token gets ~2 min to use it
153    /// before pylon rejects it as stale).
154    #[serde(default, rename = "challenge_ts")]
155    challenge_ts: Option<String>,
156    /// Provider-specific error codes — left opaque since the host
157    /// app shouldn't surface them to the caller.
158    #[serde(default, rename = "error-codes")]
159    error_codes: Option<Vec<String>>,
160}
161
162fn url_encode(s: &str) -> String {
163    let mut out = String::with_capacity(s.len());
164    for b in s.bytes() {
165        match b {
166            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
167                out.push(b as char)
168            }
169            _ => out.push_str(&format!("%{b:02X}")),
170        }
171    }
172    out
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn provider_from_str_recognizes_aliases() {
181        assert_eq!(CaptchaProvider::from_str("hcaptcha"), Some(CaptchaProvider::HCaptcha));
182        assert_eq!(CaptchaProvider::from_str("HCAPTCHA"), Some(CaptchaProvider::HCaptcha));
183        assert_eq!(CaptchaProvider::from_str("turnstile"), Some(CaptchaProvider::Turnstile));
184        assert_eq!(CaptchaProvider::from_str("cloudflare"), Some(CaptchaProvider::Turnstile));
185        assert_eq!(CaptchaProvider::from_str("recaptcha"), Some(CaptchaProvider::ReCaptcha));
186        assert_eq!(CaptchaProvider::from_str("google"), Some(CaptchaProvider::ReCaptcha));
187        assert_eq!(CaptchaProvider::from_str("nope"), None);
188    }
189
190    #[test]
191    fn endpoints_are_https() {
192        for p in [CaptchaProvider::HCaptcha, CaptchaProvider::Turnstile, CaptchaProvider::ReCaptcha] {
193            assert!(p.endpoint().starts_with("https://"), "endpoint must be https");
194        }
195    }
196
197    #[test]
198    fn empty_token_rejected_without_network() {
199        let cfg = CaptchaConfig {
200            provider: CaptchaProvider::HCaptcha,
201            secret: "test".into(),
202            min_score: 0.5,
203        };
204        assert!(cfg.verify("", None).is_err());
205    }
206}