haste_server/auth_n/global/
bot_check.rs1use 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
10pub const HONEYPOT_FIELD: &str = "website";
12pub 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 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
45pub 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}