Compare commits

...

14 commits

Author SHA1 Message Date
a294ebfe96
chore: Update README.md
Some checks failed
Rust CI / Rust CI (push) Failing after 1m12s
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:22 +03:00
29b4b80071
feat: Add case insensitive option to the captcha
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:21 +03:00
b67ebe9aea
chore: Use the simple_generator feature for simple_example
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:21 +03:00
bc6c08b3ec
chore: Upgrade simple_login example to the new update
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:21 +03:00
472e712c9a
chore: Update the dependencies and features
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:21 +03:00
4240da2409
feat: Function new_captcha for CaptchaStorage trait to create and store captcha
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:21 +03:00
c6956ad729
feat: New SimpleGenerator to generate the captcha
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:20 +03:00
28425d939f
feat: New CaptchaBuilder to build Captcha struct
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 23:30:16 +03:00
27d985114a
feat: Add new memory storage
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 16:46:02 +03:00
e21ce0d524
refactor!: Refactor CaptchaStorage trait
- Remove the requirement for the `Clone` trait in the `Self`
- Remove the `Display` and `Debug` trait and require `Error` trait
instead in `CaptchaStorage::Error`

Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 12:23:57 +00:00
25f90ecfaf
chore: Add 'static lifetime to CaptchaFinder trait
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-12 12:04:31 +00:00
110a8728e1
feat: Add query finder
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-11 12:09:28 +00:00
0ad044904a
test: Add more header tests
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-11 11:50:39 +00:00
6520ab62e5
test: Add more form tests
Signed-off-by: Awiteb <a@4rs.nl>
2024-08-11 11:48:20 +00:00
14 changed files with 700 additions and 129 deletions

View file

@ -14,14 +14,17 @@ categories = ["web-programming", "network-programming"]
[dependencies]
cacache = { version = "13", default-features = false, features = ["tokio-runtime", "mmap"], optional = true }
captcha = { version = "0.0.9", default-features = false }
log = "0.4"
salvo_core = { version = ">= 0.65, < 0.69", default-features = false }
uuid = { version = "1.7", features = ["v4"], optional = true }
log = "0.4"
uuid = { version = "1", features = ["v4"] }
tokio = { version = "1", default-features = false }
cacache = { version = "13", default-features = false, features = ["tokio-runtime", "mmap"], optional = true }
captcha = { version = "0.0.9", default-features = false, optional = true}
either = { version = "1.13.0", default-features = false }
[features]
cacache-storage = ["dep:cacache", "dep:uuid"]
cacache-storage = ["dep:cacache"]
simple_generator = ["dep:captcha"]
[dev-dependencies]
tempfile = "3.9"
@ -32,4 +35,4 @@ rstest = "0.22.0"
[[example]]
name = "simple_login"
required-features = ["cacache-storage"]
required-features = ["simple_generator"]

View file

@ -19,7 +19,7 @@ _default:
# Run the CI (Local use only)
@ci:
cargo fmt --all --check
cargo build -F 'cacache-storage' --example simple_login
cargo build -F 'simple_generator' --example simple_login
cargo clippy --workspace --all-targets --examples --tests --all-features -- -D warnings
cargo nextest run --workspace --all-targets --all-features
@{{JUST_EXECUTABLE}} msrv

View file

