1use alibi_core::error::codes;
3use alibi_core::{
4 AuthContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute, AuthSchema,
5 utils::{
6 json::{JsValue, parse_value},
7 wildcard,
8 },
9};
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use std::{sync::Arc, time::Duration};
13
14const VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
15const DEFAULT_ENDPOINTS: &[&str] = &[
16 "/sign-up/email",
17 "/sign-in/email",
18 "/request-password-reset",
19];
20
21#[derive(Clone, Debug)]
23pub struct CaptchaHttpOptions {
24 pub secret_key: String,
25 pub site_verify_url: Option<url::Url>,
27}
28impl CaptchaHttpOptions {
29 #[must_use]
30 pub fn new(secret_key: impl Into<String>) -> Self {
31 Self {
32 secret_key: secret_key.into(),
33 site_verify_url: None,
34 }
35 }
36}
37
38#[derive(Clone, Debug)]
39pub struct TurnstileConfig {
40 pub http: CaptchaHttpOptions,
41 pub expected_action: Option<String>,
42 pub allowed_hostnames: Vec<String>,
43}
44impl TurnstileConfig {
45 #[must_use]
46 pub fn new(secret_key: impl Into<String>) -> Self {
47 Self {
48 http: CaptchaHttpOptions::new(secret_key),
49 expected_action: None,
50 allowed_hostnames: Vec::new(),
51 }
52 }
53}
54
55#[derive(Clone, Debug)]
56pub struct RecaptchaConfig {
57 pub http: CaptchaHttpOptions,
58 pub min_score: f64,
59 pub expected_action: Option<String>,
60 pub allowed_hostnames: Vec<String>,
61}
62impl RecaptchaConfig {
63 #[must_use]
64 pub fn new(secret_key: impl Into<String>) -> Self {
65 Self {
66 http: CaptchaHttpOptions::new(secret_key),
67 min_score: 0.5,
68 expected_action: None,
69 allowed_hostnames: Vec::new(),
70 }
71 }
72}
73
74#[derive(Clone, Debug)]
75pub struct SiteKeyCaptchaConfig {
76 pub http: CaptchaHttpOptions,
77 pub site_key: Option<String>,
78}
79impl SiteKeyCaptchaConfig {
80 #[must_use]
81 pub fn new(secret_key: impl Into<String>) -> Self {
82 Self {
83 http: CaptchaHttpOptions::new(secret_key),
84 site_key: None,
85 }
86 }
87}
88
89#[derive(Clone, Debug, Deserialize, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct BotIdVerification {
93 pub is_bot: bool,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub is_verified_bot: Option<bool>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub verified_bot_name: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub verified_bot_category: Option<String>,
100}
101
102#[async_trait]
103pub trait CheckBotId: Send + Sync {
104 async fn check(&self) -> AuthResult<BotIdVerification>;
105}
106
107#[async_trait]
108pub trait ValidateBotIdRequest: Send + Sync {
109 async fn validate(
110 &self,
111 request: &AuthRequest,
112 verification: &BotIdVerification,
113 ) -> AuthResult<bool>;
114}
115
116#[derive(Clone)]
117pub struct BotIdConfig {
118 pub check_bot_id: Arc<dyn CheckBotId>,
119 pub validate_request: Option<Arc<dyn ValidateBotIdRequest>>,
120}
121
122#[derive(Clone)]
123pub enum CaptchaProvider {
124 CloudflareTurnstile(TurnstileConfig),
125 GoogleRecaptcha(RecaptchaConfig),
126 HCaptcha(SiteKeyCaptchaConfig),
127 CaptchaFox(SiteKeyCaptchaConfig),
128 VercelBotId(BotIdConfig),
129}
130
131#[derive(Clone)]
132pub struct CaptchaConfig {
133 pub provider: CaptchaProvider,
134 pub endpoints: Vec<String>,
137}
138impl CaptchaConfig {
139 #[must_use]
140 pub fn new(provider: CaptchaProvider) -> Self {
141 Self {
142 provider,
143 endpoints: Vec::new(),
144 }
145 }
146}
147
148pub struct CaptchaPlugin {
149 config: CaptchaConfig,
150 client: reqwest::Client,
151}
152impl CaptchaPlugin {
153 #[must_use]
154 pub fn new(config: CaptchaConfig) -> Self {
155 Self {
156 config,
157 client: reqwest::Client::new(),
158 }
159 }
160
161 #[must_use]
163 pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
164 self.client = client;
165 self
166 }
167
168 async fn verify(&self, request: &AuthRequest) -> Option<bool> {
170 if let CaptchaProvider::VercelBotId(options) = &self.config.provider {
171 return verify_bot_id(options.clone(), request.clone()).await;
172 }
173 let (http, endpoint) = match &self.config.provider {
174 CaptchaProvider::CloudflareTurnstile(options) => (
175 &options.http,
176 "https://challenges.cloudflare.com/turnstile/v0/siteverify",
177 ),
178 CaptchaProvider::GoogleRecaptcha(options) => (
179 &options.http,
180 "https://www.google.com/recaptcha/api/siteverify",
181 ),
182 CaptchaProvider::HCaptcha(options) => {
183 (&options.http, "https://api.hcaptcha.com/siteverify")
184 }
185 CaptchaProvider::CaptchaFox(options) => {
186 (&options.http, "https://api.captchafox.com/siteverify")
187 }
188 CaptchaProvider::VercelBotId(_) => return None,
189 };
190 let token = request.header("x-captcha-response")?;
191 let policy = request
192 .extensions()
193 .get::<alibi_core::config::IpAddressConfig>();
194 let ip = policy
195 .map_or_else(
196 || alibi_core::config::IpAddressConfig::default().resolve_ip(&request.headers),
197 |policy| policy.resolve_ip(&request.headers),
198 )
199 .filter(|ip| !ip.is_empty());
200 let endpoint = http
201 .site_verify_url
202 .as_ref()
203 .map_or(endpoint, url::Url::as_str);
204 let builder = self.client.post(endpoint);
205 let builder = match &self.config.provider {
206 CaptchaProvider::CloudflareTurnstile(_) => {
207 #[derive(Serialize)]
208 struct Body<'a> {
209 secret: &'a str,
210 response: &'a str,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 remoteip: Option<&'a str>,
213 }
214 builder.json(&Body {
215 secret: &http.secret_key,
216 response: token,
217 remoteip: ip.as_deref(),
218 })
219 }
220 CaptchaProvider::GoogleRecaptcha(_)
221 | CaptchaProvider::HCaptcha(_)
222 | CaptchaProvider::CaptchaFox(_) => {
223 let mut fields = vec![("secret", http.secret_key.as_str()), ("response", token)];
224 if let CaptchaProvider::HCaptcha(options) | CaptchaProvider::CaptchaFox(options) =
225 &self.config.provider
226 && let Some(site_key) =
227 options.site_key.as_deref().filter(|key| !key.is_empty())
228 {
229 fields.push(("sitekey", site_key));
230 }
231 if let Some(ip) = ip.as_deref() {
232 let name = if matches!(self.config.provider, CaptchaProvider::CaptchaFox(_)) {
233 "remoteIp"
234 } else {
235 "remoteip"
236 };
237 fields.push((name, ip));
238 }
239 builder.form(&fields)
240 }
241 CaptchaProvider::VercelBotId(_) => return None,
242 };
243 let response = tokio::time::timeout(VERIFY_TIMEOUT, builder.send())
246 .await
247 .ok()?
248 .ok()?;
249 if !response.status().is_success() {
250 return None;
251 }
252 let content_type = response
253 .headers()
254 .get(reqwest::header::CONTENT_TYPE)
255 .map(|value| value.to_str().unwrap_or_default());
256 let media_type = content_type
257 .and_then(|value| value.split(';').next())
258 .unwrap_or_default();
259 let json_media = media_type.to_ascii_lowercase();
260 let json = json_media
261 .strip_prefix("application/")
262 .is_some_and(|subtype| {
263 subtype == "json"
264 || subtype.strip_suffix("+json").is_some_and(|prefix| {
265 prefix.bytes().all(|byte| {
266 byte.is_ascii_alphanumeric() || b"_!#$%&*.^`~-".contains(&byte)
267 })
268 })
269 });
270 let text_media = media_type.starts_with("text/")
271 || [
272 "image/svg",
273 "application/xml",
274 "application/xhtml",
275 "application/html",
276 ]
277 .contains(&media_type);
278 if content_type.is_some() && !json && !text_media {
279 return Some(false);
282 }
283 let text = response.text().await.ok()?;
284 let data = parse_value(&text).unwrap_or_else(|_| JsValue::String(text));
287 if !truthy(&data) {
288 return None;
289 }
290 if !data.get("success").is_some_and(truthy) {
291 return Some(false);
292 }
293 let (action, hostnames) = match &self.config.provider {
294 CaptchaProvider::CloudflareTurnstile(options) => {
295 (&options.expected_action, &options.allowed_hostnames)
296 }
297 CaptchaProvider::GoogleRecaptcha(options) => {
298 if data
299 .get("score")
300 .and_then(JsValue::as_f64)
301 .is_some_and(|score| score < options.min_score)
302 {
303 return Some(false);
304 }
305 (&options.expected_action, &options.allowed_hostnames)
306 }
307 CaptchaProvider::HCaptcha(_) | CaptchaProvider::CaptchaFox(_) => return Some(true),
308 CaptchaProvider::VercelBotId(_) => return None,
309 };
310 if action
311 .as_deref()
312 .filter(|action| !action.is_empty())
313 .is_some_and(|action| data.get("action").and_then(JsValue::as_str) != Some(action))
314 {
315 return Some(false);
316 }
317 Some(
318 hostnames.is_empty()
319 || data
320 .get("hostname")
321 .and_then(JsValue::as_str)
322 .is_some_and(|hostname| hostnames.iter().any(|allowed| allowed == hostname)),
323 )
324 }
325}
326
327async fn verify_bot_id(options: BotIdConfig, request: AuthRequest) -> Option<bool> {
328 let task = tokio::spawn(async move {
331 let verification = options.check_bot_id.check().await?;
332 match options.validate_request {
333 Some(callback) => callback.validate(&request, &verification).await,
334 None => Ok(!verification.is_bot),
335 }
336 });
337 tokio::time::timeout(VERIFY_TIMEOUT, task)
338 .await
339 .ok()?
340 .ok()?
341 .ok()
342}
343
344fn truthy(value: &JsValue) -> bool {
345 match value {
346 JsValue::Null => false,
347 JsValue::Bool(value) => *value,
348 JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
349 JsValue::String(value) => !value.is_empty(),
350 JsValue::Array(_) | JsValue::Object(_) => true,
351 }
352}
353
354fn unknown_error() -> AuthResponse {
355 error_response(500, "Something went wrong", codes::UNKNOWN_ERROR)
356}
357
358fn error_response(status: u16, message: &str, code: &str) -> AuthResponse {
359 AuthResponse::text(
360 status,
361 format!("{{\"message\":\"{message}\",\"code\":\"{code}\"}}"),
362 )
363 .with_header("content-type", "application/json")
364}
365
366fn normalized_path(path: &str, base: &str) -> String {
367 let path = path.strip_prefix(base).unwrap_or(path);
368 let mut result = String::from("/");
369 for character in path.chars() {
370 if character != '/' || !result.ends_with('/') {
371 result.push(character);
372 }
373 }
374 if result.len() > 1 && result.ends_with('/') {
375 _ = result.pop();
376 }
377 result
378}
379
380fn path_matches(pattern: &str, path: &str) -> Result<bool, regex::Error> {
381 if !pattern.contains('*') {
382 return Ok(pattern == path);
383 }
384 wildcard::compile(pattern).map(|compiled| compiled.is_match(path))
385}
386
387#[async_trait]
388impl<S: AuthSchema> AuthPlugin<S> for CaptchaPlugin {
389 route_openapi_metadata!(S);
390
391 fn name(&self) -> &'static str {
392 "captcha"
393 }
394 fn routes(&self) -> Vec<AuthRoute> {
395 Vec::new()
396 }
397 async fn on_http_request(
398 &self,
399 request: &AuthRequest,
400 context: &AuthContext<S>,
401 ) -> AuthResult<Option<AuthResponse>> {
402 let path = normalized_path(
403 request.url().map_or_else(|| request.path(), url::Url::path),
404 &context.config.base_path,
405 );
406 let protected = if self.config.endpoints.is_empty() {
407 DEFAULT_ENDPOINTS.iter().any(|endpoint| *endpoint == path)
408 } else {
409 let mut matched = false;
410 for endpoint in &self.config.endpoints {
411 match path_matches(endpoint, &path) {
412 Ok(true) => {
413 matched = true;
414 break;
415 }
416 Ok(false) => {}
417 Err(_) => return Ok(Some(unknown_error())),
418 }
419 }
420 matched
421 };
422 if !protected {
423 return Ok(None);
424 }
425 let secret = match &self.config.provider {
426 CaptchaProvider::CloudflareTurnstile(options) => Some(&options.http.secret_key),
427 CaptchaProvider::GoogleRecaptcha(options) => Some(&options.http.secret_key),
428 CaptchaProvider::HCaptcha(options) | CaptchaProvider::CaptchaFox(options) => {
429 Some(&options.http.secret_key)
430 }
431 CaptchaProvider::VercelBotId(_) => None,
432 };
433 if secret.is_some_and(String::is_empty) {
434 return Ok(Some(unknown_error()));
435 }
436 if secret.is_some()
437 && request
438 .header("x-captcha-response")
439 .is_none_or(String::is_empty)
440 {
441 return Ok(Some(error_response(
442 400,
443 "Missing CAPTCHA response",
444 codes::MISSING_RESPONSE,
445 )));
446 }
447 Ok(match self.verify(request).await {
448 Some(true) => None,
449 Some(false) => Some(error_response(
450 403,
451 "Captcha verification failed",
452 codes::VERIFICATION_FAILED,
453 )),
454 None => Some(unknown_error()),
455 })
456 }
457 async fn on_request(
458 &self,
459 _: &AuthRequest,
460 _: &AuthContext<S>,
461 ) -> AuthResult<Option<AuthResponse>> {
462 Ok(None)
463 }
464}