Skip to content

Commit

Permalink
backend: Add rate limiter for login route.
Browse files Browse the repository at this point in the history
Removed general rate limiter and added rate limiter specific for login route that limits per ip + user combination.
  • Loading branch information
ffreddow committed Dec 17, 2024
1 parent 3b65221 commit 94688b9
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 70 deletions.
2 changes: 2 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 0 additions & 50 deletions backend/src/key_extractor.rs

This file was deleted.

4 changes: 4 additions & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<F: FnMut()> {
Expand Down Expand Up @@ -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);
}
Expand Down
24 changes: 9 additions & 15 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Mutex<LruCache<String, Vec<u8>>>> = 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)
})
Expand Down
175 changes: 175 additions & 0 deletions backend/src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/* esp32-remote-access
* Copyright (C) 2024 Frederic Henrichs <[email protected]>
*
* 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<Self::Key, Self::KeyExtractionError> {
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<LoginRateLimitKey, dashmap::DashMap<LoginRateLimitKey, InMemoryState>, QuantaClock, governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>>,
}

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<QuantaInstant>,
now: QuantaInstant,
}

impl RateLimitError {
pub fn new(wait_time: NotUntil<QuantaInstant>, 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<actix_web::body::BoxBody> {
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 = "[email protected]".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 = "[email protected]".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());
}
}
14 changes: 9 additions & 5 deletions backend/src/routes/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -111,13 +108,17 @@ pub async fn validate_password(
pub async fn login(
state: web::Data<AppState>,
data: Json<LoginSchema>,
rate_limiter: web::Data<LoginRateLimiter>,
req: HttpRequest,
) -> Result<impl Responder, actix_web::Error> {
let conn = match state.pool.get() {
Ok(conn) => conn,
Err(_err) => return Err(Error::InternalError.into()),
};

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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 94688b9

Please sign in to comment.