use std::{
sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use dashmap::DashMap;
use crate::error::{AuthError, Result};
pub trait Clock: Send + Sync {
fn now_unix_secs(&self) -> u64;
}
impl<F> Clock for F
where
F: Fn() -> u64 + Send + Sync,
{
fn now_unix_secs(&self) -> u64 {
self()
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now_unix_secs(&self) -> u64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs(),
Err(e) => {
tracing::warn!(
error = %e,
"System time error in rate limiter — brute-force protection \
continues using frozen timestamps. System clock may have moved \
backward or time source is unavailable."
);
0
},
}
}
}
#[derive(Debug, Clone)]
pub struct AuthRateLimitConfig {
pub enabled: bool,
pub max_requests: u32,
pub window_secs: u64,
}
impl AuthRateLimitConfig {
#[must_use]
pub const fn per_ip_standard() -> Self {
Self {
enabled: true,
max_requests: 100,
window_secs: 60,
}
}
#[must_use]
pub const fn per_ip_strict() -> Self {
Self {
enabled: true,
max_requests: 50,
window_secs: 60,
}
}
#[must_use]
pub const fn per_user_standard() -> Self {
Self {
enabled: true,
max_requests: 10,
window_secs: 60,
}
}
#[must_use]
pub const fn failed_login_attempts() -> Self {
Self {
enabled: true,
max_requests: 5,
window_secs: 3600,
}
}
}
#[derive(Debug, Clone)]
struct RequestRecord {
count: u32,
window_start: u64,
}
const PURGE_INTERVAL: u64 = 1_000;
const DEFAULT_MAX_ENTRIES: usize = 100_000;
pub struct KeyedRateLimiter<C: Clock = SystemClock> {
records: Arc<DashMap<String, RequestRecord>>,
config: AuthRateLimitConfig,
max_entries: usize,
check_count: AtomicU64,
record_count: Arc<AtomicUsize>,
insert_guard: Arc<parking_lot::Mutex<()>>,
clock: C,
}
impl<C: Clock + Clone> Clone for KeyedRateLimiter<C> {
fn clone(&self) -> Self {
Self {
records: Arc::clone(&self.records),
config: self.config.clone(),
max_entries: self.max_entries,
check_count: AtomicU64::new(self.check_count.load(Ordering::Relaxed)),
record_count: Arc::clone(&self.record_count),
insert_guard: Arc::clone(&self.insert_guard),
clock: self.clock.clone(),
}
}
}
impl KeyedRateLimiter<SystemClock> {
#[must_use]
pub fn new(config: AuthRateLimitConfig) -> Self {
Self::with_parts(config, DEFAULT_MAX_ENTRIES, SystemClock)
}
#[must_use]
pub fn with_max_entries(config: AuthRateLimitConfig, max_entries: usize) -> Self {
Self::with_parts(config, max_entries, SystemClock)
}
}
impl<C: Clock> KeyedRateLimiter<C> {
pub fn with_clock(config: AuthRateLimitConfig, clock: C) -> Self {
Self::with_parts(config, DEFAULT_MAX_ENTRIES, clock)
}
pub fn with_clock_and_max_entries(
config: AuthRateLimitConfig,
max_entries: usize,
clock: C,
) -> Self {
Self::with_parts(config, max_entries, clock)
}
fn with_parts(config: AuthRateLimitConfig, max_entries: usize, clock: C) -> Self {
Self {
records: Arc::new(DashMap::new()),
config,
max_entries,
check_count: AtomicU64::new(0),
record_count: Arc::new(AtomicUsize::new(0)),
insert_guard: Arc::new(parking_lot::Mutex::new(())),
clock,
}
}
pub fn check(&self, key: &str) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
let now = self.clock.now_unix_secs();
let count = self.check_count.fetch_add(1, Ordering::Relaxed);
if count.is_multiple_of(PURGE_INTERVAL) {
let _sweep_guard = self.insert_guard.lock();
let mut removed: usize = 0;
self.records.retain(|_, r| {
let keep = now < r.window_start.saturating_add(self.config.window_secs);
if !keep {
removed = removed.saturating_add(1);
}
keep
});
if removed > 0 {
self.record_count.fetch_sub(removed, Ordering::Relaxed);
}
}
if let Some(mut record) = self.records.get_mut(key) {
return Self::tick_existing(&mut record, &self.config, now);
}
let _insert_guard = self.insert_guard.lock();
if let Some(mut record) = self.records.get_mut(key) {
return Self::tick_existing(&mut record, &self.config, now);
}
if self.max_entries > 0 && self.record_count.load(Ordering::Relaxed) >= self.max_entries {
if let Some(oldest_key) = self
.records
.iter()
.min_by_key(|r| r.value().window_start)
.map(|r| r.key().clone())
{
if self.records.remove(&oldest_key).is_some() {
self.record_count.fetch_sub(1, Ordering::Relaxed);
tracing::debug!(
max_entries = self.max_entries,
"Rate limiter at capacity — evicted oldest entry to make room for new key"
);
}
}
}
if self
.records
.insert(
key.to_string(),
RequestRecord {
count: 1,
window_start: now,
},
)
.is_none()
{
self.record_count.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
const fn tick_existing(
record: &mut RequestRecord,
config: &AuthRateLimitConfig,
now: u64,
) -> Result<()> {
if now >= record.window_start.saturating_add(config.window_secs) {
record.count = 1;
record.window_start = now;
Ok(())
} else if record.count < config.max_requests {
record.count += 1;
Ok(())
} else {
Err(AuthError::RateLimited {
retry_after_secs: config.window_secs,
})
}
}
pub fn active_limiters(&self) -> usize {
self.record_count.load(Ordering::Relaxed)
}
pub fn clear(&self) {
let _guard = self.insert_guard.lock();
self.records.clear();
self.record_count.store(0, Ordering::Relaxed);
}
pub fn clone_config(&self) -> AuthRateLimitConfig {
self.config.clone()
}
}
pub fn warn_if_single_node_rate_limiting() {
let should_warn = std::env::var("FRAISEQL_RATE_LIMIT_WARN_SINGLE_NODE")
.is_ok_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
let has_backend = std::env::var("FRAISEQL_RATE_LIMIT_BACKEND").is_ok();
if should_warn && !has_backend {
tracing::warn!(
"Rate limiter is per-process; multi-replica deployments are not protected against \
distributed brute-force. Configure a Redis-backed rate limiter via the \
`redis-rate-limiting` feature for distributed enforcement."
);
}
}
pub struct RateLimiters {
pub auth_start: KeyedRateLimiter,
pub auth_callback: KeyedRateLimiter,
pub auth_refresh: KeyedRateLimiter,
pub auth_logout: KeyedRateLimiter,
pub failed_logins: KeyedRateLimiter,
}
impl RateLimiters {
#[must_use]
pub fn new() -> Self {
Self {
auth_start: KeyedRateLimiter::new(AuthRateLimitConfig::per_ip_standard()),
auth_callback: KeyedRateLimiter::new(AuthRateLimitConfig::per_ip_strict()),
auth_refresh: KeyedRateLimiter::new(AuthRateLimitConfig::per_user_standard()),
auth_logout: KeyedRateLimiter::new(AuthRateLimitConfig::per_user_standard()),
failed_logins: KeyedRateLimiter::new(AuthRateLimitConfig::failed_login_attempts()),
}
}
#[must_use]
pub fn with_configs(
start_cfg: AuthRateLimitConfig,
callback_cfg: AuthRateLimitConfig,
refresh_cfg: AuthRateLimitConfig,
logout_cfg: AuthRateLimitConfig,
failed_cfg: AuthRateLimitConfig,
) -> Self {
Self {
auth_start: KeyedRateLimiter::new(start_cfg),
auth_callback: KeyedRateLimiter::new(callback_cfg),
auth_refresh: KeyedRateLimiter::new(refresh_cfg),
auth_logout: KeyedRateLimiter::new(logout_cfg),
failed_logins: KeyedRateLimiter::new(failed_cfg),
}
}
}
impl Default for RateLimiters {
fn default() -> Self {
Self::new()
}
}