kora_lib/rpc_server/
recaptcha_util.rs1use 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}