Skip to main content

haste_server/auth_n/global/
bot_check.rs

1//! Keeps bots off the email form: a honeypot field, and a Cloudflare
2//! Turnstile token when configured. Rate limits are the caller's job.
3
4use crate::config::SignupConfig;
5use haste_fhir_model::r4::generated::terminology::IssueType;
6use haste_fhir_operation_error::OperationOutcomeError;
7use serde::Deserialize;
8use std::{net::IpAddr, sync::LazyLock, time::Duration};
9
10/// Hidden form field that only automated form fillers complete.
11pub const HONEYPOT_FIELD: &str = "website";
12/// Form field the Turnstile widget fills in.
13pub const TURNSTILE_FIELD: &str = "cf-turnstile-response";
14
15const TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
16
17static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
18    reqwest::Client::builder()
19        .timeout(Duration::from_secs(5))
20        .build()
21        .expect("reqwest client")
22});
23
24pub enum Verdict {
25    Human,
26    /// The reason, for the log.
27    Bot(&'static str),
28}
29
30#[derive(Deserialize)]
31struct TurnstileResponse {
32    success: bool,
33    #[serde(default, rename = "error-codes")]
34    error_codes: Vec<String>,
35}
36
37fn unavailable(error: reqwest::Error) -> OperationOutcomeError {
38    tracing::error!(%error, "Turnstile verification failed");
39    OperationOutcomeError::fatal(
40        IssueType::exception(),
41        "The bot check is unavailable right now. Please try again shortly.".to_string(),
42    )
43}
44
45/// Errors only when Turnstile cannot be reached: the form then refuses rather
46/// than letting bots through.
47pub async fn check(
48    config: &SignupConfig,
49    honeypot: Option<&str>,
50    turnstile_token: Option<&str>,
51    ip: Option<IpAddr>,
52) -> Result<Verdict, OperationOutcomeError> {
53    if honeypot.is_some_and(|value| !value.trim().is_empty()) {
54        return Ok(Verdict::Bot("honeypot field filled"));
55    }
56
57    let Some(turnstile) = &config.turnstile else {
58        return Ok(Verdict::Human);
59    };
60
61    let Some(token) = turnstile_token.filter(|token| !token.is_empty()) else {
62        return Ok(Verdict::Bot("turnstile token missing"));
63    };
64
65    let remote_ip = ip.map(|ip| ip.to_string());
66    let mut form = vec![
67        ("secret", turnstile.secret_key.as_str()),
68        ("response", token),
69    ];
70    form.extend(remote_ip.as_deref().map(|ip| ("remoteip", ip)));
71
72    let response: TurnstileResponse = HTTP
73        .post(TURNSTILE_VERIFY_URL)
74        .form(&form)
75        .send()
76        .await
77        .and_then(reqwest::Response::error_for_status)
78        .map_err(unavailable)?
79        .json()
80        .await
81        .map_err(unavailable)?;
82
83    if response.success {
84        Ok(Verdict::Human)
85    } else {
86        tracing::info!(error_codes = ?response.error_codes, "Turnstile rejected the token");
87        Ok(Verdict::Bot("turnstile rejected the token"))
88    }
89}