From 886bae32c8ff9864574d4aae63a4f5fe146fab43 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Sun, 3 Apr 2022 17:33:01 +0200 Subject: [PATCH] Enable bruteforce protection on login endpoint --- src/actors/bruteforce_actor.rs | 31 +++++- src/constants.rs | 2 +- src/controllers/base_controller.rs | 8 ++ src/controllers/login_controller.rs | 46 ++++++-- src/data/app_config.rs | 4 + src/middlewares/auth_middleware.rs | 51 +++++---- src/utils/mod.rs | 1 + src/utils/network_utils.rs | 102 ++++++++++++++++++ .../{access_denied.html => fatal_error.html} | 2 +- 9 files changed, 209 insertions(+), 38 deletions(-) create mode 100644 src/utils/network_utils.rs rename templates/{access_denied.html => fatal_error.html} (79%) diff --git a/src/actors/bruteforce_actor.rs b/src/actors/bruteforce_actor.rs index 422f528..3326ea3 100644 --- a/src/actors/bruteforce_actor.rs +++ b/src/actors/bruteforce_actor.rs @@ -1,11 +1,24 @@ use std::collections::HashMap; use std::net::IpAddr; -use actix::{Actor, AsyncContext, Context}; +use actix::{Actor, AsyncContext, Context, Handler, Message}; use crate::constants::{FAIL_LOGIN_ATTEMPT_CLEANUP_INTERVAL, KEEP_FAILED_LOGIN_ATTEMPTS_FOR}; use crate::utils::time::time; +#[derive(Message)] +#[rtype(result = "()")] +pub struct RecordFailedAttempt { + pub ip: IpAddr, +} + +#[derive(Message)] +#[rtype(result = "usize")] +pub struct CountFailedAttempt { + pub ip: IpAddr, +} + + #[derive(Debug, Default)] pub struct BruteForceActor { failed_attempts: HashMap>, @@ -55,6 +68,22 @@ impl Actor for BruteForceActor { } } +impl Handler for BruteForceActor { + type Result = (); + + fn handle(&mut self, attempt: RecordFailedAttempt, _ctx: &mut Self::Context) -> Self::Result { + self.insert_failed_attempt(attempt.ip) + } +} + +impl Handler for BruteForceActor { + type Result = usize; + + fn handle(&mut self, attempt: CountFailedAttempt, _ctx: &mut Self::Context) -> Self::Result { + self.count_failed_attempts(&attempt.ip) + } +} + #[cfg(test)] mod test { use std::net::{IpAddr, Ipv4Addr}; diff --git a/src/constants.rs b/src/constants.rs index 7e97ccd..3509afd 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -33,5 +33,5 @@ pub const LOGIN_ROUTE: &str = "/login"; /// Bruteforce protection pub const KEEP_FAILED_LOGIN_ATTEMPTS_FOR: u64 = 3600; -pub const MAX_FAILED_LOGIN_ATTEMPTS: u64 = 15; +pub const MAX_FAILED_LOGIN_ATTEMPTS: usize = 15; pub const FAIL_LOGIN_ATTEMPT_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); \ No newline at end of file diff --git a/src/controllers/base_controller.rs b/src/controllers/base_controller.rs index be4d476..41939bb 100644 --- a/src/controllers/base_controller.rs +++ b/src/controllers/base_controller.rs @@ -1,6 +1,7 @@ use std::fmt::Display; use actix_web::HttpResponse; +use askama::Template; use crate::constants::LOGIN_ROUTE; @@ -24,3 +25,10 @@ pub fn redirect_user_for_login(redirect_uri: P) -> HttpResponse { )) .finish() } + +/// Fatal error page message +#[derive(Template)] +#[template(path = "fatal_error.html")] +pub struct FatalErrorPage { + pub message: &'static str, +} \ No newline at end of file diff --git a/src/controllers/login_controller.rs b/src/controllers/login_controller.rs index 6b15ed0..3c28ec1 100644 --- a/src/controllers/login_controller.rs +++ b/src/controllers/login_controller.rs @@ -1,13 +1,16 @@ use actix::Addr; use actix_identity::Identity; -use actix_web::{web, HttpResponse, Responder}; +use actix_web::{HttpRequest, HttpResponse, Responder, web}; use askama::Template; -use crate::actors::users_actor; +use crate::actors::{bruteforce_actor, users_actor}; +use crate::actors::bruteforce_actor::BruteForceActor; use crate::actors::users_actor::{ChangePasswordResult, LoginResult, UsersActor}; -use crate::constants::{APP_NAME, MIN_PASS_LEN}; -use crate::controllers::base_controller::redirect_user; +use crate::constants::{APP_NAME, MAX_FAILED_LOGIN_ATTEMPTS, MIN_PASS_LEN}; +use crate::controllers::base_controller::{FatalErrorPage, redirect_user}; +use crate::data::app_config::AppConfig; use crate::data::session_identity::{SessionIdentity, SessionStatus}; +use crate::utils::network_utils::{get_remote_ip, parse_ip}; #[derive(Template)] #[template(path = "base_login_page.html")] @@ -47,15 +50,38 @@ pub struct LoginRequestQuery { /// Authenticate user pub async fn login_route( + http_req: HttpRequest, users: web::Data>, + bruteforce: web::Data>, query: web::Query, req: Option>, + config: web::Data, id: Identity, ) -> impl Responder { let mut danger = String::new(); let mut success = String::new(); let mut login = String::new(); + let remote_ip = match parse_ip(&get_remote_ip(&http_req, config.proxy_ip.as_deref())) { + None => return HttpResponse::InternalServerError().body( + FatalErrorPage { + message: "Failed to determine remote ip address!" + }.render().unwrap() + ), + Some(i) => i, + }; + + let failed_attempts = bruteforce.send(bruteforce_actor::CountFailedAttempt { ip: remote_ip }) + .await.unwrap(); + + if failed_attempts > MAX_FAILED_LOGIN_ATTEMPTS { + return HttpResponse::InternalServerError().body( + FatalErrorPage { + message: "Too many failed login attempts, please try again later!" + }.render().unwrap() + ); + } + let redirect_uri = match query.redirect.as_deref() { None => "/", Some(s) => match s.starts_with('/') && !s.starts_with("//") { @@ -125,8 +151,10 @@ pub async fn login_route( } c => { - log::warn!("Failed login for username {} : {:?}", login, c); + log::warn!("Failed login for ip {:?} / username {}: {:?}", remote_ip, login, c); danger = "Login failed.".to_string(); + + bruteforce.send(bruteforce_actor::RecordFailedAttempt { ip: remote_ip }).await.unwrap(); } } } @@ -144,8 +172,8 @@ pub async fn login_route( }, min_pass_len: MIN_PASS_LEN, } - .render() - .unwrap(), + .render() + .unwrap(), ); } @@ -160,8 +188,8 @@ pub async fn login_route( }, login, } - .render() - .unwrap(), + .render() + .unwrap(), ) } diff --git a/src/data/app_config.rs b/src/data/app_config.rs index 9b0d07a..463e0ef 100644 --- a/src/data/app_config.rs +++ b/src/data/app_config.rs @@ -23,6 +23,10 @@ pub struct AppConfig { /// Website origin #[clap(short, long, env, default_value = "http://localhost:8000")] pub website_origin: String, + + /// Proxy IP, might end with a star "*" + #[clap(short, long, env)] + pub proxy_ip: Option, } impl AppConfig { diff --git a/src/middlewares/auth_middleware.rs b/src/middlewares/auth_middleware.rs index deed4ef..245f087 100644 --- a/src/middlewares/auth_middleware.rs +++ b/src/middlewares/auth_middleware.rs @@ -1,20 +1,20 @@ //! # Authentication middleware -use std::future::{ready, Future, Ready}; +use std::future::{Future, ready, Ready}; use std::pin::Pin; use std::rc::Rc; use actix_identity::RequestIdentity; -use actix_web::body::EitherBody; -use actix_web::http::{header, Method}; use actix_web::{ dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, - web, Error, HttpResponse, + Error, HttpResponse, web, }; +use actix_web::body::EitherBody; +use actix_web::http::{header, Method}; use askama::Template; use crate::constants::{ADMIN_ROUTES, AUTHENTICATED_ROUTES}; -use crate::controllers::base_controller::redirect_user_for_login; +use crate::controllers::base_controller::{FatalErrorPage, redirect_user_for_login}; use crate::data::app_config::AppConfig; use crate::data::session_identity::{SessionIdentity, SessionIdentityData, SessionStatus}; @@ -28,10 +28,10 @@ pub struct AuthMiddleware; // `S` - type of the next service // `B` - type of response's body impl Transform for AuthMiddleware -where - S: Service, Error = Error> + 'static, - S::Future: 'static, - B: 'static, + where + S: Service, Error=Error> + 'static, + S::Future: 'static, + B: 'static, { type Response = ServiceResponse>; type Error = Error; @@ -63,25 +63,22 @@ impl ConnStatus { } } -#[derive(Template)] -#[template(path = "access_denied.html")] -struct AccessDeniedTemplate {} pub struct AuthInnerMiddleware { service: Rc, } impl Service for AuthInnerMiddleware -where - S: Service, Error = Error> + 'static, - S::Future: 'static, - B: 'static, + where + S: Service, Error=Error> + 'static, + S::Future: 'static, + B: 'static, { type Response = ServiceResponse>; type Error = Error; #[allow(clippy::type_complexity)] - type Future = Pin>>>; + type Future = Pin>>>; forward_ready!(service); @@ -120,21 +117,21 @@ where let session = match SessionIdentity::deserialize_session_data(req.get_identity()) { Some(SessionIdentityData { - status: SessionStatus::SignedIn, - is_admin: true, - .. - }) => ConnStatus::Admin, + status: SessionStatus::SignedIn, + is_admin: true, + .. + }) => ConnStatus::Admin, Some(SessionIdentityData { - status: SessionStatus::SignedIn, - .. - }) => ConnStatus::RegularUser, + status: SessionStatus::SignedIn, + .. + }) => ConnStatus::RegularUser, _ => ConnStatus::SignedOut, }; // Redirect user to login page if !session.is_auth() && (req.path().starts_with(ADMIN_ROUTES) - || req.path().starts_with(AUTHENTICATED_ROUTES)) + || req.path().starts_with(AUTHENTICATED_ROUTES)) { let path = req.path().to_string(); return Ok(req @@ -147,7 +144,9 @@ where return Ok(req .into_response( HttpResponse::Unauthorized() - .body(AccessDeniedTemplate {}.render().unwrap()), + .body(FatalErrorPage { + message: "You are not allowed to access this resource." + }.render().unwrap()), ) .map_into_right_body()); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index dfd8879..b3d702f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod err; pub mod time; +pub mod network_utils; \ No newline at end of file diff --git a/src/utils/network_utils.rs b/src/utils/network_utils.rs new file mode 100644 index 0000000..5cb251d --- /dev/null +++ b/src/utils/network_utils.rs @@ -0,0 +1,102 @@ +use std::net::{IpAddr, Ipv6Addr}; +use std::str::FromStr; + +use actix_web::HttpRequest; + +/// Check if two ips matches +pub fn match_ip(pattern: &str, ip: &str) -> bool { + if pattern.eq(ip) { + return true; + } + + if pattern.ends_with('*') && ip.starts_with(&pattern.replace('*', "")) { + return true; + } + + false +} + + +/// Get the remote IP address +pub fn get_remote_ip(req: &HttpRequest, proxy_ip: Option<&str>) -> String { + let mut ip = req.peer_addr().unwrap().ip().to_string(); + + // We check if the request comes from a trusted reverse proxy + if let Some(proxy) = proxy_ip.as_ref() { + if match_ip(proxy, &ip) { + if let Some(header) = req.headers().get("X-Forwarded-For") { + let header: Vec = header + .to_str() + .unwrap() + .to_string() + .split(',') + .map(|f| f.to_string()) + .collect(); + + if !header.is_empty() { + ip = header[0].to_string(); + } + } + } + } + + ip +} + +/// Parse an IP address +pub fn parse_ip(ip: &str) -> Option { + let mut ip = match IpAddr::from_str(ip) { + Ok(ip) => ip, + Err(e) => { + log::warn!("Failed to parse an IP address: {}", e); + return None; + } + }; + + if let IpAddr::V6(ipv6) = &mut ip { + let mut octets = ipv6.octets(); + for i in 8..16 { + octets[i] = 0; + } + ip = IpAddr::V6(Ipv6Addr::from(octets)); + } + + Some(ip) +} + +#[cfg(test)] +mod test { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use crate::utils::network_utils::parse_ip; + + #[test] + fn parse_bad_ip() { + let ip = parse_ip("badbad"); + assert_eq!(None, ip); + } + + #[test] + fn parse_ip_v4_address() { + let ip = parse_ip("192.168.1.1").unwrap(); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))); + } + + #[test] + fn parse_ip_v6_address() { + let ip = parse_ip("2a00:1450:4007:813::200e").unwrap(); + assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0x2a00, 0x1450, 0x4007, 0x813, 0, 0, 0, 0))); + } + + #[test] + fn parse_ip_v6_address_2() { + let ip = parse_ip("::1").unwrap(); + assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0))); + } + + #[test] + fn parse_ip_v6_address_3() { + let ip = parse_ip("a::1").unwrap(); + assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0xa, 0, 0, 0, 0, 0, 0, 0))); + } +} \ No newline at end of file diff --git a/templates/access_denied.html b/templates/fatal_error.html similarity index 79% rename from templates/access_denied.html rename to templates/fatal_error.html index 550d14b..6f331c1 100644 --- a/templates/access_denied.html +++ b/templates/fatal_error.html @@ -7,6 +7,6 @@ -

You are not allowed to access this resource.

+

{{ message }}

\ No newline at end of file