diff --git a/Cargo.toml b/Cargo.toml index e6649e7..396bd00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ jsonwebtoken = { version = "9.1.0", default-features = false } log = "0.4.20" redis = { version = "0.24.0", features = ["tokio-comp"] } regex = "1.10.2" +reqwest = { version = "0.11.23", features = ["json"], optional = true } rocket = { version = "0.5.0", features = ["json", "uuid"] } rocket_db_pools = { version = "0.1.0", features = ["sqlx_mysql", "deadpool_redis"] } serde = { version = "1.0.193", features = ["derive"] } @@ -28,6 +29,9 @@ sqlx = { version = "0.7", features = [ "runtime-tokio", "mysql", "migrate", "uui tokio = { version = "1.34.0", features = ["fs"] } uuid = { version = "1.6.1", features = ["v4", "fast-rng", "macro-diagnostics"] } +[features] +turnstile = ["dep:reqwest"] + [[bin]] name = "server" diff --git a/Rocket.toml.example b/Rocket.toml.example index 9a26bdc..afaa48b 100644 --- a/Rocket.toml.example +++ b/Rocket.toml.example @@ -1,6 +1,8 @@ [default] upload_dir = "upload/" upload_url = "/upload" +turnstile_secret = "" +turnstile_url = "" [default.databases.startpage] url = "mysql://startpage:startpage@127.0.0.1/startpage" diff --git a/src/config.rs b/src/config.rs index c1f3d41..d42045b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,4 +22,6 @@ pub struct Config { pub jwt: Jwt, pub upload_dir: PathBuf, pub upload_url: String, + pub turnstile_secret: Option, + pub turnstile_url: Option, } diff --git a/src/guards.rs b/src/guards.rs new file mode 100644 index 0000000..01e7298 --- /dev/null +++ b/src/guards.rs @@ -0,0 +1,2 @@ +pub mod jwt; +pub mod remote_ip; diff --git a/src/middlewares/jwt.rs b/src/guards/jwt.rs similarity index 95% rename from src/middlewares/jwt.rs rename to src/guards/jwt.rs index faf8f96..7cdc431 100644 --- a/src/middlewares/jwt.rs +++ b/src/guards/jwt.rs @@ -1,6 +1,7 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use rocket::http::Status; use rocket::request::{FromRequest, Outcome}; +use rocket::Request; use rocket_db_pools::deadpool_redis::redis::AsyncCommands; use crate::config::Config; @@ -23,7 +24,7 @@ pub enum JwtError { impl<'r> FromRequest<'r> for Middleware { type Error = JwtError; - async fn from_request(request: &'r rocket::Request<'_>) -> Outcome { + async fn from_request(request: &'r Request<'_>) -> Outcome { let config = match request.rocket().figment().extract::() { Ok(config) => config, Err(_) => return Outcome::Error((Status::InternalServerError, JwtError::ConfigError)), diff --git a/src/guards/remote_ip.rs b/src/guards/remote_ip.rs new file mode 100644 index 0000000..91cb86d --- /dev/null +++ b/src/guards/remote_ip.rs @@ -0,0 +1,18 @@ +use rocket::request::{FromRequest, Outcome}; +use rocket::Request; + +pub struct Ip(pub(crate) Option); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Ip { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let ip = request.headers().get_one("CF-Connecting-IP"); + + match ip { + Some(ip) => Outcome::Success(Ip(Some(ip.to_string()))), + None => Outcome::Success(Ip(None)), + } + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 06a7ce1..58df258 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -7,6 +7,9 @@ use rocket_db_pools::Connection; use sqlx::query_as; use uuid::Uuid; +#[cfg(feature = "turnstile")] +use reqwest; + use crate::config::Config; use crate::errors::ServiceError; use crate::request; @@ -15,13 +18,82 @@ use crate::utils::calculate_expires; use crate::Claims; use crate::{models, MySQLDb, RedisDb}; +#[cfg(feature = "turnstile")] +use crate::response::auth::TurnstileResponse; + pub async fn login( user: &request::auth::User<'_>, state: &AppState, config: &Config, + _remote_ip: Option, db: &mut Connection, cache: &mut Connection, ) -> Result { + #[cfg(feature = "turnstile")] + { + let token = match user.token { + Some(token) => token, + None => { + return Err(ServiceError::BadRequest(String::from( + "Invalid challenge token", + ))) + } + }; + + let secret = match &config.turnstile_secret { + Some(secret) => secret, + None => { + return Err(ServiceError::BadRequest(String::from( + "Missing Turnstile secret", + ))) + } + }; + + let url = match &config.turnstile_url { + Some(url) => url, + None => { + return Err(ServiceError::BadRequest(String::from( + "Missing Turnstile URL", + ))) + } + }; + + let idempotency_key = Uuid::new_v4().to_string(); + + let params: [(&str, Option<&str>); 4] = [ + ("secret", Some(secret)), + ("response", Some(token)), + ("remoteip", _remote_ip.as_deref()), + ("idempotency_key", Some(&idempotency_key)), + ]; + + let client = reqwest::Client::new(); + + let response = client + .post(url) + .form(¶ms) + .send() + .map_err(|e| { + error!("Failed to send request to Turnstile: {}", e); + + ServiceError::InternalServerError + }) + .await? + .json::() + .map_err(|e| { + error!("Failed to parse Turnstile response: {}", e); + + ServiceError::InternalServerError + }) + .await?; + + if !response.success { + return Err(ServiceError::BadRequest(String::from( + "Invalid challenge token", + ))); + } + } + let record = query_as::<_, models::user::User>( r#"SELECT username, nickname, password, avatar, email FROM user WHERE username = ?"#, ) diff --git a/src/lib.rs b/src/lib.rs index 546e2e6..b7dbe47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,8 +22,8 @@ pub struct MySQLDb(MySqlPool); pub struct RedisDb(deadpool_redis::Pool); pub mod errors; +pub mod guards; pub mod handlers; -pub mod middlewares; pub mod models; pub mod routes; pub mod state; diff --git a/src/middlewares.rs b/src/middlewares.rs deleted file mode 100644 index 417233c..0000000 --- a/src/middlewares.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod jwt; diff --git a/src/request/auth.rs b/src/request/auth.rs index db95dd4..4b6b3ef 100644 --- a/src/request/auth.rs +++ b/src/request/auth.rs @@ -4,4 +4,5 @@ use serde::Deserialize; pub struct User<'r> { pub username: &'r str, pub password: &'r str, + pub token: Option<&'r str>, } diff --git a/src/response/auth.rs b/src/response/auth.rs index d8b7ea4..4a0e8d7 100644 --- a/src/response/auth.rs +++ b/src/response/auth.rs @@ -2,6 +2,9 @@ use cookie::time::Duration; use rocket::http::{ContentType, Cookie}; use rocket::response::Responder; +#[cfg(feature = "turnstile")] +use serde::Deserialize; + const COOKIE_MAX_AGE: i64 = 2147483647; #[derive(Debug)] @@ -40,3 +43,15 @@ impl<'r> Responder<'r, 'static> for Logout { .ok() } } + +#[cfg(feature = "turnstile")] +#[derive(Debug, Deserialize)] +pub struct TurnstileResponse { + pub success: bool, + #[serde(rename(deserialize = "error-codes"))] + pub error_codes: Vec, + pub challenge_ts: Option, + pub hostname: Option, + pub action: Option, + pub cdata: Option, +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index ad91e69..00e359d 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -9,7 +9,7 @@ use rocket_db_pools::deadpool_redis::redis::AsyncCommands; use rocket_db_pools::Connection; use crate::config::Config; -use crate::middlewares::jwt::Middleware; +use crate::guards::{jwt::Middleware, remote_ip::Ip}; use crate::response::auth::Logout; use crate::state::AppState; use crate::{handlers, request, response, MySQLDb, RedisDb}; @@ -19,10 +19,13 @@ pub async fn login( user: Json>, state: &State, config: &State, + remote_ip: Ip, mut db: Connection, mut cache: Connection, ) -> Result { - let token = handlers::auth::login(user.deref(), state, config, &mut db, &mut cache) + let remote_ip = remote_ip.0; + + let token = handlers::auth::login(user.deref(), state, config, remote_ip, &mut db, &mut cache) .await .map_err(|e| { error!("{}", e); diff --git a/src/routes/category.rs b/src/routes/category.rs index 8b3e5f9..8569669 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -5,11 +5,11 @@ use rocket::{delete, get, post, put, State}; use rocket_db_pools::Connection; use crate::config::Config; +use crate::guards::jwt::Middleware; use crate::handlers::category::{ self, add_category, delete_category, get_categories, sort_categories, sort_category_sites, update_category, }; -use crate::middlewares::jwt::Middleware; use crate::request::category::{CreateCategory, SortCategory, UpdateCategory}; use crate::response::category::Category; use crate::response::site::Site; diff --git a/src/routes/site.rs b/src/routes/site.rs index 976f7e9..063f0a2 100644 --- a/src/routes/site.rs +++ b/src/routes/site.rs @@ -5,9 +5,9 @@ use rocket::{delete, get, post, put, State}; use rocket_db_pools::Connection; use crate::config::Config; +use crate::guards::jwt::Middleware; use crate::handlers::site; use crate::handlers::site::get_sites; -use crate::middlewares::jwt::Middleware; use crate::request::site::{CreateSite, UpdateSite}; use crate::response::site::SiteWithCategory; use crate::response::WithTotal; diff --git a/src/routes/upload.rs b/src/routes/upload.rs index 8a2860c..54389f0 100644 --- a/src/routes/upload.rs +++ b/src/routes/upload.rs @@ -6,8 +6,8 @@ use rocket::serde::json::Json; use rocket::{post, FromForm, State}; use crate::config::Config; +use crate::guards::jwt::Middleware; use crate::handlers; -use crate::middlewares::jwt::Middleware; #[derive(FromForm)] pub struct Upload<'r> { diff --git a/src/routes/user.rs b/src/routes/user.rs index 0c04dd1..5ed38be 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -8,8 +8,8 @@ use rocket_db_pools::deadpool_redis::redis::AsyncCommands; use rocket_db_pools::Connection; use crate::config::Config; +use crate::guards::jwt::Middleware; use crate::handlers::user::{get_user, update_user, update_user_password}; -use crate::middlewares::jwt::Middleware; use crate::request::user::{UpdatePassword, UpdateUser}; use crate::response::auth::Logout; use crate::response::user::User;