salvo_captcha/
lib.rs

1// Copyright (c) 2024-2025, Awiteb <a@4rs.nl>
2//     A captcha middleware for Salvo framework.
3//
4// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
5// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
6// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
7// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
8// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
9// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
10// THE SOFTWARE.
11
12#![doc = include_str!("../README.md")]
13#![deny(warnings)]
14#![deny(missing_docs)]
15#![deny(clippy::print_stdout)]
16#![cfg_attr(docsrs, feature(doc_cfg))]
17
18mod captcha_gen;
19mod finder;
20mod storage;
21
22use std::{sync::Arc, time::Duration};
23
24use salvo_core::{
25    handler::{none_skipper, Skipper},
26    Depot, FlowCtrl, Handler, Request, Response,
27};
28pub use {captcha_gen::*, finder::*, storage::*};
29
30/// Key used to insert the captcha state into the depot
31pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
32
33/// The captcha middleware
34///
35/// The captcha middleware is used to check the captcha token and answer from
36/// the request. You can use the [`CaptchaBuilder`] to create a new captcha
37/// middleware.
38///
39/// ## Note
40/// You need to generate the captcha token and answer before, then the captcha
41/// middleware will check the token and answer from the request using the finder
42/// and storage you provided. The captcha middleware will insert the
43/// [`CaptchaState`] into the depot, you can get the captcha state from the
44/// depot using the [`CaptchaDepotExt::get_captcha_state`] trait, which is
45/// implemented for the [`Depot`].
46///
47/// Check the [`examples`](https://git.4rs.nl/awiteb/salvo-captcha.git/tree/examples) for more information.
48#[non_exhaustive]
49pub struct Captcha<S, F>
50where
51    S: CaptchaStorage,
52    F: CaptchaFinder,
53{
54    /// The captcha finder, used to find the captcha token and answer from the request.
55    finder: F,
56    /// The storage of the captcha, used to store and get the captcha token and answer.
57    storage: Arc<S>,
58    /// The skipper of the captcha, used to skip the captcha check.
59    skipper: Box<dyn Skipper>,
60    /// The case sensitive of the captcha answer.
61    case_sensitive: bool,
62}
63
64/// The captcha states of the request
65#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
66pub enum CaptchaState {
67    /// The captcha check is skipped. This depends on the skipper.
68    #[default]
69    Skipped,
70    /// The captcha is checked and passed. If the captcha is passed, it will be cleared from the storage.
71    Passed,
72    /// Can't find the captcha token in the request
73    TokenNotFound,
74    /// Can't find the captcha answer in the request
75    AnswerNotFound,
76    /// Can't find the captcha token in the storage or the token is wrong (not valid string)
77    WrongToken,
78    /// Can't find the captcha answer in the storage or the answer is wrong (not valid string)
79    WrongAnswer,
80    /// Storage error
81    StorageError,
82}
83
84/// The [`Captcha`] builder
85pub struct CaptchaBuilder<S, F>
86where
87    S: CaptchaStorage,
88    F: CaptchaFinder,
89{
90    storage: S,
91    finder: F,
92    captcha_expired_after: Duration,
93    clean_interval: Duration,
94    skipper: Box<dyn Skipper>,
95    case_sensitive: bool,
96}
97
98impl<S, F> CaptchaBuilder<Arc<S>, F>
99where
100    S: CaptchaStorage,
101    F: CaptchaFinder,
102{
103    /// Create a new [`CaptchaBuilder`] with the given storage and finder.
104    pub fn new(storage: Arc<S>, finder: F) -> Self {
105        CaptchaBuilder {
106            storage,
107            finder,
108            captcha_expired_after: Duration::from_secs(60 * 5),
109            clean_interval: Duration::from_secs(60),
110            skipper: Box::new(none_skipper),
111            case_sensitive: true,
112        }
113    }
114
115    /// Remove the case sensitive of the captcha, default is case sensitive.
116    ///
117    /// This will make the captcha case insensitive, for example, the answer "Hello" will be the same as "hello".
118    pub fn case_insensitive(mut self) -> Self {
119        self.case_sensitive = false;
120        self
121    }
122
123    /// Set the duration after which the captcha will be expired, default is 5 minutes.
124    ///
125    /// After the captcha is expired, it will be removed from the storage, and the user needs to get a new captcha.
126    pub fn expired_after(mut self, expired_after: impl Into<Duration>) -> Self {
127        self.captcha_expired_after = expired_after.into();
128        self
129    }
130
131    /// Set the interval to clean the expired captcha, default is 1 minute.
132    ///
133    /// The expired captcha will be removed from the storage every interval.
134    pub fn clean_interval(mut self, interval: impl Into<Duration>) -> Self {
135        self.clean_interval = interval.into();
136        self
137    }
138
139    /// Set the skipper of the captcha, default without skipper.
140    ///
141    /// The skipper is used to skip the captcha check, for example, you can skip the captcha check for the admin user.
142    pub fn skipper(mut self, skipper: impl Skipper) -> Self {
143        self.skipper = Box::new(skipper);
144        self
145    }
146
147    /// Build the [`Captcha`] with the given configuration.
148    pub fn build(self) -> Captcha<S, F> {
149        Captcha::new(
150            self.storage,
151            self.finder,
152            self.captcha_expired_after,
153            self.clean_interval,
154            self.skipper,
155            self.case_sensitive,
156        )
157    }
158}
159
160impl<S, F> Captcha<S, F>
161where
162    S: CaptchaStorage,
163    F: CaptchaFinder,
164{
165    /// Create a new Captcha
166    fn new(
167        storage: Arc<S>,
168        finder: F,
169        captcha_expired_after: Duration,
170        clean_interval: Duration,
171        skipper: Box<dyn Skipper>,
172        case_sensitive: bool,
173    ) -> Self {
174        let task_storage = Arc::clone(&storage);
175
176        tokio::spawn(async move {
177            loop {
178                if let Err(err) = task_storage.clear_expired(captcha_expired_after).await {
179                    log::error!("Captcha storage error: {err}")
180                }
181                tokio::time::sleep(clean_interval).await;
182            }
183        });
184
185        Self {
186            finder,
187            storage,
188            skipper,
189            case_sensitive,
190        }
191    }
192}
193
194/// The captcha extension of the depot.
195/// Used to get the captcha info from the depot.
196pub trait CaptchaDepotExt {
197    /// Get the captcha state from the depot
198    fn get_captcha_state(&self) -> CaptchaState;
199}
200
201impl CaptchaDepotExt for Depot {
202    fn get_captcha_state(&self) -> CaptchaState {
203        self.get(CAPTCHA_STATE_KEY).cloned().unwrap_or_default()
204    }
205}
206
207#[salvo_core::async_trait]
208impl<S, F> Handler for Captcha<S, F>
209where
210    S: CaptchaStorage,
211    F: CaptchaFinder,
212{
213    async fn handle(
214        &self,
215        req: &mut Request,
216        depot: &mut Depot,
217        _: &mut Response,
218        _: &mut FlowCtrl,
219    ) {
220        if self.skipper.as_ref().skipped(req, depot) {
221            log::info!("Captcha check is skipped");
222            depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped);
223            return;
224        }
225
226        let token = match self.finder.find_token(req).await {
227            Some(Some(token)) => token,
228            Some(None) => {
229                log::info!("Captcha token is not found in request");
230                depot.insert(CAPTCHA_STATE_KEY, CaptchaState::TokenNotFound);
231                return;
232            }
233            None => {
234                log::error!("Invalid token found in request");
235                depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
236                return;
237            }
238        };
239
240        let answer = match self.finder.find_answer(req).await {
241            Some(Some(answer)) => answer,
242            Some(None) => {
243                log::info!("Captcha answer is not found in request");
244                depot.insert(CAPTCHA_STATE_KEY, CaptchaState::AnswerNotFound);
245                return;
246            }
247            None => {
248                log::error!("Invalid answer found in request");
249                depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
250                return;
251            }
252        };
253
254        match self.storage.get_answer(&token).await {
255            Ok(Some(captch_answer)) => {
256                log::info!("Captcha answer is exist in storage for token: {token}");
257                if (captch_answer == answer && self.case_sensitive)
258                    || captch_answer.eq_ignore_ascii_case(&answer)
259                {
260                    log::info!("Captcha answer is correct for token: {token}");
261                    self.storage.clear_by_token(&token).await.ok();
262                    depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Passed);
263                } else {
264                    log::info!("Captcha answer is wrong for token: {token}");
265                    depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongAnswer);
266                }
267            }
268            Ok(None) => {
269                log::info!("Captcha answer is not exist in storage for token: {token}");
270                depot.insert(CAPTCHA_STATE_KEY, CaptchaState::WrongToken);
271            }
272            Err(err) => {
273                log::error!("Failed to get captcha answer from storage: {err}");
274                depot.insert(CAPTCHA_STATE_KEY, CaptchaState::StorageError);
275            }
276        };
277    }
278}