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 )]
160 pub async fn verify(
161 &self,
162 activity_tracker: &BoundActivityTracker,
163 http_client: &reqwest::Client,
164 site_hostname: &str,
165 config: Option<&CaptchaConfig>,
166 ) -> Result<(), Error> {
167 let Some(config) = config else {
168 if self.g_recaptcha_response.is_some()
169 || self.h_captcha_response.is_some()
170 || self.cf_turnstile_response.is_some()
171 {
172 return Err(Error::NoCaptchaConfigured);
173 }
174
175 return Ok(());
176 };
177
178 let remoteip = activity_tracker.ip();
179 let secret = &config.secret_key;
180
181 let span = tracing::Span::current();
182 span.record("captcha.service", tracing::field::debug(config.service));
183
184 let request = match (
185 config.service,
186 &self.g_recaptcha_response,
187 &self.h_captcha_response,
188 &self.cf_turnstile_response,
189 ) {
190 (_, None, None, None) => return Err(Error::MissingCaptchaResponse),
191
192 (CaptchaService::RecaptchaV2, Some(response), None, None) => http_client
194 .post(RECAPTCHA_VERIFY_URL)
195 .form(&VerificationRequest {
196 secret,
197 response,
198 remoteip,
199 }),
200
201 (CaptchaService::HCaptcha, None, Some(response), None) => http_client
203 .post(HCAPTCHA_VERIFY_URL)
204 .form(&VerificationRequest {
205 secret,
206 response,
207 remoteip,
208 }),
209
210 (CaptchaService::CloudflareTurnstile, None, None, Some(response)) => http_client
212 .post(CF_TURNSTILE_VERIFY_URL)
213 .form(&VerificationRequest {
214 secret,
215 response,
216 remoteip,
217 }),
218
219 _ => return Err(Error::CaptchaResponseMismatch),
220 };
221
222 let response: VerificationResponse = request
223 .send_traced()
224 .await?
225 .error_for_status()?
226 .json()
227 .await?;
228
229 if !response.success {
230 return Err(Error::InvalidCaptcha(
231 response.error_codes.unwrap_or_default(),
232 ));
233 }
234
235 let Some(hostname) = response.hostname else {
238 return Err(Error::InvalidResponse);
239 };
240
241 let Some(challenge_ts) = response.challenge_ts else {
242 return Err(Error::InvalidResponse);
243 };
244
245 span.record("captcha.hostname", &hostname);
246 span.record("captcha.challenge_ts", &challenge_ts);
247
248 if hostname != site_hostname {
249 return Err(Error::HostnameMismatch {
250 expected: site_hostname.to_owned(),
251 got: hostname,
252 });
253 }
254
255 Ok(())
256 }
257}