use std::time::Duration;
use dashmap::DashMap;
use tokio::time::Instant;
const FAILURES_BEFORE_LOCKOUT: u32 = 5;
const LOCKOUT_BASE: Duration = Duration::from_millis(500);
const LOCKOUT_MAX: Duration = Duration::from_secs(30);
const FAILURE_TTL: Duration = Duration::from_secs(300);
const EVICT_THRESHOLD: usize = 10_000;
#[derive(Debug, Clone, Copy)]
struct FailureRecord {
consecutive: u32,
locked_until: Option<Instant>,
last_seen: Instant,
}
#[derive(Debug, Default)]
pub struct FailedAuthTracker {
clients: DashMap<String, FailureRecord>,
}
impl FailedAuthTracker {
pub fn locked_for(&self, client: &str) -> Option<Duration> {
let rec = self.clients.get(client)?;
let until = rec.locked_until?;
until.checked_duration_since(Instant::now())
}
pub fn record_failure(&self, client: &str) -> Option<Duration> {
let now = Instant::now();
if self.clients.len() >= EVICT_THRESHOLD {
self.evict_stale();
}
let mut entry = self
.clients
.entry(client.to_string())
.or_insert(FailureRecord {
consecutive: 0,
locked_until: None,
last_seen: now,
});
if now.duration_since(entry.last_seen) > FAILURE_TTL {
entry.consecutive = 0;
entry.locked_until = None;
}
entry.consecutive = entry.consecutive.saturating_add(1);
entry.last_seen = now;
if entry.consecutive < FAILURES_BEFORE_LOCKOUT {
return None;
}
let steps = entry.consecutive - FAILURES_BEFORE_LOCKOUT;
let backoff = LOCKOUT_BASE
.checked_mul(1u32.checked_shl(steps.min(16)).unwrap_or(u32::MAX))
.unwrap_or(LOCKOUT_MAX)
.min(LOCKOUT_MAX);
entry.locked_until = Some(now + backoff);
Some(backoff)
}
pub fn record_success(&self, client: &str) {
self.clients.remove(client);
}
fn evict_stale(&self) {
let now = Instant::now();
self.clients
.retain(|_, rec| now.duration_since(rec.last_seen) <= FAILURE_TTL);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn backoff_starts_only_after_a_grace_period() {
let t = FailedAuthTracker::default();
for _ in 1..FAILURES_BEFORE_LOCKOUT {
assert!(
t.record_failure("1.2.3.4").is_none(),
"a few typos must not lock anyone out"
);
assert!(t.locked_for("1.2.3.4").is_none());
}
let first = t.record_failure("1.2.3.4").expect("lockout starts");
assert_eq!(first, LOCKOUT_BASE);
assert!(t.locked_for("1.2.3.4").is_some());
}
#[tokio::test(start_paused = true)]
async fn backoff_doubles_and_is_capped() {
let t = FailedAuthTracker::default();
let mut last = Duration::ZERO;
for _ in 0..40 {
if let Some(d) = t.record_failure("1.2.3.4") {
assert!(d >= last, "backoff must not shrink");
last = d;
}
}
assert_eq!(last, LOCKOUT_MAX, "backoff must saturate, not overflow");
}
#[tokio::test(start_paused = true)]
async fn lockout_expires_on_the_monotonic_clock() {
let t = FailedAuthTracker::default();
for _ in 0..FAILURES_BEFORE_LOCKOUT {
t.record_failure("1.2.3.4");
}
assert!(t.locked_for("1.2.3.4").is_some());
tokio::time::advance(LOCKOUT_BASE + Duration::from_millis(1)).await;
assert!(
t.locked_for("1.2.3.4").is_none(),
"the lockout must lift once it elapses"
);
}
#[tokio::test(start_paused = true)]
async fn success_clears_the_record_and_clients_are_independent() {
let t = FailedAuthTracker::default();
for _ in 0..FAILURES_BEFORE_LOCKOUT {
t.record_failure("1.2.3.4");
}
assert!(t.locked_for("1.2.3.4").is_some());
assert!(t.locked_for("5.6.7.8").is_none());
t.record_success("1.2.3.4");
assert!(t.locked_for("1.2.3.4").is_none());
assert!(
t.record_failure("1.2.3.4").is_none(),
"the counter must restart after a success"
);
}
}