[feat](trx-frontend-http): add per-IP login rate limiting

Implement LoginRateLimiter that tracks failed login attempts per IP,
enforcing a cooldown (10 attempts per 60s window) to mitigate brute-
force attacks on the /auth/login endpoint.

https://claude.ai/code/session_01XzurkeuUmamBuhQwxVy7T4
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-25 22:43:21 +00:00
committed by Stan Grams
parent c299e9a2d2
commit d7c8eed44f
@@ -16,8 +16,8 @@ use actix_web::{
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime}; use std::time::{Duration, Instant, SystemTime};
/// Unique session identifier (hex-encoded 128-bit random) /// Unique session identifier (hex-encoded 128-bit random)
pub type SessionId = String; pub type SessionId = String;
@@ -201,10 +201,70 @@ impl AuthConfig {
} }
} }
/// Simple per-IP rate limiter for login attempts.
///
/// Tracks failed attempts per IP and enforces a cooldown window after
/// exceeding the maximum number of attempts.
pub struct LoginRateLimiter {
/// Maps IP → (attempt_count, window_start).
attempts: Mutex<HashMap<String, (u32, Instant)>>,
/// Maximum allowed attempts within the window.
max_attempts: u32,
/// Duration of the rate-limit window.
window: Duration,
}
impl LoginRateLimiter {
pub fn new(max_attempts: u32, window: Duration) -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
max_attempts,
window,
}
}
/// Check whether an IP is rate-limited. Returns `true` if the request
/// should be allowed, `false` if rate-limited.
pub fn check(&self, ip: &str) -> bool {
let mut map = self.attempts.lock().unwrap();
let now = Instant::now();
if let Some((count, window_start)) = map.get_mut(ip) {
if now.duration_since(*window_start) > self.window {
// Window expired, reset.
*count = 1;
*window_start = now;
true
} else if *count >= self.max_attempts {
false
} else {
*count += 1;
true
}
} else {
map.insert(ip.to_string(), (1, now));
true
}
}
/// Record a successful login — clears the rate-limit counter for the IP.
pub fn reset(&self, ip: &str) {
let mut map = self.attempts.lock().unwrap();
map.remove(ip);
}
}
impl Default for LoginRateLimiter {
fn default() -> Self {
// 10 attempts per 60-second window.
Self::new(10, Duration::from_secs(60))
}
}
/// Application data for authentication /// Application data for authentication
pub struct AuthState { pub struct AuthState {
pub config: AuthConfig, pub config: AuthConfig,
pub store: SessionStore, pub store: SessionStore,
pub rate_limiter: LoginRateLimiter,
} }
impl AuthState { impl AuthState {
@@ -212,6 +272,7 @@ impl AuthState {
Self { Self {
config, config,
store: SessionStore::new(), store: SessionStore::new(),
rate_limiter: LoginRateLimiter::default(),
} }
} }
} }
@@ -272,7 +333,7 @@ pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<Aut
/// POST /auth/login /// POST /auth/login
#[post("/auth/login")] #[post("/auth/login")]
pub async fn login( pub async fn login(
_req: HttpRequest, req: HttpRequest,
body: web::Json<LoginRequest>, body: web::Json<LoginRequest>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<impl Responder, Error> { ) -> Result<impl Responder, Error> {
@@ -280,6 +341,17 @@ pub async fn login(
return Ok(HttpResponse::NotFound().finish()); return Ok(HttpResponse::NotFound().finish());
} }
// Per-IP rate limiting to mitigate brute-force attacks.
let peer_ip = req
.peer_addr()
.map(|a| a.ip().to_string())
.unwrap_or_default();
if !auth_state.rate_limiter.check(&peer_ip) {
return Ok(HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many login attempts, please try again later"
})));
}
// Check passphrase // Check passphrase
let role = match auth_state.config.check_passphrase(&body.passphrase) { let role = match auth_state.config.check_passphrase(&body.passphrase) {
Some(r) => r, Some(r) => r,
@@ -290,6 +362,9 @@ pub async fn login(
} }
}; };
// Successful login — clear rate limit counter.
auth_state.rate_limiter.reset(&peer_ip);
// Create session // Create session
let session_id = auth_state.store.create(role, auth_state.config.session_ttl); let session_id = auth_state.store.create(role, auth_state.config.session_ttl);