Skip to main content

kora_lib/rpc_server/
recaptcha_util.rs

1use crate::{
2    constant::{RECAPTCHA_TIMEOUT_SECS, RECAPTCHA_VERIFY_URL},
3    error::KoraError,
4    rpc_server::middleware_utils::build_response_with_graceful_error,
5    sanitize_error,
6};
7use http::{Response, StatusCode};
8use jsonrpsee::server::logger::Body;
9use reqwest::Client;
10use serde::Deserialize;
11use std::time::Duration;
12
13#[derive(Debug, Deserialize)]
14struct RecaptchaVerifyResponse {
15    success: bool,
16    score: Option<f64>,
17    #[serde(rename = "error-codes")]
18    error_codes: Option<Vec<String>>,
19}
20
21#[derive(Clone)]
22pub struct RecaptchaConfig {
23    pub secret: String,
24    pub score_threshold: f64,
25    pub protected_methods: Vec<String>,
26}
27
28impl RecaptchaConfig {
29    pub fn new(secret: String, score_threshold: f64, protected_methods: Vec<String>) -> Self {
30        Self { secret, score_threshold, protected_methods }
31    }
32
33    pub fn is_protected_method(&self, method: &str) -> bool {
34        self.protected_methods.iter().any(|m| m == method)
35    }
36
37    pub async fn validate(&self, token: Option<&str>) -> Result<(), Response<Body>> {
38        let token = match token {
39            Some(t) if !t.is_empty() => t,
40            _ => {
41                return Err(build_response_with_graceful_error(None, StatusCode::UNAUTHORIZED, ""))
42            }
43        };
44
45        if let Err(e) = self.verify_token(token).await {
46            log::error!("reCAPTCHA verification error: {}", sanitize_error!(e));
47            return Err(build_response_with_graceful_error(None, StatusCode::UNAUTHORIZED, ""));
48        }
49
50        Ok(())
51    }
52
53    async fn verify_token(&self, token: &str) -> Result<f64, KoraError> {
54        let client = Client::builder()
55            .timeout(Duration::from_secs(RECAPTCHA_TIMEOUT_SECS))
56            .build()
57            .map_err(|e| {
58                KoraError::RecaptchaError(format!(
59                    "Failed to create HTTP client: {}",
60                    sanitize_error!(e)
61                ))
62            })?;
63
64        let response = client
65            .post(RECAPTCHA_VERIFY_URL)
66            .form(&[("secret", self.secret.as_str()), ("response", token)])
67            .send()
68            .await
69            .map_err(|e| {
70                KoraError::RecaptchaError(format!("API call failed: {}", sanitize_error!(e)))
71            })?;
72
73        let status = response.status();
74        let response_text = response.text().await.map_err(|e| {
75            KoraError::RecaptchaError(format!(
76                "Failed to read response body: {}",
77                sanitize_error!(e)
78            ))
79        })?;
80
81        if !status.is_success() {
82            log::error!(
83                "reCAPTCHA API returned error status {}: {}",
84                status.as_u16(),
85                sanitize_error!(response_text)
86            );
87            return Err(KoraError::RecaptchaError(format!(
88                "API returned status: {}",
89                status.as_u16()
90            )));
91        }
92
93        let verify_response: RecaptchaVerifyResponse = serde_json::from_str(&response_text)
94            .map_err(|e| {
95                KoraError::RecaptchaError(format!(
96                    "Failed to parse response: {}",
97                    sanitize_error!(e)
98                ))
99            })?;
100
101        if !verify_response.success {
102            let errors = verify_response.error_codes.unwrap_or_default().join(", ");
103            return Err(KoraError::RecaptchaError(format!(
104                "Verification failed: {}",
105                sanitize_error!(errors)
106            )));
107        }
108
109        let score = verify_response
110            .score
111            .ok_or_else(|| KoraError::RecaptchaError("Response missing score".to_string()))?;
112
113        if score < self.score_threshold {
114            return Err(KoraError::RecaptchaError(format!(
115                "Score {:.2} below threshold {:.2}",
116                score, self.score_threshold
117            )));
118        }
119
120        Ok(score)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_recaptcha_config_is_protected_method() {
130        let config = RecaptchaConfig::new(
131            "secret".to_string(),
132            0.5,
133            vec!["signTransaction".to_string(), "signAndSendTransaction".to_string()],
134        );
135
136        assert!(config.is_protected_method("signTransaction"));
137        assert!(config.is_protected_method("signAndSendTransaction"));
138        assert!(!config.is_protected_method("getConfig"));
139        assert!(!config.is_protected_method("liveness"));
140    }
141
142    #[test]
143    fn test_optional_recaptcha_config() {
144        let no_config: Option<RecaptchaConfig> = None;
145        assert!(no_config.is_none());
146
147        let with_config = Some(RecaptchaConfig::new(
148            "secret".to_string(),
149            0.5,
150            vec!["signTransaction".to_string()],
151        ));
152        assert!(with_config.is_some());
153    }
154}