rustauth_plugins/captcha/
mod.rs1mod error;
4mod ip;
5mod options;
6mod response;
7
8pub mod verify_handlers;
9
10pub use error::{CaptchaConfigError, CaptchaErrorCode};
11pub use options::{CaptchaOptions, CaptchaOptionsBuilder, CaptchaProvider, DEFAULT_ENDPOINTS};
12
13use std::sync::Arc;
14
15use response::error_response;
16use rustauth_core::error::RustAuthError;
17use rustauth_core::plugin::{AuthPlugin, PluginErrorCode};
18use rustauth_core::utils::url::normalize_pathname;
19use verify_handlers::{verify_captcha, VerifyCaptchaInput};
20
21pub const UPSTREAM_PLUGIN_ID: &str = "captcha";
22
23pub fn captcha(options: CaptchaOptions) -> Result<AuthPlugin, RustAuthError> {
25 options
26 .validate()
27 .map_err(|error| RustAuthError::InvalidConfig(error.to_string()))?;
28
29 let options = Arc::new(options.with_defaults());
30 let serialized_options = serde_json::to_value(options.as_ref())
31 .map_err(|error| RustAuthError::InvalidConfig(error.to_string()))?;
32
33 let mut plugin = AuthPlugin::new(UPSTREAM_PLUGIN_ID)
34 .with_version(env!("CARGO_PKG_VERSION"))
35 .with_options(serialized_options)
36 .with_error_code(PluginErrorCode::new(
37 CaptchaErrorCode::VerificationFailed.as_str(),
38 CaptchaErrorCode::VerificationFailed.message(),
39 ))
40 .with_error_code(PluginErrorCode::new(
41 CaptchaErrorCode::MissingResponse.as_str(),
42 CaptchaErrorCode::MissingResponse.message(),
43 ))
44 .with_error_code(PluginErrorCode::new(
45 CaptchaErrorCode::UnknownError.as_str(),
46 CaptchaErrorCode::UnknownError.message(),
47 ));
48
49 plugin = plugin.with_async_middleware("*", move |context, request| {
50 let options = Arc::clone(&options);
51 Box::pin(async move {
52 let path = normalize_pathname(&request.uri().to_string(), &context.base_path);
53 if !options
54 .endpoints
55 .iter()
56 .any(|endpoint| endpoint_matches_path(endpoint, &path))
57 {
58 return Ok(None);
59 }
60 let Some(captcha_response) = request
61 .headers()
62 .get("x-captcha-response")
63 .and_then(|value| value.to_str().ok())
64 .map(str::trim)
65 .filter(|value| !value.is_empty())
66 .map(str::to_owned)
67 else {
68 return error_response(CaptchaErrorCode::MissingResponse).map(Some);
69 };
70
71 let input = VerifyCaptchaInput {
72 options: options.as_ref(),
73 captcha_response: &captcha_response,
74 remote_ip: ip::request_ip(context, request),
75 };
76
77 match verify_captcha(input).await {
78 Ok(true) => Ok(None),
79 Ok(false) => error_response(CaptchaErrorCode::VerificationFailed).map(Some),
80 Err(_) => error_response(CaptchaErrorCode::UnknownError).map(Some),
81 }
82 })
83 });
84
85 Ok(plugin)
86}
87
88fn endpoint_matches_path(endpoint: &str, path: &str) -> bool {
96 let endpoint = endpoint.trim_end_matches('/');
97 if endpoint.is_empty() {
98 return false;
99 }
100 path == endpoint
101 || path
102 .strip_prefix(endpoint)
103 .is_some_and(|rest| rest.starts_with('/'))
104}