rocket_recaptcha_v3/
lib.rs

1/*!
2# reCAPTCHA v3 for Rocket Framework
3
4This crate can help you use reCAPTCHA v3 in your Rocket web application.
5
6See `Rocket.toml` and `examples`.
7*/
8
9mod errors;
10mod fairing;
11mod models;
12mod verification;
13
14use std::{collections::HashMap, marker::PhantomData, str::FromStr};
15
16use chrono::prelude::*;
17pub use errors::ReCaptchaError;
18use fairing::ReCaptchaFairing;
19pub use models::*;
20use reqwest::{
21    header::{self, HeaderMap, HeaderValue},
22    Client,
23};
24pub use rocket_client_addr::ClientRealAddr;
25use validators::{errors::RegexError, prelude::*};
26pub use verification::ReCaptchaVerification;
27use verification::ReCaptchaVerificationInner;
28
29const API_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
30
31pub trait ReCaptchaVariant: Sync + Send + 'static {
32    fn get_version_str(&self) -> &'static str;
33}
34
35#[derive(Debug, Clone, Copy)]
36pub struct V3;
37
38impl ReCaptchaVariant for V3 {
39    #[inline]
40    fn get_version_str(&self) -> &'static str {
41        "v3"
42    }
43}
44
45#[derive(Debug, Clone, Copy)]
46pub struct V2;
47
48impl ReCaptchaVariant for V2 {
49    #[inline]
50    fn get_version_str(&self) -> &'static str {
51        "v2"
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct ReCaptcha<V: ReCaptchaVariant = V3> {
57    html_key:   Option<ReCaptchaKey>,
58    secret_key: ReCaptchaKey,
59    phantom:    PhantomData<V>,
60}
61
62impl<V: ReCaptchaVariant> ReCaptcha<V> {
63    #[inline]
64    /// You should use the Rocket fairing mechanism instead of invoking this method to create a `ReCaptcha` instance.
65    pub fn new(html_key: Option<ReCaptchaKey>, secret_key: ReCaptchaKey) -> ReCaptcha<V> {
66        ReCaptcha {
67            html_key,
68            secret_key,
69            phantom: PhantomData,
70        }
71    }
72
73    #[inline]
74    /// You should use the Rocket fairing mechanism instead of invoking this method to create a `ReCaptcha` instance.
75    pub fn from_str<S1: AsRef<str>, S2: AsRef<str>>(
76        html_key: Option<S1>,
77        secret_key: S2,
78    ) -> Result<ReCaptcha<V>, RegexError> {
79        #[allow(clippy::manual_map)]
80        let html_key = match html_key {
81            Some(html_key) => Some(ReCaptchaKey::parse_str(html_key.as_ref())?),
82            None => None,
83        };
84
85        let secret_key = ReCaptchaKey::parse_str(secret_key.as_ref())?;
86
87        Ok(ReCaptcha::<V>::new(html_key, secret_key))
88    }
89
90    #[inline]
91    /// You should use the Rocket fairing mechanism instead of invoking this method to create a `ReCaptcha` instance.
92    pub fn from_string<S1: Into<String>, S2: Into<String>>(
93        html_key: Option<S1>,
94        secret_key: S2,
95    ) -> Result<ReCaptcha<V>, RegexError> {
96        #[allow(clippy::manual_map)]
97        let html_key = match html_key {
98            Some(html_key) => Some(ReCaptchaKey::parse_string(html_key.into())?),
99            None => None,
100        };
101
102        let secret_key = ReCaptchaKey::parse_string(secret_key.into())?;
103
104        Ok(ReCaptcha::<V>::new(html_key, secret_key))
105    }
106
107    #[inline]
108    pub fn get_html_key_as_str(&self) -> Option<&str> {
109        self.html_key.as_ref().map(|k| k.as_str())
110    }
111
112    #[inline]
113    pub fn get_secret_key_as_str(&self) -> &str {
114        self.secret_key.as_str()
115    }
116}
117
118impl ReCaptcha {
119    #[inline]
120    /// Create a `ReCaptchaFairing<V3>` instance to load reCAPTCHA v3 keys. It will mount a `ReCaptcha<V3>` (`ReCaptcha`) instance on Rocket.
121    pub fn fairing() -> ReCaptchaFairing<V3> {
122        ReCaptchaFairing::<V3>::new()
123    }
124
125    #[inline]
126    /// Create a `ReCaptchaFairing<V2>` instance to load reCAPTCHA v2 keys. It will mount a `ReCaptcha<V2>` instance on Rocket.
127    pub fn fairing_v2() -> ReCaptchaFairing<V2> {
128        ReCaptchaFairing::<V2>::new()
129    }
130}
131
132impl<V: ReCaptchaVariant> ReCaptcha<V> {
133    pub async fn verify(
134        &self,
135        recaptcha_token: &ReCaptchaToken,
136        remote_ip: Option<&ClientRealAddr>,
137    ) -> Result<ReCaptchaVerification, ReCaptchaError> {
138        let request = Client::builder()
139            .default_headers({
140                // The Content-Length header is necessary, or it will return 411 status.
141
142                let mut map = HeaderMap::with_capacity(1);
143
144                map.insert(header::CONTENT_LENGTH, HeaderValue::from(0usize));
145
146                map
147            })
148            .build()
149            .unwrap()
150            .post(API_URL)
151            .query(&{
152                let mut map = HashMap::new();
153
154                map.insert("secret", self.get_secret_key_as_str().to_string());
155                map.insert("response", recaptcha_token.as_str().to_string());
156
157                if let Some(remote_ip) = remote_ip {
158                    map.insert("remoteip", remote_ip.ip.to_string());
159                }
160
161                map
162            });
163
164        let response = request
165            .send()
166            .await
167            .map_err(|err| ReCaptchaError::InternalError(format!("{:?}", err)))?;
168
169        if response.status().is_success() {
170            let body = response
171                .bytes()
172                .await
173                .map_err(|err| ReCaptchaError::InternalError(format!("{:?}", err)))?;
174
175            let result: ReCaptchaVerificationInner = serde_json::from_slice(body.as_ref())
176                .map_err(|err| ReCaptchaError::InternalError(err.to_string()))?;
177
178            if result.success {
179                let score = result.score.unwrap_or(1.0);
180                let action = result.action;
181                let challenge_ts = result.challenge_ts.ok_or_else(|| {
182                    ReCaptchaError::InternalError("There is no `challenge_ts` field.".to_string())
183                })?;
184                let hostname = result.hostname.ok_or_else(|| {
185                    ReCaptchaError::InternalError("There is no `hostname` field.".to_string())
186                })?;
187
188                let challenge_ts = DateTime::from_str(&challenge_ts).map_err(|_| {
189                    ReCaptchaError::InternalError(format!(
190                        "The format of the timestamp `{}` is incorrect.",
191                        challenge_ts
192                    ))
193                })?;
194
195                Ok(ReCaptchaVerification {
196                    score,
197                    action,
198                    challenge_ts,
199                    hostname,
200                })
201            } else {
202                match result.error_codes {
203                    Some(error_codes) => {
204                        if error_codes.contains(&"invalid-input-secret".to_string()) {
205                            Err(ReCaptchaError::InvalidInputSecret)
206                        } else if error_codes.contains(&"invalid-input-response".to_string()) {
207                            Err(ReCaptchaError::InvalidReCaptchaToken)
208                        } else if error_codes.contains(&"timeout-or-duplicate".to_string()) {
209                            Err(ReCaptchaError::TimeoutOrDuplicate)
210                        } else {
211                            Err(ReCaptchaError::InternalError(
212                                "No expected error codes.".to_string(),
213                            ))
214                        }
215                    },
216                    None => Err(ReCaptchaError::InternalError("No error codes.".to_string())),
217                }
218            }
219        } else {
220            Err(ReCaptchaError::InternalError(format!(
221                "The response status code of the `siteverify` API is {}.",
222                response.status().as_u16()
223            )))
224        }
225    }
226}