From c84c2ef3c5c24b8b9e4ea5fe76fd59b155bc557d Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Fri, 26 May 2023 17:55:19 +0200 Subject: [PATCH] Add rate limiting --- geneit_backend/Cargo.lock | 32 +++++++++ geneit_backend/Cargo.toml | 4 +- geneit_backend/src/app_config.rs | 32 +++++++++ .../src/{ => connections}/db_connection.rs | 0 geneit_backend/src/connections/mod.rs | 4 ++ .../src/connections/redis_connection.rs | 51 ++++++++++++++ geneit_backend/src/controllers.rs | 4 -- .../src/controllers/auth_controller.rs | 37 ++++------ geneit_backend/src/controllers/mod.rs | 35 ++++++++++ geneit_backend/src/lib.rs | 3 +- geneit_backend/src/services/mod.rs | 1 + .../src/services/rate_limiter_service.rs | 68 +++++++++++++++++++ geneit_backend/src/services/users_service.rs | 2 +- geneit_backend/src/{utils.rs => utils/mod.rs} | 0 14 files changed, 242 insertions(+), 31 deletions(-) rename geneit_backend/src/{ => connections}/db_connection.rs (100%) create mode 100644 geneit_backend/src/connections/mod.rs create mode 100644 geneit_backend/src/connections/redis_connection.rs delete mode 100644 geneit_backend/src/controllers.rs create mode 100644 geneit_backend/src/controllers/mod.rs create mode 100644 geneit_backend/src/services/rate_limiter_service.rs rename geneit_backend/src/{utils.rs => utils/mod.rs} (100%) diff --git a/geneit_backend/Cargo.lock b/geneit_backend/Cargo.lock index e216052..306fd49 100644 --- a/geneit_backend/Cargo.lock +++ b/geneit_backend/Cargo.lock @@ -440,6 +440,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -666,7 +676,9 @@ dependencies = [ "lazy_static", "log", "mailchecker", + "redis", "serde", + "serde_json", ] [[package]] @@ -1083,6 +1095,20 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea8c51b5dc1d8e5fd3350ec8167f464ec0995e79f2e90a075b63371500d557f" +dependencies = [ + "combine", + "itoa", + "percent-encoding", + "ryu", + "sha1_smol", + "url", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -1204,6 +1230,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "signal-hook-registry" version = "1.4.1" diff --git a/geneit_backend/Cargo.toml b/geneit_backend/Cargo.toml index 3411579..f4b5504 100644 --- a/geneit_backend/Cargo.toml +++ b/geneit_backend/Cargo.toml @@ -14,5 +14,7 @@ anyhow = "1.0.71" actix-web = "4.3.1" diesel = { version = "2.0.4", features = ["postgres"] } serde = { version = "1.0.163", features = ["derive"] } +serde_json = "1.0.96" actix-remote-ip = "0.1.0" -mailchecker = "5.0.9" \ No newline at end of file +mailchecker = "5.0.9" +redis = "0.23.0" \ No newline at end of file diff --git a/geneit_backend/src/app_config.rs b/geneit_backend/src/app_config.rs index e6253bc..e7bcf90 100644 --- a/geneit_backend/src/app_config.rs +++ b/geneit_backend/src/app_config.rs @@ -35,6 +35,26 @@ pub struct AppConfig { /// PostgreSQL database name #[clap(long, env, default_value = "geneit")] db_name: String, + + /// Redis connection hostname + #[clap(long, env, default_value = "localhost")] + redis_hostname: String, + + /// Redis connection port + #[clap(long, env, default_value_t = 6379)] + redis_port: u16, + + /// Redis database number + #[clap(long, env, default_value_t = 0)] + redis_db_number: i64, + + /// Redis username + #[clap(long, env)] + redis_username: Option, + + /// Redis password + #[clap(long, env, default_value = "secretredis")] + redis_password: String, } lazy_static::lazy_static! { @@ -56,4 +76,16 @@ impl AppConfig { self.db_username, self.db_password, self.db_host, self.db_port, self.db_name ) } + + /// Get Redis connection configuration + pub fn redis_connection_config(&self) -> redis::ConnectionInfo { + redis::ConnectionInfo { + addr: redis::ConnectionAddr::Tcp(self.redis_hostname.clone(), self.redis_port), + redis: redis::RedisConnectionInfo { + db: self.redis_db_number, + username: self.redis_username.clone(), + password: Some(self.redis_password.clone()), + }, + } + } } diff --git a/geneit_backend/src/db_connection.rs b/geneit_backend/src/connections/db_connection.rs similarity index 100% rename from geneit_backend/src/db_connection.rs rename to geneit_backend/src/connections/db_connection.rs diff --git a/geneit_backend/src/connections/mod.rs b/geneit_backend/src/connections/mod.rs new file mode 100644 index 0000000..b38c36c --- /dev/null +++ b/geneit_backend/src/connections/mod.rs @@ -0,0 +1,4 @@ +//! # External services connections + +pub mod db_connection; +pub mod redis_connection; diff --git a/geneit_backend/src/connections/redis_connection.rs b/geneit_backend/src/connections/redis_connection.rs new file mode 100644 index 0000000..0d58aad --- /dev/null +++ b/geneit_backend/src/connections/redis_connection.rs @@ -0,0 +1,51 @@ +//! # Redis connection management + +use crate::app_config::AppConfig; +use redis::Commands; +use serde::de::DeserializeOwned; +use std::cell::RefCell; +use std::time::Duration; + +thread_local! { + static REDIS_CONNECTION: RefCell> = RefCell::new(None); +} + +/// Execute a request on Redis +fn execute_request(cb: E) -> anyhow::Result +where + E: FnOnce(&mut redis::Client) -> anyhow::Result, +{ + // Establish connection if required + if REDIS_CONNECTION.with(|i| i.borrow().is_none()) { + let conn = redis::Client::open(AppConfig::get().redis_connection_config())?; + + REDIS_CONNECTION.with(|i| *i.borrow_mut() = Some(conn)) + } + + REDIS_CONNECTION.with(|i| cb(i.borrow_mut().as_mut().unwrap())) +} + +/// Get a value stored on Redis +pub async fn get_value(key: &str) -> anyhow::Result> +where + E: DeserializeOwned, +{ + let value: Option = execute_request(|conn| Ok(conn.get(key)?))?; + + Ok(match value { + None => None, + Some(v) => serde_json::from_str(&v)?, + }) +} + +/// Set a new value on Redis +pub async fn set_value(key: &str, value: &E, lifetime: Duration) -> anyhow::Result<()> +where + E: serde::Serialize, +{ + let value_str = serde_json::to_string(value)?; + + execute_request(|conn| Ok(conn.set_ex(key, value_str, lifetime.as_secs() as usize)?))?; + + Ok(()) +} diff --git a/geneit_backend/src/controllers.rs b/geneit_backend/src/controllers.rs deleted file mode 100644 index 4b69ed9..0000000 --- a/geneit_backend/src/controllers.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! # API controller - -pub mod auth_controller; -pub mod config_controller; diff --git a/geneit_backend/src/controllers/auth_controller.rs b/geneit_backend/src/controllers/auth_controller.rs index 47aa8fc..7927f3e 100644 --- a/geneit_backend/src/controllers/auth_controller.rs +++ b/geneit_backend/src/controllers/auth_controller.rs @@ -1,7 +1,8 @@ use crate::constants::StaticConstraints; -use crate::services::users_service; +use crate::controllers::HttpResult; +use crate::services::rate_limiter_service::RatedAction; +use crate::services::{rate_limiter_service, users_service}; use actix_remote_ip::RemoteIP; -use actix_web::error::ErrorInternalServerError; use actix_web::{web, HttpResponse}; #[derive(serde::Deserialize)] @@ -11,11 +12,12 @@ pub struct CreateAccountBody { } /// Create a new account -pub async fn create_account( - _remote_ip: RemoteIP, - req: web::Json, -) -> actix_web::Result { - // TODO : rate limiting +pub async fn create_account(remote_ip: RemoteIP, req: web::Json) -> HttpResult { + // Rate limiting + if rate_limiter_service::should_block_action(remote_ip.0, RatedAction::CreateAccount).await? { + return Ok(HttpResponse::TooManyRequests().finish()); + } + rate_limiter_service::record_action(remote_ip.0, RatedAction::CreateAccount).await?; // Check if email is valid if !mailchecker::is_valid(&req.email) { @@ -30,25 +32,14 @@ pub async fn create_account( } // Check if email is already attached to an account - match users_service::exists_email(&req.email).await { - Ok(false) => {} - Ok(true) => { - return Ok(HttpResponse::Conflict() - .json("An account with the same email address already exists!")); - } - Err(e) => { - log::error!("Failed to check email existence! {}", e); - return Err(ErrorInternalServerError(e)); - } + if users_service::exists_email(&req.email).await? { + return Ok( + HttpResponse::Conflict().json("An account with the same email address already exists!") + ); } // Create the account - let user_id = users_service::create_account(&req.name, &req.email) - .await - .map_err(|e| { - log::error!("Failed to create user! {e}"); - ErrorInternalServerError(e) - })?; + let user_id = users_service::create_account(&req.name, &req.email).await?; // TODO : trigger reset password (send mail) diff --git a/geneit_backend/src/controllers/mod.rs b/geneit_backend/src/controllers/mod.rs new file mode 100644 index 0000000..1ef5a31 --- /dev/null +++ b/geneit_backend/src/controllers/mod.rs @@ -0,0 +1,35 @@ +//! # API controller + +use actix_web::body::BoxBody; +use actix_web::HttpResponse; +use std::fmt::{Debug, Display, Formatter}; + +pub mod auth_controller; +pub mod config_controller; + +/// Custom error to ease controller writing +#[derive(Debug)] +pub struct HttpErr { + err: anyhow::Error, +} + +impl Display for HttpErr { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.err, f) + } +} + +impl actix_web::error::ResponseError for HttpErr { + fn error_response(&self) -> HttpResponse { + log::error!("Error while processing request! {}", self); + HttpResponse::InternalServerError().body("Failed to execute request!") + } +} + +impl From for HttpErr { + fn from(err: anyhow::Error) -> HttpErr { + HttpErr { err } + } +} + +pub type HttpResult = Result; diff --git a/geneit_backend/src/lib.rs b/geneit_backend/src/lib.rs index 2a0f26d..9d4a2cb 100644 --- a/geneit_backend/src/lib.rs +++ b/geneit_backend/src/lib.rs @@ -1,10 +1,9 @@ pub mod app_config; +pub mod connections; pub mod constants; pub mod controllers; pub mod services; pub mod utils; -// Diesel specific -pub mod db_connection; pub mod models; pub mod schema; diff --git a/geneit_backend/src/services/mod.rs b/geneit_backend/src/services/mod.rs index 2121797..db116d9 100644 --- a/geneit_backend/src/services/mod.rs +++ b/geneit_backend/src/services/mod.rs @@ -1,3 +1,4 @@ //! # Backend services +pub mod rate_limiter_service; pub mod users_service; diff --git a/geneit_backend/src/services/rate_limiter_service.rs b/geneit_backend/src/services/rate_limiter_service.rs new file mode 100644 index 0000000..5447bce --- /dev/null +++ b/geneit_backend/src/services/rate_limiter_service.rs @@ -0,0 +1,68 @@ +use crate::connections::redis_connection; +use crate::utils::time_utils::time; +use std::net::IpAddr; +use std::time::Duration; + +#[derive(Debug, Copy, Clone)] +pub enum RatedAction { + CreateAccount, +} + +impl RatedAction { + fn id(&self) -> &'static str { + match self { + RatedAction::CreateAccount => "create-account", + } + } + + fn limit(&self) -> usize { + match self { + RatedAction::CreateAccount => 5, + } + } + + fn keep_seconds(&self) -> u64 { + match self { + RatedAction::CreateAccount => 3600, + } + } + + fn key(&self, ip: IpAddr) -> String { + format!("rate-{}-{}", self.id(), ip) + } +} + +/// Keep track of the time the action was executed by the user +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +struct ActionRecord(Vec); + +impl ActionRecord { + pub fn clean(&mut self, action: RatedAction) { + self.0.retain(|e| e + action.keep_seconds() > time()); + } +} + +/// Record a new action of the user +pub async fn record_action(ip: IpAddr, action: RatedAction) -> anyhow::Result<()> { + let key = action.key(ip); + + let mut record = redis_connection::get_value::(&key) + .await? + .unwrap_or_default(); + record.clean(action); + record.0.push(time()); + redis_connection::set_value(&key, &record, Duration::from_secs(action.keep_seconds())).await?; + + Ok(()) +} + +/// Check whether an action should be blocked, due to too much attempts from the user +pub async fn should_block_action(ip: IpAddr, action: RatedAction) -> anyhow::Result { + let mut record = redis_connection::get_value::(&action.key(ip)) + .await? + .unwrap_or_default(); + + record.clean(action); + + Ok(record.0.len() >= action.limit()) +} diff --git a/geneit_backend/src/services/users_service.rs b/geneit_backend/src/services/users_service.rs index caed4d0..ae96bda 100644 --- a/geneit_backend/src/services/users_service.rs +++ b/geneit_backend/src/services/users_service.rs @@ -1,6 +1,6 @@ //! # Users service -use crate::db_connection; +use crate::connections::db_connection; use crate::models::{NewUser, User}; use crate::schema::users; use crate::utils::time_utils::time; diff --git a/geneit_backend/src/utils.rs b/geneit_backend/src/utils/mod.rs similarity index 100% rename from geneit_backend/src/utils.rs rename to geneit_backend/src/utils/mod.rs