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] [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 } 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] [features]
cacache-storage = ["dep:cacache", "dep:uuid"] cacache-storage = ["dep:cacache"]
simple_generator = ["dep:captcha"]
[dev-dependencies] [dev-dependencies]
tempfile = "3.9" tempfile = "3.9"
@ -32,4 +35,4 @@ rstest = "0.22.0"
[[example]] [[example]]
name = "simple_login" name = "simple_login"
required-features = ["cacache-storage"] required-features = ["simple_generator"]

View file

@ -19,7 +19,7 @@ _default:
# Run the CI (Local use only) # Run the CI (Local use only)
@ci: @ci:
cargo fmt --all --check 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 clippy --workspace --all-targets --examples --tests --all-features -- -D warnings
cargo nextest run --workspace --all-targets --all-features cargo nextest run --workspace --all-targets --all-features
@{{JUST_EXECUTABLE}} msrv @{{JUST_EXECUTABLE}} msrv

View file

@ -2,7 +2,7 @@
# salvo-captcha # 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) [![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 ```toml
[dependencies] [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 ```sh
$ cargo add salvo-captcha $ cargo add salvo-captcha
@ -25,39 +25,59 @@ $ cargo add salvo-captcha
## Usage ## 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 ## 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 ```toml
[dependencies] [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. In this table, you can see the difference between the difficulties and the name of the captcha.
| Name | Easy | Medium | Hard | | 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) | | Normal | ![Simple](https://i.suar.me/edwBG/s) | ![Simple](https://i.suar.me/NJmg0/s) | ![Simple](https://i.suar.me/OJK7M/s) |
| Lucy | ![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) |
| Mila | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) | | VeryTwisted | ![Simple](https://i.suar.me/dO78z/s) | ![Simple](https://i.suar.me/PXBwK/s) | ![Simple](https://i.suar.me/8edgE/s) |
## Mirrors ## Mirrors
- Github (https://github.com/TheAwiteb/salvo-captcha) - Github (<https://github.com/TheAwiteb/salvo-captcha>)
- Codeberg (https://codeberg.org/awiteb/salvo-captcha) - Codeberg (<https://codeberg.org/awiteb/salvo-captcha>)
### Main Repository ### Main Repository
- My Git (https://git.4rs.nl/awiteb/salvo-captcha) - My Git (<https://git.4rs.nl/awiteb/salvo-captcha>)
## License ## 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 [`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` // 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 base64::{engine::GeneralPurpose, Engine};
use salvo::prelude::*; use salvo::prelude::*;
@ -16,18 +16,22 @@ const BASE_64_ENGINE: GeneralPurpose = GeneralPurpose::new(
base64::engine::general_purpose::PAD, base64::engine::general_purpose::PAD,
); );
const SIMPLE_GENERATOR: SimpleGenerator =
SimpleGenerator::new(CaptchaName::Normal, CaptchaDifficulty::Medium);
#[handler] #[handler]
async fn index(res: &mut Response, depot: &mut Depot) { async fn index(res: &mut Response, depot: &mut Depot) {
// Get the captcha from the 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 // Create a new captcha
let (token, image) = captcha_storage let Ok((token, image)) = captcha_storage.new_captcha(SIMPLE_GENERATOR).await else {
.as_ref() res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
.new_captcha(CaptchaName::Mila, CaptchaDifficulty::Medium) res.render(Text::Html(
.await "<html><body><h1>Server Error 500</h1></body></html>",
.expect("Failed to save captcha") ));
.expect("Failed to create captcha"); return;
};
// Convert the image to base64 // Convert the image to base64
let image = BASE_64_ENGINE.encode(image); 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] #[tokio::main]
async fn main() { async fn main() {
let captcha_middleware = Captcha::new( let captcha_storage = Arc::new(MemoryStorage::new());
CacacheStorage::new("./captcha-cache"), let captcha_middleware =
CaptchaFormFinder::new(), CaptchaBuilder::new(Arc::clone(&captcha_storage), CaptchaFormFinder::new())
) // Skip the captcha if the request path is /skipped
.skipper(|req: &mut Request, _: &Depot| { .skipper(|req: &mut Request, _: &Depot| req.uri().path() == "/skipped")
// Skip the captcha if the request path is /skipped .case_insensitive()
req.uri().path() == "/skipped" .build();
});
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 router = Router::new() let router = Router::new()
.hoop(affix::inject(captcha_storage.clone())) .hoop(affix::inject(captcha_storage))
.push(Router::with_path("/").get(index)) .push(Router::with_path("/").get(index))
.push( .push(
Router::new() Router::new()
@ -103,9 +87,7 @@ async fn main() {
); );
let acceptor = TcpListener::new(("127.0.0.1", 5800)).bind().await; 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; Server::new(acceptor).serve(router).await;
captcha_cleaner.await.ok();
} }
fn index_page(captcha_image: String, captcha_token: String) -> String { 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("token")),
Some(Some("answer")) 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( #[case::custom_not_found(
Some("custom_token"), Some("custom_token"),
Some("custom_answer"), Some("custom_answer"),

View file

@ -96,6 +96,22 @@ mod tests {
Some(Some("token")), Some(Some("token")),
Some(Some("answer")) 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(Some("custom-token"), Some("custom-answer"), None, None, None, None)]
#[case::custom_not_found_with_headers( #[case::custom_not_found_with_headers(
Some("custom-token"), Some("custom-token"),

View file

@ -13,12 +13,14 @@ use salvo_core::http::Request;
mod form_finder; mod form_finder;
mod header_finder; mod header_finder;
mod query_finder;
pub use form_finder::*; pub use form_finder::*;
pub use header_finder::*; pub use header_finder::*;
pub use query_finder::*;
/// Trait to find the captcha token and answer from the request. /// 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. /// Find the captcha token from the request.
/// ///
/// ### Returns /// ### 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 finder;
mod storage; mod storage;
use std::{sync::Arc, time::Duration};
use salvo_core::{ use salvo_core::{
handler::{none_skipper, Skipper}, handler::{none_skipper, Skipper},
Depot, FlowCtrl, Handler, Request, Response, Depot, FlowCtrl, Handler, Request, Response,
}; };
pub use {captcha_gen::*, finder::*, storage::*}; 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 /// Key used to insert the captcha state into the depot
pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state"; pub const CAPTCHA_STATE_KEY: &str = "::salvo_captcha::captcha_state";
/// Captcha struct, contains the token and answer. /// Captcha struct, contains the token and answer.
#[non_exhaustive] #[non_exhaustive]
#[allow(clippy::type_complexity)]
pub struct Captcha<S, F> pub struct Captcha<S, F>
where where
S: CaptchaStorage, S: CaptchaStorage,
@ -41,9 +39,11 @@ where
/// The captcha finder, used to find the captcha token and answer from the request. /// The captcha finder, used to find the captcha token and answer from the request.
finder: F, finder: F,
/// The storage of the captcha, used to store and get the captcha token and answer. /// 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. /// The skipper of the captcha, used to skip the captcha check.
skipper: Box<dyn Skipper>, skipper: Box<dyn Skipper>,
/// The case sensitive of the captcha answer.
case_sensitive: bool,
} }
/// The captcha states of the request /// The captcha states of the request
@ -66,30 +66,114 @@ pub enum CaptchaState {
StorageError, 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> impl<S, F> Captcha<S, F>
where where
S: CaptchaStorage, S: CaptchaStorage,
F: CaptchaFinder, F: CaptchaFinder,
{ {
/// Create a new Captcha /// 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 { Self {
finder, finder,
storage, 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. /// The captcha extension of the depot.
@ -109,7 +193,7 @@ impl CaptchaDepotExt for Depot {
impl<S, F> Handler for Captcha<S, F> impl<S, F> Handler for Captcha<S, F>
where where
S: CaptchaStorage, S: CaptchaStorage,
F: CaptchaFinder + 'static, // why? F: CaptchaFinder,
{ {
async fn handle( async fn handle(
&self, &self,
@ -118,7 +202,7 @@ where
_: &mut Response, _: &mut Response,
_: &mut FlowCtrl, _: &mut FlowCtrl,
) { ) {
if self.skipper.skipped(req, depot) { if self.skipper.as_ref().skipped(req, depot) {
log::info!("Captcha check is skipped"); log::info!("Captcha check is skipped");
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped); depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Skipped);
return; return;
@ -155,7 +239,9 @@ where
match self.storage.get_answer(&token).await { match self.storage.get_answer(&token).await {
Ok(Some(captch_answer)) => { Ok(Some(captch_answer)) => {
log::info!("Captcha answer is exist in storage for token: {token}"); 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}"); log::info!("Captcha answer is correct for token: {token}");
self.storage.clear_by_token(&token).await.ok(); self.storage.clear_by_token(&token).await.ok();
depot.insert(CAPTCHA_STATE_KEY, CaptchaState::Passed); 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")] #[cfg(feature = "cacache-storage")]
mod cacache_storage; mod cacache_storage;
mod memory_storage;
#[cfg(feature = "cacache-storage")] #[cfg(feature = "cacache-storage")]
pub use cacache_storage::*; pub use cacache_storage::*;
pub use memory_storage::*;
/// Trait to store the captcha token and answer. is also clear the expired captcha. /// 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 will be implemented for `Arc<T>` if `T` implements the trait.
/// ///
/// The trait is thread safe, so the storage can be shared between threads. /// The trait is thread safe, so the storage can be shared between threads.
pub trait CaptchaStorage: Send + Sync + 'static pub trait CaptchaStorage: Send + Sync + 'static {
where
Self: Clone + std::fmt::Debug,
{
/// The error type of the storage. /// 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. /// Store the captcha token and answer.
fn store_answer( fn store_answer(
@ -52,6 +51,24 @@ where
&self, &self,
token: &str, token: &str,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send; ) -> 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> impl<T> CaptchaStorage for Arc<T>