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