Skip to main content

rustauth_plugins/captcha/
mod.rs

1//! CAPTCHA plugin.
2
3mod 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
23/// Create the CAPTCHA plugin.
24pub 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
88/// Returns whether a configured CAPTCHA `endpoint` protects the routed `path`.
89///
90/// Matching is performed against the already normalized request pathname only,
91/// so query strings and fragments cannot smuggle a protected path into an
92/// otherwise unprotected route. An endpoint matches when it equals the path
93/// exactly or is a path-segment prefix of it: `/sign-up` protects
94/// `/sign-up/email` but not `/sign-up-email` or `/foo/sign-up/email`.
95fn 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}