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