diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0eb1c28..89058b9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -471,11 +471,13 @@ dependencies = [ "boringtun", "bs58", "chrono", + "dashmap", "db_connector", "derive_more", "diesel", "dotenv", "futures-util", + "governor", "ipnetwork", "jsonwebtoken", "lettre", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7bda64e..e9caeec 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -47,6 +47,8 @@ bs58 = "0.5.1" askama = "0.12.1" actix-governor = {version = "0.8.0", features = ["log"]} lru = "0.12.5" +governor = "0.8.0" +dashmap = "6.1.0" [dev-dependencies] libsodium-sys-stable = "1.20.4" diff --git a/backend/src/key_extractor.rs b/backend/src/key_extractor.rs deleted file mode 100644 index a2ce5fd..0000000 --- a/backend/src/key_extractor.rs +++ /dev/null @@ -1,50 +0,0 @@ -/* esp32-remote-access - * Copyright (C) 2024 Frederic Henrichs - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the - * Free Software Foundation, Inc., 59 Temple Place - Suite 330, - * Boston, MA 02111-1307, USA. - */ - -use actix_governor::{KeyExtractor, SimpleKeyExtractionError}; - -/** - * The struct used to extract ip for ratelimiting - */ -#[derive(Clone)] -pub struct Extractor; - -impl KeyExtractor for Extractor { - type Key = String; - type KeyExtractionError = SimpleKeyExtractionError<&'static str>; - - fn extract(&self, req: &actix_web::dev::ServiceRequest) -> Result { - let info = req.connection_info(); - if let Some(ip) = info.realip_remote_addr() { - Ok(ip.to_string()) - } else { - Err(SimpleKeyExtractionError::new("Invalid real IP")) - } - } - - fn name(&self) -> &'static str { - "KeyExtractor" - } -} - -impl Extractor { - pub fn new() -> Self { - Self - } -} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 706e094..89f382c 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -44,6 +44,7 @@ pub mod routes; pub mod udp_server; pub mod utils; pub mod ws_udp_bridge; +pub mod rate_limit; #[derive(Hash, PartialEq, Eq, Clone, Debug)] pub struct DiscoveryCharger { @@ -193,6 +194,7 @@ pub(crate) mod tests { use db_connector::{models::{recovery_tokens::RecoveryToken, refresh_tokens::RefreshToken, users::User}, test_connection_pool}; use rand::RngCore; use rand_core::OsRng; + use rate_limit::LoginRateLimiter; use routes::user::tests::{get_test_uuid, TestUser}; pub struct ScopeCall { @@ -261,6 +263,8 @@ pub(crate) mod tests { let state = web::Data::new(state); let bridge_state = web::Data::new(bridge_state); + let login_rate_limiter = web::Data::new(LoginRateLimiter::new()); + cfg.app_data(login_rate_limiter); cfg.app_data(state); cfg.app_data(bridge_state); } diff --git a/backend/src/main.rs b/backend/src/main.rs index bc226db..1baf414 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -18,24 +18,20 @@ */ mod monitoring; -mod key_extractor; use std::{ - collections::HashMap, - net::UdpSocket, - sync::{Arc, Mutex}, - time::Duration, + collections::HashMap, net::UdpSocket, num::NonZeroUsize, sync::{Arc, Mutex}, time::Duration }; -use actix_governor::{Governor, GovernorConfigBuilder}; use backend::utils::get_connection; pub use backend::*; use actix_web::{middleware::Logger, web, App, HttpServer}; use db_connector::{get_connection_pool, run_migrations, Pool}; use diesel::prelude::*; -use key_extractor::Extractor; +use rate_limit::LoginRateLimiter; use lettre::{transport::smtp::authentication::Credentials, SmtpTransport}; +use lru::LruCache; use simplelog::{ColorChoice, CombinedLogger, Config, LevelFilter, TermLogger, TerminalMode}; use udp_server::packet::{ ManagementCommand, ManagementCommandId, ManagementCommandPacket, ManagementPacket, @@ -170,21 +166,19 @@ async fn main() -> std::io::Result<()> { udp_server::start_server(bridge_state.clone()).unwrap(); - // Config for rate limitation - let governor_config = GovernorConfigBuilder::default() - .key_extractor(Extractor::new()) - .requests_per_second(2) - .burst_size(20) - .finish() - .unwrap(); + // Cache for random salts of non existing users + let cache: web::Data>>> = web::Data::new(Mutex::new(LruCache::new(NonZeroUsize::new(10000).unwrap()))); + + let login_ratelimiter = web::Data::new(LoginRateLimiter::new()); HttpServer::new(move || { let cors = actix_cors::Cors::permissive(); App::new() .wrap(cors) .wrap(Logger::default()) - .wrap(Governor::new(&governor_config)) + .app_data(cache.clone()) .app_data(state.clone()) + .app_data(login_ratelimiter.clone()) .app_data(bridge_state.clone()) .configure(routes::configure) }) diff --git a/backend/src/rate_limit.rs b/backend/src/rate_limit.rs new file mode 100644 index 0000000..57d98fb --- /dev/null +++ b/backend/src/rate_limit.rs @@ -0,0 +1,175 @@ +/* esp32-remote-access + * Copyright (C) 2024 Frederic Henrichs + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the + * Free Software Foundation, Inc., 59 Temple Place - Suite 330, + * Boston, MA 02111-1307, USA. + */ + +use std::num::NonZeroU32; + +use actix_governor::{KeyExtractor, SimpleKeyExtractionError}; +use actix_web::{http::StatusCode, HttpRequest, HttpResponse, ResponseError}; +use governor::{clock::{Clock, QuantaClock, QuantaInstant}, state::InMemoryState, NotUntil, Quota, RateLimiter}; + + +/** + * The struct used to extract ip for ratelimiting + */ +#[derive(Clone)] +pub struct IPExtractor; + +impl KeyExtractor for IPExtractor { + type Key = String; + type KeyExtractionError = SimpleKeyExtractionError<&'static str>; + + fn extract(&self, req: &actix_web::dev::ServiceRequest) -> Result { + let info = req.connection_info(); + if let Some(ip) = info.realip_remote_addr() { + Ok(ip.to_string()) + } else { + Err(SimpleKeyExtractionError::new("Invalid real IP")) + } + } + + fn name(&self) -> &'static str { + "IPExtractor" + } +} + +impl IPExtractor { + pub fn new() -> Self { + Self + } +} + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct LoginRateLimitKey { + user: String, + ip: String, +} + +// RateLimiter for the login route +pub struct LoginRateLimiter { + rate_limiter: RateLimiter, QuantaClock, governor::middleware::NoOpMiddleware>, +} + +impl LoginRateLimiter { + pub fn new() -> Self { + Self { + rate_limiter: RateLimiter::keyed(Quota::per_second(NonZeroU32::new(1).unwrap()).allow_burst(NonZeroU32::new(5).unwrap())), + } + } + + pub fn check(&self, email: String, req: &HttpRequest) -> actix_web::Result<()> { + let ip = if let Some(ip) = req.connection_info().realip_remote_addr() { + ip.to_string() + } else { + return Err(crate::error::Error::InternalError.into()) + }; + + let key = LoginRateLimitKey { + user: email, + ip + }; + if let Err(err) = self.rate_limiter.check_key(&key) { + log::warn!("RateLimiter triggered for {:?}", key); + let now = self.rate_limiter.clock().now(); + + Err(RateLimitError::new(err, now).into()) + } else { + Ok(()) + } + } +} + + +#[derive(Debug)] +struct RateLimitError { + wait_time: NotUntil, + now: QuantaInstant, +} + +impl RateLimitError { + pub fn new(wait_time: NotUntil, now: QuantaInstant) -> Self { + Self { wait_time, now } + } +} + +impl std::fmt::Display for RateLimitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let wait_time = self.wait_time.wait_time_from(self.now); + write!(f, "Retry in {} seconds.", wait_time.as_secs()) + } +} + +impl ResponseError for RateLimitError { + fn status_code(&self) -> actix_web::http::StatusCode { + StatusCode::TOO_MANY_REQUESTS + } + + fn error_response(&self) -> HttpResponse { + let wait_time = self.wait_time.wait_time_from(self.now); + HttpResponse::TooManyRequests() + .append_header(("retry-after", wait_time.as_secs())) + .append_header(("x-retry-after", wait_time.as_secs())) + .body(self.to_string()) + } +} + +#[cfg(test)] +mod tests { + use actix_web::test; + + use super::LoginRateLimiter; + + #[actix_web::test] + async fn test_login_rate_limiter() { + let limiter = LoginRateLimiter::new(); + let req = test::TestRequest::get() + .uri("/login") + .insert_header(("X-Forwarded-For", "123.123.123.2")) + .to_http_request(); + let email = "abc@de.fg".to_string(); + + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_ok()); + + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_ok()); + + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_ok()); + + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_ok()); + + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_ok()); + + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_err()); + + let email2 = "gf@edc.ba".to_string(); + let ret = limiter.check(email2.clone(), &req); + assert!(ret.is_ok()); + + let req = test::TestRequest::get() + .uri("/login") + .insert_header(("X-Forwarded-For", "123.123.123.3")) + .to_http_request(); + let ret = limiter.check(email.clone(), &req); + assert!(ret.is_ok()); + } +} diff --git a/backend/src/routes/auth/login.rs b/backend/src/routes/auth/login.rs index 000c693..9ba7f83 100644 --- a/backend/src/routes/auth/login.rs +++ b/backend/src/routes/auth/login.rs @@ -17,7 +17,7 @@ * Boston, MA 02111-1307, USA. */ -use actix_web::{cookie::Cookie, post, web, HttpResponse, Responder}; +use actix_web::{cookie::Cookie, post, web, HttpRequest, HttpResponse, Responder}; use actix_web_validator::Json; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use chrono::{Days, TimeDelta, Utc}; @@ -32,10 +32,7 @@ use utoipa::ToSchema; use validator::Validate; use crate::{ - error::Error, - models::token_claims::TokenClaims, - utils::{get_connection, web_block_unpacked}, - AppState, + error::Error, models::token_claims::TokenClaims, rate_limit::LoginRateLimiter, utils::{get_connection, web_block_unpacked}, AppState }; pub const MAX_TOKEN_AGE_MINUTES: i64 = 6; @@ -111,6 +108,8 @@ pub async fn validate_password( pub async fn login( state: web::Data, data: Json, + rate_limiter: web::Data, + req: HttpRequest, ) -> Result { let conn = match state.pool.get() { Ok(conn) => conn, @@ -118,6 +117,8 @@ pub async fn login( }; let email = data.email.to_lowercase(); + rate_limiter.check(email.clone(), &req)?; + let uuid = validate_password(&data.login_key, FindBy::Email(email), conn).await?; let now = Utc::now(); @@ -245,6 +246,7 @@ pub(crate) mod tests { let req = test::TestRequest::post() .uri("/login") .insert_header(ContentType::json()) + .insert_header(("X-Forwarded-For", "123.123.123.2")) .set_json(login_schema) .to_request(); let resp = test::call_service(&app, req).await; @@ -294,6 +296,7 @@ pub(crate) mod tests { let req = test::TestRequest::post() .uri("/login") .insert_header(ContentType::json()) + .insert_header(("X-Forwarded-For", "123.123.123.2")) .set_json(login_schema) .to_request(); let resp = test::call_service(&app, req).await; @@ -329,6 +332,7 @@ pub(crate) mod tests { let req = test::TestRequest::post() .uri("/login") .insert_header(ContentType::json()) + .insert_header(("X-Forwarded-For", "123.123.123.2")) .set_json(login_schema) .to_request(); let resp = test::call_service(&app, req).await;