1use std::net::IpAddr;
8
9use mas_data_model::{CaptchaConfig, CaptchaService};
10use mas_http::RequestBuilderExt as _;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::BoundActivityTracker;
15
16const RECAPTCHA_VERIFY_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
18
19const HCAPTCHA_VERIFY_URL: &str = "https://api.hcaptcha.com/siteverify";
21
22const CF_TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
24
25#[derive(Debug, Error)]
26pub enum Error {
27 #[error("A CAPTCHA response was expected, but none was provided")]
28 MissingCaptchaResponse,
29
30 #[error("A CAPTCHA response was provided, but no CAPTCHA provider is configured")]
31 NoCaptchaConfigured,
32
33 #[error("The CAPTCHA response provided is not valid for the configured service")]
34 CaptchaResponseMismatch,
35
36 #[error("The CAPTCHA response provided is invalid: {0:?}")]
37 InvalidCaptcha(Vec<ErrorCode>),
38
39 #[error("The CAPTCHA provider returned an invalid response")]
40 InvalidResponse,
41
42 #[error(
43 "The hostname in the CAPTCHA response ({got:?}) does not match the site hostname ({expected:?})"
44 )]
45 HostnameMismatch { expected: String, got: String },
46
47 #[error("The CAPTCHA provider returned an error")]
48 RequestFailed(#[from] reqwest::Error),
49}
50
51#[allow(clippy::struct_field_names)]
52#[derive(Debug, Deserialize, Default)]
53#[serde(rename_all = "kebab-case")]
54pub struct Form {
55 g_recaptcha_response: Option<String>,
56 h_captcha_response: Option<String>,
57 cf_turnstile_response: Option<String>,
58}
59
60#[derive(Debug, Serialize)]
61struct VerificationRequest<'a> {
62 secret: &'a str,
63 response: &'a str,
64 remoteip: Option<IpAddr>,
65}
66
67#[derive(Debug, Deserialize)]
68struct VerificationResponse {
69 success: bool,
70 #[serde(rename = "error-codes")]
71 error_codes: Option<Vec<ErrorCode>>,
72
73 challenge_ts: Option<String>,
74 hostname: Option<String>,
75}
76
77#[derive(Debug, Deserialize, Clone, Copy)]
78#[serde(rename_all = "kebab-case")]
79pub enum ErrorCode {
80 MissingInputSecret,
84
85 InvalidInputSecret,
89
90 MissingInputResponse,
94
95 InvalidInputResponse,
99
100 InvalidWidgetId,
105
106 InvalidParsedSecret,
110
111 BadRequest,
115
116 MissingRemoteip,
120
121 InvalidRemoteip,
125
126 InvalidOrAlreadySeenResponse,
130
131 NotUsingDummyPasscode,
135
136 SitekeySecretMismatch,
140
141 TimeoutOrDisplicate,
146
147 InternalError,
152}
153
154impl Form {
155 #[tracing::instrument(
156 skip_all,
157 name = "captcha.verify",
158 fields(captcha.hostname, captcha.challenge_ts, captcha.service),
159 err
160 )]
161 pub async fn verify(
162 &self,
163 activity_tracker: &BoundActivityTracker,
164 http_client: &reqwest::Client,
165 site_hostname: &str,
166 config: Option<&CaptchaConfig>,
167 ) -> Result<(), Error> {
168 let Some(config) = config else {
169 if self.g_recaptcha_response.is_some()
170 || self.h_captcha_response.is_some()
171 || self.cf_turnstile_response.is_some()
172 {
173 return Err(Error::NoCaptchaConfigured);
174 }
175
176 return Ok(());
177 };
178
179 let remoteip = activity_tracker.ip();
180 let secret = &config.secret_key;
181
182 let span = tracing::Span::current();
183 span.record("captcha.service", tracing::field::debug(config.service));
184
185 let request = match (
186 config.service,
187 &self.g_recaptcha_response,
188 &self.h_captcha_response,
189 &self.cf_turnstile_response,
190 ) {
191 (_, None, None, None) => return Err(Error::MissingCaptchaResponse),
192
193 (CaptchaService::RecaptchaV2, Some(response), None, None) => http_client
195 .post(RECAPTCHA_VERIFY_URL)
196 .form(&VerificationRequest {
197 secret,
198 response,
199 remoteip,
200 }),
201
202 (CaptchaService::HCaptcha, None, Some(response), None) => http_client
204 .post(HCAPTCHA_VERIFY_URL)
205 .form(&VerificationRequest {
206 secret,
207 response,
208 remoteip,
209 }),
210
211 (CaptchaService::CloudflareTurnstile, None, None, Some(response)) => http_client
213 .post(CF_TURNSTILE_VERIFY_URL)
214 .form(&VerificationRequest {
215 secret,
216 response,
217 remoteip,
218 }),
219
220 _ => return Err(Error::CaptchaResponseMismatch),
221 };
222
223 let response: VerificationResponse = request
224 .send_traced()
225 .await?
226 .error_for_status()?
227 .json()
228 .await?;
229
230 if !response.success {
231 return Err(Error::InvalidCaptcha(
232 response.error_codes.unwrap_or_default(),
233 ));
234 }
235
236 let Some(hostname) = response.hostname else {
239 return Err(Error::InvalidResponse);
240 };
241
242 let Some(challenge_ts) = response.challenge_ts else {
243 return Err(Error::InvalidResponse);
244 };
245
246 span.record("captcha.hostname", &hostname);
247 span.record("captcha.challenge_ts", &challenge_ts);
248
249 if hostname != site_hostname {
250 return Err(Error::HostnameMismatch {
251 expected: site_hostname.to_owned(),
252 got: hostname,
253 });
254 }
255
256 Ok(())
257 }
258}