@ -2,7 +2,7 @@
# salvo-captcha
A captcha middleware for [salvo](salvo.rs) framework. It uses [`captcha`](https://github.com/daniel-e/captcha) crate to generate captcha images.
A captcha middleware for [salvo](salvo.rs) framework. With fully customizable captchas generator, storage, and finders
[![salvo-captcha-video](https://i.suar.me/9NjJ1)](https://ibb.co/XVRVMZj)
@ -14,10 +14,10 @@ First, add the following to your `Cargo.toml`:
```toml
[dependencies]
salvo-captcha = "0.1"
salvo-captcha = "0.2"
```
Or use [cargo-add](https://doc.rust-lang.org/cargo/commands/cargo-add.html) to add the dependency to your `Cargo.toml`:
Or use [`cargo add`] to add the dependency to your `Cargo.toml`:
```sh
$ cargo add salvo-captcha
@ -25,39 +25,59 @@ $ cargo add salvo-captcha
## Usage
See the [examples](examples) directory for a complete example.
See the [examples] directory for a complete example. You can also see the implemented generators, storages, and finders in the source code.
## Storage
The storage of the captcha is handled by a [`CaptchaStore`] trait. You can implement your own storage or use the [`cacache-rs`] by enabling the `cacache-storage` feature.
There is a default storage, which is [`MemoryStorage`] it's a simple in-memory storage. You can implement your own storage by implementing the [`CaptchaStorage`] trait.
### Cacache Storage
A high-performance, concurrent, content-addressable disk cache. The storage is based on [`cacache-rs`] crate. to use it, you need to enable the `cacache-storage` feature.
```toml
[dependencies]
salvo-captcha = { version = "0.1", features = ["cacache-storage"] }
salvo-captcha = { version = "0.2", features = ["cacache-storage"] }
```
## Captcha name and difficulty
## Captcha Finder
We provide fully customizable query parameters, form fields, and headers to find the captcha token and the captcha answer. You can implement your own finder by implementing the [`CaptchaFinder`] trait.
## Captcha Generator
We provide [`SimpleCaptchaGenerator`] which is a simple captcha generator based on the [`captcha`] crate. You can implement your own captcha generator by implementing the [`CaptchaGenerator`] trait.
### Captcha name and difficulty
In this table, you can see the difference between the difficulties and the name of the captcha.
| Name | Easy | Medium | Hard |
| :----: | :----------------------------------: | :----------------------------------: | :----------------------------------: |
| Amelia | ![Simple](https://i.suar.me/1JaxG/s) | ![Simple](https://i.suar.me/l7zBl/s) | ![Simple](https://i.suar.me/qXAlx/s) |
| Lucy | ![Simple](https://i.suar.me/edwBG/s) | ![Simple](https://i.suar.me/NJmg0/s) | ![Simple](https://i.suar.me/OJK7M/s) |
| Mila | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) |
| Name | Easy | Medium | Hard |
| :-------------: | :----------------------------------: | :----------------------------------: | :----------------------------------: |
| Normal | ![Simple](https://i.suar.me/edwBG/s) | ![Simple](https://i.suar.me/NJmg0/s) | ![Simple](https://i.suar.me/OJK7M/s) |
| SlightlyTwisted | ![Simple](https://i.suar.me/1JaxG/s) | ![Simple](https://i.suar.me/l7zBl/s) | ![Simple](https://i.suar.me/qXAlx/s) |
| VeryTwisted | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) |
## Mirrors
- Github (https://github.com/TheAwiteb/salvo-captcha)
- Codeberg (https://codeberg.org/awiteb/salvo-captcha)
- Github (<https://github.com/TheAwiteb/salvo-captcha>)
- Codeberg (<https://codeberg.org/awiteb/salvo-captcha>)
### Main Repository
- My Git (https://git.4rs.nl/awiteb/salvo-captcha)
- My Git (<https://git.4rs.nl/awiteb/salvo-captcha>)
## License
This project is licensed under the MIT license for more details see [LICENSE](LICENSE) or http://opensource.org/licenses/MIT.
This project is licensed under the MIT license for more details see [LICENSE] or <http://opensource.org/licenses/MIT>.
[`CaptchaStore`]: https://docs.rs/salvo_captcha/0.1.0/salvo_captcha/trait.CaptchaStore.html
[`MemoryStorage`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/struct.MemoryStorage.html
[`CaptchaStorage`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/trait.CaptchaStorage.html
[`cacache-rs`]: https://github.com/zkat/cacache-rs
[`SimpleCaptchaGenerator`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/struct.SimpleCaptchaGenerator.html
[`CaptchaGenerator`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/trait.CaptchaGenerator.html
[`CaptchaFinder`]: https://docs.rs/salvo-captcha/latest/salvo_captcha/trait.CaptchaFinder.html
[examples]: https://git.4rs.nl/awiteb/salvo-captcha/src/branch/master/examples
[`captcha`]: https://github.com/daniel-e/captcha
[LICENSE]: https://git.4rs.nl/awiteb/salvo-captcha/src/branch/master/LICENSE
[`cargo add`]: https://doc.rust-lang.org/cargo/commands/cargo-add.html

View file

@ -4,7 +4,7 @@
//
// Run the example with `cargo run --example simple_login --features cacache-storage`
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use base64::{engine::GeneralPurpose, Engine};
use salvo::prelude::*;
@ -16,18 +16,22 @@ const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
base64::engine::general_purpose::PAD,
);
const SIMPLE_GENERATOR: SimpleGenerator =
SimpleGenerator::new(CaptchaName::Normal, CaptchaDifficulty::Medium);
#[handler]
async fn index(res: &mut Response, depot: &mut Depot) {
// Get the captcha from the depot
let captcha_storage = depot.obtain::<Arc<CacacheStorage>>().unwrap();
let captcha_storage = depot.obtain::<Arc<MemoryStorage>>().unwrap();
// Create a new captcha
let (token, image) = captcha_storage
.as_ref()
.new_captcha(CaptchaName::Mila, CaptchaDifficulty::Medium)
.await
.expect("Failed to save captcha")
.expect("Failed to create captcha");
let Ok((token, image)) = captcha_storage.new_captcha(SIMPLE_GENERATOR).await else {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(Text::Html(
"<html><body><h1>Server Error 500</h1></body></html>",
));
return;
};
// Convert the image to base64
let image = BASE_64_ENGINE.encode(image);
@ -64,36 +68,16 @@ async fn auth(req: &mut Request, res: &mut Response, depot: &mut Depot) {
#[tokio::main]
async fn main() {
let captcha_middleware = Captcha::new(
CacacheStorage::new("./captcha-cache"),
CaptchaFormFinder::new(),
)
.skipper(|req: &mut Request, _: &Depot| {
// Skip the captcha if the request path is /skipped
req.uri().path() == "/skipped"
});
let captcha_storage = Arc::new(captcha_middleware.storage().clone());
// Set up a task to clean the expired captcha, just call the `clear_expired` function from the storage
let captcha_cleaner = tokio::spawn({
let cleanner_storage = captcha_storage.clone();
async move {
let captcha_expired_after = Duration::from_secs(60 * 5);
let clean_interval = Duration::from_secs(60);
// Just this loop, to clean the expired captcha every 60 seconds, each captcha will be expired after 5 minutes
loop {
cleanner_storage
.clear_expired(captcha_expired_after)
.await
.ok(); // You should log this error
tokio::time::sleep(clean_interval).await;
}
}
});
let captcha_storage = Arc::new(MemoryStorage::new());
let captcha_middleware =
CaptchaBuilder::new(Arc::clone(&captcha_storage), CaptchaFormFinder::new())
// Skip the captcha if the request path is /skipped
.skipper(|req: &mut Request, _: &Depot| req.uri().path() == "/skipped")
.case_insensitive()
.build();
let router = Router::new()
.hoop(affix::inject(captcha_storage.clone()))
.hoop(affix::inject(captcha_storage))
.push(Router::with_path("/").get(index))
.push(
Router::new()
@ -103,9 +87,7 @@ async fn main() {
);
let acceptor = TcpListener::new(("127.0.0.1", 5800)).bind().await;
println!("Starting server on http://127.0.0.1:5800");
Server::new(acceptor).serve(router).await;
captcha_cleaner.await.ok();
}
fn index_page(captcha_image: String, captcha_token: String) -> String {

View file

@ -1,40 +0,0 @@
// Copyright (c) 2024, Awiteb <a@4rs.nl>
// A captcha middleware for Salvo framework.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
use crate::{CaptchaDifficulty, CaptchaName, CaptchaStorage};
/// Captcha generator, used to generate a new captcha image. This trait are implemented for all [`CaptchaStorage`].
pub trait CaptchaGenerator: CaptchaStorage {
/// Create a new captcha image and return the token and the image encoded as png. Will return None if the captcha crate failed to create the captcha.
///
/// The returned captcha image is 220x110 pixels.
///
/// For more information about the captcha name and difficulty, see the [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty).
fn new_captcha(
&self,
name: CaptchaName,
difficulty: CaptchaDifficulty,
) -> impl std::future::Future<Output = Result<Option<(String, Vec<u8>)>, Self::Error>> + Send
{
async {
let Some((captcha_answer, captcha_image)) =
captcha::by_name(difficulty, name).as_tuple()
else {
return Ok(None);
};
let token = self.store_answer(captcha_answer).await?;
Ok(Some((token, captcha_image)))
}
}
}
impl<T> CaptchaGenerator for T where T: CaptchaStorage {}

27
src/captcha_gen/mod.rs Normal file
View file

@ -0,0 +1,27 @@
// Copyright (c) 2024, Awiteb <a@4rs.nl>
// A captcha middleware for Salvo framework.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#[cfg(feature = "simple_generator")]
mod simple_generator;
#[cfg(feature = "simple_generator")]
pub use simple_generator::*;
/// Captcha generator, used to generate a new captcha image and answer.
pub trait CaptchaGenerator: Send {
/// The error type of the captcha generator
type Error: std::error::Error;
/// Create a new captcha image and return the answer and the image encoded as png
fn new_captcha(
&self,
) -> impl std::future::Future<Output = Result<(String, Vec<u8>), Self::Error>> + Send;
}

View file

@ -0,0 +1,107 @@
// Copyright (c) 2024, Awiteb <a@4rs.nl>
// A captcha middleware for Salvo framework.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
use crate::CaptchaGenerator;
use std::fmt::Display;
/// Supported captcha names
///
/// See [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty) for more information.
#[derive(Debug, Clone, Copy)]
pub enum CaptchaName {
/// Plain text, without any distortion
Normal,
/// Slightly twisted text
SlightlyTwisted,
/// Very twisted text
VeryTwisted,
}
/// Supported captcha difficulties
///
/// See [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty) for more information.
#[derive(Debug, Clone, Copy)]
pub enum CaptchaDifficulty {
/// Easy to read text
Easy,
/// Medium difficulty text
Medium,
/// Hard to read text
Hard,
}
impl From<CaptchaName> for captcha::CaptchaName {
/// Function to convert the [`CaptchaName`] to the [`captcha::CaptchaName`]
fn from(value: CaptchaName) -> Self {
match value {
CaptchaName::Normal => Self::Lucy,
CaptchaName::SlightlyTwisted => Self::Amelia,
CaptchaName::VeryTwisted => Self::Mila,
}
}
}
impl From<CaptchaDifficulty> for captcha::Difficulty {
/// Function to convert the [`CaptchaDifficulty`] to the [`captcha::Difficulty`]
fn from(value: CaptchaDifficulty) -> captcha::Difficulty {
match value {
CaptchaDifficulty::Easy => Self::Easy,
CaptchaDifficulty::Medium => Self::Medium,
CaptchaDifficulty::Hard => Self::Hard,
}
}
}
#[derive(Debug)]
/// Error type for the [`SimpleGenerator`]
pub enum SimpleGeneratorError {
/// Failed to encode the captcha to png image
FaildEncodedToPng,
}
impl Display for SimpleGeneratorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Faild to encode the captcha to png image")
}
}
impl std::error::Error for SimpleGeneratorError {}
/// The simple captcha generator
pub struct SimpleGenerator {
name: CaptchaName,
difficulty: CaptchaDifficulty,
}
impl SimpleGenerator {
/// Create new [`SimpleGenerator`] instance
pub const fn new(name: CaptchaName, difficulty: CaptchaDifficulty) -> Self {
Self { name, difficulty }
}
}
impl CaptchaGenerator for SimpleGenerator {
type Error = SimpleGeneratorError;
/// The returned captcha image is 220x110 pixels in png format.
///
/// For more information about the captcha name and difficulty, see the [`README.md`](https://git.4rs.nl/awiteb/salvo-captcha/#captcha-name-and-difficulty).
async fn new_captcha(&self) -> Result<(String, Vec<u8>), Self::Error> {
let Some((captcha_answer, captcha_image)) =
captcha::by_name(self.difficulty.into(), self.name.into()).as_tuple()
else {
return Err(SimpleGeneratorError::FaildEncodedToPng);
};
Ok((captcha_answer, captcha_image))
}
}

View file

@ -110,6 +110,24 @@ mod tests {
Some(Some("token")),
Some(Some("answer"))
)]
#[case::only_token(
None,
None,
Some(("captcha_token", "token")),
None,
"application/x-www-form-urlencoded",
Some(Some("token")),
None
)]
#[case::only_answer(
None,
None,
None,
Some(("captcha_answer", "answer")),
"application/x-www-form-urlencoded",
None,
Some(Some("answer"))
)]
#[case::custom_not_found(
Some("custom_token"),
Some("custom_answer"),

View file

@ -96,6 +96,22 @@ mod tests {
Some(Some("token")),
Some(Some("answer"))
)]
#[case::only_token(
None,
None,
Some(("x-captcha-token", "token")),
None,
Some(Some("token")),
None
)]
#[case::only_answer(
None,
None,
None,
Some(("x-captcha-answer", "answer")),
None,
Some(Some("answer"))
)]
#[case::custom_not_found(Some("custom-token"), Some("custom-answer"), None, None, None, None)]
#[case::custom_not_found_with_headers(
Some("custom-token"),

View file

@ -13,12 +13,14 @@ use salvo_core::http::Request;
mod form_finder;
mod header_finder;
mod query_finder;
pub use form_finder::*;
pub use header_finder::*;
pub use query_finder::*;
/// Trait to find the captcha token and answer from the request.
pub trait CaptchaFinder: Send + Sync {
pub trait CaptchaFinder: Send + Sync + 'static {
/// Find the captcha token from the request.
///
/// ### Returns

151
src/finder/query_finder.rs Normal file
View file

@ -0,0 +1,151 @@
// Copyright (c) 2024, Awiteb <a@4rs.nl>
// A captcha middleware for Salvo framework.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
use salvo_core::http::Request;
use crate::CaptchaFinder;
/// Find the captcha token and answer from the url query
#[derive(Debug)]
pub struct CaptchaQueryFinder {
/// The query name of the captcha token
///
/// Default: "c_t"
pub token_name: String,
/// The query name of the captcha answer
///
/// Default: "c_a"
pub answer_name: String,
}
impl CaptchaQueryFinder {
/// Create a new [`CaptchaQueryFinder`]
pub fn new() -> Self {
Self::default()
}
/// Set the token query name
pub fn token_name(mut self, token_name: String) -> Self {
self.token_name = token_name;
self
}
/// Set the answer query name
pub fn answer_name(mut self, answer_name: String) -> Self {
self.answer_name = answer_name;
self
}
}
impl Default for CaptchaQueryFinder {
/// Create a default [`CaptchaQueryFinder`] with:
/// - token_name: "c_t"
/// - answer_name: "c_a"
fn default() -> Self {
Self {
token_name: "c_t".to_string(),
answer_name: "c_a".to_string(),
}
}
}
impl CaptchaFinder for CaptchaQueryFinder {
async fn find_token(&self, req: &mut Request) -> Option<Option<String>> {
req.queries()
.get(&self.token_name)
.map(|o| Some(o.to_owned()))
}
async fn find_answer(&self, req: &mut Request) -> Option<Option<String>> {
req.queries()
.get(&self.answer_name)
.map(|o| Some(o.to_owned()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[rstest::rstest]
#[case::not_found(None, None, None, None, None, None)]
#[case::normal(
None,
None,
Some(("c_t", "token")),
Some(("c_a", "answer")),
Some(Some("token")),
Some(Some("answer"))
)]
#[case::custom_keys(
Some("cc_t"),
Some("cc_a"),
Some(("cc_t", "token")),
Some(("cc_a", "answer")),
Some(Some("token")),
Some(Some("answer"))
)]
#[case::only_token(
None,
None,
Some(("c_t", "token")),
None,
Some(Some("token")),
None
)]
#[case::only_answer(None, None, None, Some(("c_a", "ans")), None, Some(Some("ans")))]
#[case::custom_not_found(Some("cc_t"), Some("cc_a"), None, None, None, None)]
#[case::custom_not_found_with_query(
Some("cc_t"),
Some("cc_a"),
Some(("c_t", "token")),
Some(("c_a", "answer")),
None,
None
)]
async fn test_query_finder(
#[case] custom_token_key: Option<&'static str>,
#[case] custom_answer_key: Option<&'static str>,
#[case] token_key_val: Option<(&'static str, &'static str)>,
#[case] answer_key_val: Option<(&'static str, &'static str)>,
#[case] excepted_token: Option<Option<&'static str>>,
#[case] excepted_answer: Option<Option<&'static str>>,
) {
let mut req = Request::default();
let mut finder = CaptchaQueryFinder::new();
if let Some(token_key) = custom_token_key {
finder = finder.token_name(token_key.to_string())
}
if let Some(answer_key) = custom_answer_key {
finder = finder.answer_name(answer_key.to_string())
}
let queries = req.queries_mut();
if let Some((k, v)) = token_key_val {
queries.insert(k.to_owned(), v.to_owned());
}
if let Some((k, v)) = answer_key_val {
queries.insert(k.to_owned(), v.to_owned());
}
assert_eq!(
finder.find_token(&mut req).await,
excepted_token.map(|o| o.map(ToOwned::to_owned))
);
assert_eq!(
finder.find_answer(&mut req).await,
excepted_answer.map(|o| o.map(ToOwned::to_owned))
);
}
}

View file

@ -18,21 +18,19 @@ mod captcha_gen;
mod finder;
mod storage;
use std::{sync::Arc, time::Duration};
use salvo_core::{
handler::{none_skipper, Skipper},
Depot, FlowCtrl, Handler, Request, Response,
};
pub use {captcha_gen::*, finder::*, storage::*};
// Exports from other crates
pub use captcha::{CaptchaName, Difficulty as CaptchaDifficulty};
/// Key used to insert the captcha state into the depot
pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
/// Captcha struct, contains the token and answer.
#[non_exhaustive]
#[allow(clippy::type_complexity)]
pub struct Captcha<S, F>
where
S: CaptchaStorage,
@ -41,9 +39,11 @@ where
/// The captcha finder, used to find the captcha token and answer from the request.
finder: F,
/// The storage of the captcha, used to store and get the captcha token and answer.
storage: S,
storage: Arc<S>,
/// The skipper of the captcha, used to skip the captcha check.
skipper: Box<dyn Skipper>,
/// The case sensitive of the captcha answer.
case_sensitive: bool,
}
/// The captcha states of the request
@ -66,30 +66,114 @@ pub enum CaptchaState {
StorageError,
}
/// The [`Captcha`] builder
pub struct CaptchaBuilder<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
storage: S,
finder: F,
captcha_expired_after: Duration,
clean_interval: Duration,
skipper: Box<dyn Skipper>,
case_sensitive: bool,
}
impl<S, F> CaptchaBuilder<Arc<S>, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
/// Create a new [`CaptchaBuilder`] with the given storage and finder.
pub fn new(storage: Arc<S>, finder: F) -> Self {
CaptchaBuilder {
storage,
finder,
captcha_expired_after: Duration::from_secs(60 * 5),
clean_interval: Duration::from_secs(60),
skipper: Box::new(none_skipper),
case_sensitive: true,
}
}
/// Remove the case sensitive of the captcha, default is case sensitive.
///
/// This will make the captcha case insensitive, for example, the answer "Hello" will be the same as "hello".
pub fn case_insensitive(mut self) -> Self {
self.case_sensitive = false;
self
}
/// Set the duration after which the captcha will be expired, default is 5 minutes.
///
/// After the captcha is expired, it will be removed from the storage, and the user needs to get a new captcha.
pub fn expired_after(mut self, expired_after: impl Into<Duration>) -> Self {
self.captcha_expired_after = expired_after.into();
self
}
/// Set the interval to clean the expired captcha, default is 1 minute.
///
/// The expired captcha will be removed from the storage every interval.
pub fn clean_interval(mut self, interval: impl Into<Duration>) -> Self {
self.clean_interval = interval.into();
self
}
/// Set the skipper of the captcha, default without skipper.
///
/// The skipper is used to skip the captcha check, for example, you can skip the captcha check for the admin user.
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
self.skipper = Box::new(skipper);
self
}
/// Build the [`Captcha`] with the given configuration.
pub fn build(self) -> Captcha<S, F> {
Captcha::new(
self.storage,
self.finder,
self.captcha_expired_after,
self.clean_interval,
self.skipper,
self.case_sensitive,
)
}
}
impl<S, F> Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder,
{
/// Create a new Captcha
pub fn new(storage: S, finder: F) -> Self {
fn new(
storage: Arc<S>,
finder: F,
captcha_expired_after: Duration,
clean_interval: Duration,
skipper: Box<dyn Skipper>,
case_sensitive: bool,
) -> Self {
let task_storage = Arc::clone(&storage);
tokio::spawn(async move {
loop {
if let Err(err) = task_storage.clear_expired(captcha_expired_after).await {
log::error!("Captcha storage error: {err}")
}
tokio::time::sleep(clean_interval).await;
}
});
Self {
finder,
storage,
skipper: Box::new(none_skipper),
skipper,
case_sensitive,
}
}
/// Returns the captcha storage
pub fn storage(&self) -> &S {
&self.storage
}
/// Set the captcha skipper, the skipper will be used to check if the captcha check should be skipped.
pub fn skipper(mut self, skipper: impl Skipper) -> Self {
self.skipper = Box::new(skipper);
self
}
}
/// The captcha extension of the depot.
@ -109,7 +193,7 @@ impl CaptchaDepotExt for Depot {
impl<S, F> Handler for Captcha<S, F>
where
S: CaptchaStorage,
F: CaptchaFinder + 'static, // why?
F: CaptchaFinder,
{
async fn handle(
&self,
@ -118,7 +202,7 @@ where
_: &mut Response,
_: &mut FlowCtrl,
) {
if self.skipper.skipped(req, depot) {
if self.skipper.as_ref().skipped(req, depot) {
log::info!("Captcha check is skipped");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped);
return;
@ -155,7 +239,9 @@ where
match self.storage.get_answer(&token).await {
Ok(Some(captch_answer)) => {
log::info!("Captcha answer is exist in storage for token: {token}");
if captch_answer == answer {
if (captch_answer == answer && self.case_sensitive)
|| captch_answer.eq_ignore_ascii_case(&answer)
{
log::info!("Captcha answer is correct for token: {token}");
self.storage.clear_by_token(&token).await.ok();
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Passed);

View file

@ -0,0 +1,182 @@
// Copyright (c) 2024, Awiteb <a@4rs.nl>
// A captcha middleware for Salvo framework.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#![allow(warnings)]
use std::{
collections::HashMap,
convert::Infallible,
time::{Duration, SystemTime},
};
use tokio::sync::RwLock;
use crate::CaptchaStorage;
/// Captcha storage implementation using an in-memory HashMap.
#[derive(Debug)]
pub struct MemoryStorage(RwLock<HashMap<String, (u64, String)>>);
impl MemoryStorage {
/// Create a new instance of [`MemoryStorage`].
pub fn new() -> Self {
Self(RwLock::new(HashMap::new()))
}
}
impl CaptchaStorage for MemoryStorage {
type Error = Infallible;
async fn store_answer(&self, answer: String) -> Result<String, Self::Error> {
let token = uuid::Uuid::new_v4().to_string();
let mut write_lock = self.0.write().await;
write_lock.insert(token.clone(), (now(), answer));
Ok(token)
}
async fn get_answer(&self, token: &str) -> Result<Option<String>, Self::Error> {
let reader = self.0.read().await;
Ok(reader.get(token).map(|(_, answer)| answer.to_owned()))
}
async fn clear_expired(&self, expired_after: Duration) -> Result<(), Self::Error> {
let expired_after = now() - expired_after.as_secs();
let mut write_lock = self.0.write().await;
write_lock.retain(|_, (timestamp, _)| *timestamp > expired_after);
Ok(())
}
async fn clear_by_token(&self, token: &str) -> Result<(), Self::Error> {
let mut write_lock = self.0.write().await;
write_lock.retain(|c_token, (_, _)| c_token != token);
Ok(())
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!")
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn memory_store_captcha() {
let storage = MemoryStorage::new();
let token = storage
.store_answer("answer".to_owned())
.await
.expect("failed to store captcha");
assert_eq!(
storage
.get_answer(&token)
.await
.expect("failed to get captcha answer"),
Some("answer".to_owned())
);
}
#[tokio::test]
async fn memory_clear_expired() {
let storage = MemoryStorage::new();
let token = storage
.store_answer("answer".to_owned())
.await
.expect("failed to store captcha");
storage
.clear_expired(Duration::from_secs(0))
.await
.expect("failed to clear expired captcha");
assert!(storage
.get_answer(&token)
.await
.expect("failed to get captcha answer")
.is_none());
}
#[tokio::test]
async fn memory_clear_by_token() {
let storage = MemoryStorage::new();
let token = storage
.store_answer("answer".to_owned())
.await
.expect("failed to store captcha");
storage
.clear_by_token(&token)
.await
.expect("failed to clear captcha by token");
assert!(storage
.get_answer(&token)
.await
.expect("failed to get captcha answer")
.is_none());
}
#[tokio::test]
async fn memory_is_token_exist() {
let storage = MemoryStorage::new();
let token = storage
.store_answer("answer".to_owned())
.await
.expect("failed to store captcha");
assert!(storage
.get_answer(&token)
.await
.expect("failed to check if token is exist")
.is_some());
assert!(storage
.get_answer("token")
.await
.expect("failed to check if token is exist")
.is_none());
}
#[tokio::test]
async fn memory_clear_expired_with_expired_after() {
let storage = MemoryStorage::new();
let token = storage
.store_answer("answer".to_owned())
.await
.expect("failed to store captcha");
storage
.clear_expired(Duration::from_secs(1))
.await
.expect("failed to clear expired captcha");
assert_eq!(
storage
.get_answer(&token)
.await
.expect("failed to get captcha answer"),
Some("answer".to_owned())
);
tokio::time::sleep(Duration::from_secs(1)).await;
storage
.clear_expired(Duration::from_secs(1))
.await
.expect("failed to clear expired captcha");
assert!(storage
.get_answer(&token)
.await
.expect("failed to get captcha answer")
.is_none());
}
}

View file

@ -13,21 +13,20 @@ use std::{sync::Arc, time::Duration};
#[cfg(feature = "cacache-storage")]
mod cacache_storage;
mod memory_storage;
#[cfg(feature = "cacache-storage")]
pub use cacache_storage::*;
pub use memory_storage::*;
/// Trait to store the captcha token and answer. is also clear the expired captcha.
///
/// The trait will be implemented for `Arc<T>` if `T` implements the trait.
///
/// The trait is thread safe, so the storage can be shared between threads.
pub trait CaptchaStorage: Send + Sync + 'static
where
Self: Clone + std::fmt::Debug,
{
pub trait CaptchaStorage: Send + Sync + 'static {
/// The error type of the storage.
type Error: std::fmt::Display + std::fmt::Debug + Send;
type Error: std::error::Error + Send;
/// Store the captcha token and answer.
fn store_answer(
@ -52,6 +51,24 @@ where
&self,
token: &str,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
/// Create a new captcha image and return the answer and the image encoded as png.
///
/// This method will store the answer in the storage.
fn new_captcha<G: crate::CaptchaGenerator>(
&self,
generator: G,
) -> impl std::future::Future<
Output = Result<(String, Vec<u8>), either::Either<Self::Error, G::Error>>,
> + Send {
async move {
let (answer, image) = generator.new_captcha().await.map_err(either::Right)?;
Ok((
self.store_answer(answer).await.map_err(either::Left)?,
image,
))
}
}
}
impl<T> CaptchaStorage for Arc<T>