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}