Skip to main content

rocket_recaptcha_v3/
lib.rs

1/*!
2# reCAPTCHA v3 for Rocket Framework
3
4This crate can help you use reCAPTCHA v3 (v2 is backward compatible) in your Rocket web application.
5
6## Configuration
7
8Put your reCAPTCHA keys in `Rocket.toml`. The `html_key` is optional, and is only needed to render the front-end script.
9
10```toml
11[default.recaptcha.v3]
12html_key = "6Lf6dLIUAAAAAAxghN7nH6m_yuLfHwdD3N7FpanR"
13secret_key = "6Lf6dLIUAAAAAHdJ4e0nsv-8OpFH-7Oad1XQ95rq"
14```
15
16## Usage
17
18Attach [`ReCaptcha::fairing`] to Rocket, and then every route can take a `&State<ReCaptcha>` to verify tokens with.
19
20```rust,no_run
21#[macro_use]
22extern crate rocket;
23
24use rocket::{State, form::Form};
25use rocket_recaptcha_v3::{ReCaptcha, ReCaptchaToken};
26
27#[derive(FromForm)]
28struct LoginModel {
29    recaptcha_token: ReCaptchaToken,
30}
31
32#[get("/login")]
33fn login_get(recaptcha: &State<ReCaptcha>) -> String {
34    // Render the front-end script with this key.
35    recaptcha.html_key().unwrap().as_str().to_string()
36}
37
38#[post("/login", data = "<model>")]
39async fn login_post(recaptcha: &State<ReCaptcha>, model: Form<LoginModel>) -> &'static str {
40    match recaptcha.verify(&model.recaptcha_token, None).await {
41        Ok(verification) => {
42            if verification.score > 0.7 {
43                "Hello, human!"
44            } else {
45                "You are probably not a human."
46            }
47        },
48        Err(_) => "Please try again.",
49    }
50}
51
52#[rocket::main]
53async fn main() -> Result<(), rocket::Error> {
54    rocket::build()
55        .attach(ReCaptcha::fairing())
56        .mount("/", routes![login_get, login_post])
57        .launch()
58        .await?;
59
60    Ok(())
61}
62```
63
64## reCAPTCHA v2
65
66reCAPTCHA v2 works the same way. Put the keys under `[default.recaptcha.v2]`, attach [`ReCaptcha::fairing_v2`], and take a `&State<ReCaptcha<V2>>` in your routes. A solved v2 challenge carries no score, so [`ReCaptchaVerification::score`] is always `1.0` for it.
67
68Both fairings can be attached to the same Rocket instance, because `ReCaptcha<V3>` and `ReCaptcha<V2>` are separate types.
69
70## The client's IP address
71
72[`ReCaptcha::verify`] can report the client's IP address to Google along with the token. Pass `None` to leave it out, or a [`ClientIp`] from the re-exported [`rocket_client_addr`] crate, which needs its `ClientIpConfig` in Rocket's managed state.
73
74```rust,no_run
75#[macro_use]
76extern crate rocket;
77
78use rocket::{State, form::Form};
79use rocket_recaptcha_v3::{
80    ClientIp, ReCaptcha, ReCaptchaToken,
81    rocket_client_addr::{ClientIpConfig, IpCidr},
82};
83
84#[derive(FromForm)]
85struct LoginModel {
86    recaptcha_token: ReCaptchaToken,
87}
88
89#[post("/login", data = "<model>")]
90async fn login_post(
91    recaptcha: &State<ReCaptcha>,
92    client_ip: &ClientIp,
93    model: Form<LoginModel>,
94) -> &'static str {
95    match recaptcha.verify(&model.recaptcha_token, Some(client_ip)).await {
96        Ok(verification) if verification.score > 0.7 => "Hello, human!",
97        Ok(_) => "You are probably not a human.",
98        Err(_) => "Please try again.",
99    }
100}
101
102#[rocket::main]
103async fn main() -> Result<(), rocket::Error> {
104    let client_ip_config = ClientIpConfig::builder()
105        .trusted_proxies()
106        .proxy("10.0.0.0/24".parse::<IpCidr>().unwrap())
107        .build()
108        .unwrap();
109
110    rocket::build()
111        .attach(ReCaptcha::fairing())
112        .manage(client_ip_config)
113        .mount("/", routes![login_post])
114        .launch()
115        .await?;
116
117    Ok(())
118}
119```
120*/
121
122mod errors;
123mod fairing;
124mod models;
125mod verification;
126
127use std::{
128    borrow::Cow, error::Error, fmt::Debug, marker::PhantomData, sync::LazyLock, time::Duration,
129};
130
131pub use chrono;
132pub use errors::{ReCaptchaError, ReCaptchaErrorCode};
133pub use fairing::ReCaptchaFairing;
134pub use models::*;
135use reqwest::Client;
136pub use rocket_client_addr::{self, ClientIp};
137use validators::prelude::*;
138pub use validators::{self, errors::RegexError};
139pub use verification::ReCaptchaVerification;
140use verification::ReCaptchaVerificationInner;
141
142/// The endpoint of the `siteverify` API, which is what a `ReCaptcha` instance uses by default.
143pub const API_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
144
145/// An alternative endpoint of the `siteverify` API, for regions where `google.com` cannot be reached.
146pub const API_URL_RECAPTCHA_NET: &str = "https://www.recaptcha.net/recaptcha/api/siteverify";
147
148/// A whole request to the `siteverify` API should not outlive a page load, so this is deliberately short.
149const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
150
151/// Every `ReCaptcha` instance shares this client, so that one connection pool serves the whole process.
152static DEFAULT_CLIENT: LazyLock<Client> = LazyLock::new(|| {
153    Client::builder().timeout(DEFAULT_TIMEOUT).build().expect("cannot build an HTTP client")
154});
155
156mod sealed {
157    pub trait Sealed {}
158}
159
160/// A version of reCAPTCHA. This trait is sealed, and [`V3`] and [`V2`] are its only implementations.
161pub trait ReCaptchaVariant: sealed::Sealed + Debug + Clone + Sync + Send + 'static {
162    /// The name of the Rocket configuration table this version reads its keys from.
163    const VERSION_STR: &'static str;
164    /// The name this version's fairing reports to Rocket.
165    const FAIRING_NAME: &'static str;
166}
167
168#[derive(Debug, Clone, Copy)]
169/// reCAPTCHA v3, which scores a request instead of challenging the user.
170pub struct V3;
171
172impl sealed::Sealed for V3 {}
173
174impl ReCaptchaVariant for V3 {
175    const FAIRING_NAME: &'static str = "reCAPTCHA v3";
176    const VERSION_STR: &'static str = "v3";
177}
178
179#[derive(Debug, Clone, Copy)]
180/// reCAPTCHA v2, which challenges the user and reports no score.
181pub struct V2;
182
183impl sealed::Sealed for V2 {}
184
185impl ReCaptchaVariant for V2 {
186    const FAIRING_NAME: &'static str = "reCAPTCHA v2";
187    const VERSION_STR: &'static str = "v2";
188}
189
190#[derive(Debug, Clone)]
191/// A pair of reCAPTCHA keys which can verify reCAPTCHA tokens.
192pub struct ReCaptcha<V: ReCaptchaVariant = V3> {
193    html_key:   Option<ReCaptchaKey>,
194    secret_key: ReCaptchaKey,
195    client:     Client,
196    api_url:    Cow<'static, str>,
197    phantom:    PhantomData<V>,
198}
199
200impl<V: ReCaptchaVariant> ReCaptcha<V> {
201    #[inline]
202    /// You should use the Rocket fairing mechanism instead of invoking this method to create a `ReCaptcha` instance.
203    pub fn new(html_key: Option<ReCaptchaKey>, secret_key: ReCaptchaKey) -> ReCaptcha<V> {
204        ReCaptcha {
205            html_key,
206            secret_key,
207            client: DEFAULT_CLIENT.clone(),
208            api_url: Cow::from(API_URL),
209            phantom: PhantomData,
210        }
211    }
212
213    #[inline]
214    /// You should use the Rocket fairing mechanism instead of invoking this method to create a `ReCaptcha` instance.
215    pub fn parse_str<S1: AsRef<str>, S2: AsRef<str>>(
216        html_key: Option<S1>,
217        secret_key: S2,
218    ) -> Result<ReCaptcha<V>, RegexError> {
219        #[allow(clippy::manual_map)]
220        let html_key = match html_key {
221            Some(html_key) => Some(ReCaptchaKey::parse_str(html_key.as_ref())?),
222            None => None,
223        };
224
225        let secret_key = ReCaptchaKey::parse_str(secret_key.as_ref())?;
226
227        Ok(ReCaptcha::<V>::new(html_key, secret_key))
228    }
229
230    #[inline]
231    /// You should use the Rocket fairing mechanism instead of invoking this method to create a `ReCaptcha` instance.
232    pub fn parse_string<S1: Into<String>, S2: Into<String>>(
233        html_key: Option<S1>,
234        secret_key: S2,
235    ) -> Result<ReCaptcha<V>, RegexError> {
236        #[allow(clippy::manual_map)]
237        let html_key = match html_key {
238            Some(html_key) => Some(ReCaptchaKey::parse_string(html_key.into())?),
239            None => None,
240        };
241
242        let secret_key = ReCaptchaKey::parse_string(secret_key.into())?;
243
244        Ok(ReCaptcha::<V>::new(html_key, secret_key))
245    }
246
247    #[inline]
248    /// Return the key the front-end script needs, which is not set unless the configuration provides it.
249    pub fn html_key(&self) -> Option<&ReCaptchaKey> {
250        self.html_key.as_ref()
251    }
252
253    #[inline]
254    /// Return the key the `siteverify` API is called with.
255    pub fn secret_key(&self) -> &ReCaptchaKey {
256        &self.secret_key
257    }
258
259    #[inline]
260    /// Return the endpoint of the `siteverify` API this instance calls.
261    pub fn api_url(&self) -> &str {
262        self.api_url.as_ref()
263    }
264
265    #[inline]
266    /// Call another endpoint of the `siteverify` API, such as [`API_URL_RECAPTCHA_NET`].
267    pub fn set_api_url<S: Into<Cow<'static, str>>>(&mut self, api_url: S) {
268        self.api_url = api_url.into();
269    }
270
271    #[inline]
272    /// Call the `siteverify` API with your own HTTP client, to control its timeout or its proxy.
273    pub fn set_client(&mut self, client: Client) {
274        self.client = client;
275    }
276}
277
278impl ReCaptcha {
279    #[inline]
280    /// Create a `ReCaptchaFairing<V3>` instance to load reCAPTCHA v3 keys. It will mount a `ReCaptcha<V3>` (`ReCaptcha`) instance on Rocket.
281    pub fn fairing() -> ReCaptchaFairing<V3> {
282        ReCaptchaFairing::<V3>::new()
283    }
284
285    #[inline]
286    /// Create a `ReCaptchaFairing<V2>` instance to load reCAPTCHA v2 keys. It will mount a `ReCaptcha<V2>` instance on Rocket.
287    pub fn fairing_v2() -> ReCaptchaFairing<V2> {
288        ReCaptchaFairing::<V2>::new()
289    }
290}
291
292impl<V: ReCaptchaVariant> ReCaptcha<V> {
293    /// Ask the `siteverify` API whether a reCAPTCHA token is genuine.
294    ///
295    /// Reporting `remote_ip` is optional, and lets Google take the client's address into account.
296    pub async fn verify(
297        &self,
298        recaptcha_token: &ReCaptchaToken,
299        remote_ip: Option<&ClientIp>,
300    ) -> Result<ReCaptchaVerification, ReCaptchaError> {
301        // The parameters go into the request body, so that the secret key never reaches a URL.
302        let mut form: Vec<(&str, Cow<str>)> = Vec::with_capacity(3);
303
304        form.push(("secret", Cow::from(self.secret_key.as_str())));
305        form.push(("response", Cow::from(recaptcha_token.as_str())));
306
307        if let Some(remote_ip) = remote_ip {
308            form.push(("remoteip", Cow::from(remote_ip.ip().to_string())));
309        }
310
311        let response = self
312            .client
313            .post(self.api_url.as_ref())
314            .form(&form)
315            .send()
316            .await
317            .map_err(|error| ReCaptchaError::Request(describe_error(&error)))?;
318
319        let status = response.status();
320
321        if !status.is_success() {
322            return Err(ReCaptchaError::UnexpectedStatusCode(status.as_u16()));
323        }
324
325        let result: ReCaptchaVerificationInner = response
326            .json()
327            .await
328            .map_err(|error| ReCaptchaError::UnexpectedResponse(describe_error(&error)))?;
329
330        if !result.success {
331            return Err(ReCaptchaError::ErrorCodes(
332                result
333                    .error_codes
334                    .unwrap_or_default()
335                    .into_iter()
336                    .map(ReCaptchaErrorCode::from)
337                    .collect(),
338            ));
339        }
340
341        let challenge_ts = result.challenge_ts.ok_or_else(|| {
342            ReCaptchaError::UnexpectedResponse("There is no `challenge_ts` field.".to_string())
343        })?;
344
345        let hostname = result.hostname.ok_or_else(|| {
346            ReCaptchaError::UnexpectedResponse("There is no `hostname` field.".to_string())
347        })?;
348
349        Ok(ReCaptchaVerification {
350            // reCAPTCHA v2 scores nothing, so a challenge it accepted is fully human.
351            score: result.score.unwrap_or(1.0),
352            action: result.action,
353            challenge_ts,
354            hostname,
355        })
356    }
357}
358
359/// Flatten an error and its sources into one line, because only the message survives into a `ReCaptchaError`.
360fn describe_error(error: &dyn Error) -> String {
361    let mut text = error.to_string();
362    let mut source = error.source();
363
364    while let Some(error) = source {
365        text.push_str(": ");
366        text.push_str(&error.to_string());
367
368        source = error.source();
369    }
370
371    text
372}