use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const MAX_USER_FAILURES: u32 = 5;
const USER_LOCKOUT: Duration = Duration::from_secs(15 * 60);
const IP_MAX_ATTEMPTS: u32 = 30;
const IP_WINDOW: Duration = Duration::from_secs(60);
const PRUNE_AT: usize = 4096;
#[derive(Default)]
struct UserState {
failures: u32,
locked_until: Option<Instant>,
}
struct IpState {
count: u32,
window_start: Instant,
}
#[derive(Default)]
pub struct LoginThrottle {
users: Mutex<HashMap<String, UserState>>,
ips: Mutex<HashMap<String, IpState>>,
}
impl LoginThrottle {
pub fn check(&self, username: &str, ip: &str) -> Result<(), u64> {
self.check_at(username, ip, Instant::now())
}
pub fn record_failure(&self, username: &str, ip: &str) {
self.record_failure_at(username, ip, Instant::now());
}
pub fn record_success(&self, username: &str) {
self.users.lock().unwrap().remove(username);
}
fn check_at(&self, username: &str, ip: &str, now: Instant) -> Result<(), u64> {
if let Some(u) = self.users.lock().unwrap().get(username) {
if let Some(until) = u.locked_until {
if until > now {
return Err((until - now).as_secs() + 1);
}
}
}
if let Some(s) = self.ips.lock().unwrap().get(ip) {
let elapsed = now.saturating_duration_since(s.window_start);
if elapsed < IP_WINDOW && s.count >= IP_MAX_ATTEMPTS {
return Err((IP_WINDOW - elapsed).as_secs() + 1);
}
}
Ok(())
}
fn record_failure_at(&self, username: &str, ip: &str, now: Instant) {
{
let mut users = self.users.lock().unwrap();
if users.len() >= PRUNE_AT {
users.retain(|_, u| u.locked_until.is_some_and(|t| t > now));
}
let u = users.entry(username.to_string()).or_default();
if u.locked_until.is_some_and(|t| t <= now) {
u.failures = 0;
u.locked_until = None;
}
u.failures += 1;
if u.failures >= MAX_USER_FAILURES {
u.locked_until = Some(now + USER_LOCKOUT);
}
}
{
let mut ips = self.ips.lock().unwrap();
if ips.len() >= PRUNE_AT {
ips.retain(|_, s| now.saturating_duration_since(s.window_start) < IP_WINDOW);
}
let s = ips.entry(ip.to_string()).or_insert(IpState {
count: 0,
window_start: now,
});
if now.saturating_duration_since(s.window_start) >= IP_WINDOW {
s.count = 0;
s.window_start = now;
}
s.count += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn account_locks_after_threshold_and_clears_on_success() {
let t = LoginThrottle::default();
let now = Instant::now();
for _ in 0..MAX_USER_FAILURES {
assert!(t.check_at("admin", "1.1.1.1", now).is_ok());
t.record_failure_at("admin", "1.1.1.1", now);
}
assert!(t.check_at("admin", "9.9.9.9", now).is_err());
assert!(t.check_at("other", "9.9.9.9", now).is_ok());
let later = now + USER_LOCKOUT + Duration::from_secs(1);
assert!(t.check_at("admin", "1.1.1.1", later).is_ok());
t.record_success("admin");
assert!(t.check_at("admin", "1.1.1.1", now).is_ok());
}
#[test]
fn expired_lock_gives_a_fresh_attempt_budget() {
let t = LoginThrottle::default();
let now = Instant::now();
for _ in 0..MAX_USER_FAILURES {
t.record_failure_at("u", "ip", now);
}
let later = now + USER_LOCKOUT + Duration::from_secs(1);
t.record_failure_at("u", "ip", later);
assert!(t.check_at("u", "otherip", later).is_ok());
}
#[test]
fn ip_window_caps_spray_then_resets() {
let t = LoginThrottle::default();
let now = Instant::now();
for i in 0..IP_MAX_ATTEMPTS {
let user = format!("u{i}");
assert!(t.check_at(&user, "5.5.5.5", now).is_ok());
t.record_failure_at(&user, "5.5.5.5", now);
}
assert!(t.check_at("unext", "5.5.5.5", now).is_err());
assert!(t.check_at("unext", "6.6.6.6", now).is_ok());
let later = now + IP_WINDOW + Duration::from_secs(1);
assert!(t.check_at("unext", "5.5.5.5", later).is_ok());
}
}