use lazy_static::lazy_static;
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::RwLock;
#[derive(Clone, Debug)]
pub struct Limit {
pub max_attempts: u32,
pub window_secs: i64,
}
impl Limit {
pub const fn new(max_attempts: u32, window_secs: i64) -> Self {
Self {
max_attempts,
window_secs,
}
}
}
pub const DEFAULT_LOGIN_LIMIT: Limit = Limit::new(10, 900);
pub const DEFAULT_MFA_LIMIT: Limit = Limit::new(5, 900);
const MAX_TRACKED_KEYS: usize = 10_000;
#[derive(Clone, Debug)]
pub struct RateLimitConfig {
pub login: Option<Limit>,
pub mfa: Option<Limit>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
login: Some(DEFAULT_LOGIN_LIMIT),
mfa: Some(DEFAULT_MFA_LIMIT),
}
}
}
static CONFIG: OnceCell<RateLimitConfig> = OnceCell::new();
pub fn configure(config: RateLimitConfig) {
if CONFIG.set(config).is_err() {
tracing::warn!("adminx rate limits already configured; ignoring reconfigure");
}
}
fn config() -> &'static RateLimitConfig {
CONFIG.get_or_init(RateLimitConfig::default)
}
pub fn login_limit() -> Option<&'static Limit> {
config().login.as_ref()
}
pub fn mfa_limit() -> Option<&'static Limit> {
config().mfa.as_ref()
}
#[derive(Clone, Debug)]
struct Window {
count: u32,
expires_at: i64,
}
lazy_static! {
static ref FAILURES: RwLock<HashMap<String, Window>> = RwLock::new(HashMap::new());
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn is_limited_at(key: &str, limit: &Limit, now: i64) -> bool {
match FAILURES.read().unwrap_or_else(|e| e.into_inner()).get(key) {
Some(w) if w.expires_at > now => w.count >= limit.max_attempts,
_ => false,
}
}
fn record_failure_at(key: &str, limit: &Limit, now: i64) -> u32 {
let mut map = FAILURES.write().unwrap_or_else(|e| e.into_inner());
if map.len() >= MAX_TRACKED_KEYS {
map.retain(|_, w| w.expires_at > now);
}
let entry = map.entry(key.to_string()).or_insert(Window {
count: 0,
expires_at: now + limit.window_secs,
});
if entry.expires_at <= now {
entry.count = 0;
entry.expires_at = now + limit.window_secs;
}
entry.count += 1;
entry.count
}
pub fn is_limited(key: &str, limit: &Limit) -> bool {
is_limited_at(key, limit, now_secs())
}
pub fn record_failure(key: &str, limit: &Limit) {
record_failure_at(key, limit, now_secs());
}
pub fn reset(key: &str) {
FAILURES.write().unwrap_or_else(|e| e.into_inner()).remove(key);
}
#[doc(hidden)]
pub fn clear_all() {
FAILURES.write().unwrap_or_else(|e| e.into_inner()).clear();
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
const T0: i64 = 1_000_000;
lazy_static! {
static ref TEST_LOCK: Mutex<()> = Mutex::new(());
}
fn isolated() -> MutexGuard<'static, ()> {
let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_all();
guard
}
#[test]
fn throttles_only_after_the_limit_is_reached() {
let _g = isolated();
let limit = Limit::new(3, 60);
let key = "throttles-only-after";
assert!(!is_limited_at(key, &limit, T0), "clean key is not limited");
assert_eq!(record_failure_at(key, &limit, T0), 1);
assert_eq!(record_failure_at(key, &limit, T0), 2);
assert!(!is_limited_at(key, &limit, T0), "under the limit, still allowed");
assert_eq!(record_failure_at(key, &limit, T0), 3);
assert!(is_limited_at(key, &limit, T0), "at the limit, throttled");
}
#[test]
fn window_lapses_and_the_count_starts_over() {
let _g = isolated();
let limit = Limit::new(2, 60);
let key = "window-lapses";
record_failure_at(key, &limit, T0);
record_failure_at(key, &limit, T0);
assert!(is_limited_at(key, &limit, T0));
assert!(is_limited_at(key, &limit, T0 + 59));
assert!(!is_limited_at(key, &limit, T0 + 61));
assert_eq!(
record_failure_at(key, &limit, T0 + 61),
1,
"a lapsed window restarts at one rather than accumulating"
);
}
#[test]
fn window_is_anchored_at_the_first_failure() {
let _g = isolated();
let limit = Limit::new(2, 60);
let key = "anchored";
record_failure_at(key, &limit, T0);
record_failure_at(key, &limit, T0 + 50);
assert!(is_limited_at(key, &limit, T0 + 50));
assert!(
!is_limited_at(key, &limit, T0 + 61),
"expiry stays anchored to the first failure"
);
}
#[test]
fn success_clears_the_count() {
let _g = isolated();
let limit = Limit::new(2, 60);
let key = "success-clears";
record_failure_at(key, &limit, T0);
record_failure_at(key, &limit, T0);
assert!(is_limited_at(key, &limit, T0));
reset(key);
assert!(!is_limited_at(key, &limit, T0), "reset lifts the throttle");
}
#[test]
fn keys_are_tracked_independently() {
let _g = isolated();
let limit = Limit::new(1, 60);
record_failure_at("alice", &limit, T0);
assert!(is_limited_at("alice", &limit, T0));
assert!(
!is_limited_at("bob", &limit, T0),
"one account's failures must not throttle another"
);
}
#[test]
fn expired_entries_are_pruned_under_pressure() {
let _g = isolated();
let limit = Limit::new(1, 60);
for i in 0..MAX_TRACKED_KEYS {
record_failure_at(&format!("key-{i}"), &limit, T0);
}
assert_eq!(FAILURES.read().unwrap_or_else(|e| e.into_inner()).len(), MAX_TRACKED_KEYS);
record_failure_at("newcomer", &limit, T0 + 61);
let len = FAILURES.read().unwrap_or_else(|e| e.into_inner()).len();
assert!(
len < MAX_TRACKED_KEYS,
"expired entries should be pruned, still holding {len}"
);
assert!(is_limited_at("newcomer", &limit, T0 + 61));
}
}