use super::models::{NewUnifiedLoginThrottle, UnifiedLoginThrottle};
use super::DAL;
use crate::database::schema::unified::login_throttle;
use crate::database::universal_types::UniversalTimestamp;
use crate::error::ValidationError;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use diesel::prelude::*;
pub const SCOPE_USERNAME: &str = "username";
pub const SCOPE_IP: &str = "ip";
pub fn username_key(tenant: Option<&str>, username: &str) -> String {
format!("u:{}/{}", tenant.unwrap_or("_"), username)
}
pub fn ip_key(ip: &str) -> String {
format!("ip:{ip}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThrottlePolicy {
pub threshold: i32,
pub base_lock: ChronoDuration,
pub max_lock: ChronoDuration,
pub decay: ChronoDuration,
}
impl ThrottlePolicy {
pub fn username_default() -> Self {
Self {
threshold: 5,
base_lock: ChronoDuration::seconds(30),
max_lock: ChronoDuration::minutes(15),
decay: ChronoDuration::minutes(15),
}
}
pub fn ip_default() -> Self {
Self {
threshold: 50,
base_lock: ChronoDuration::minutes(15),
max_lock: ChronoDuration::minutes(15),
decay: ChronoDuration::minutes(15),
}
}
fn lock_for(&self, failure_count: i32) -> Option<ChronoDuration> {
let over = failure_count - self.threshold;
if over <= 0 {
return None;
}
let shift = (over - 1).clamp(0, 20) as u32;
let scaled = self
.base_lock
.checked_mul(1i32 << shift)
.unwrap_or(self.max_lock);
Some(if scaled > self.max_lock {
self.max_lock
} else {
scaled
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThrottleState {
pub scope: String,
pub failure_count: i32,
pub locked_until: Option<DateTime<Utc>>,
pub newly_locked: bool,
}
impl ThrottleState {
pub fn is_locked_at(&self, now: DateTime<Utc>) -> bool {
self.locked_until.map(|t| t > now).unwrap_or(false)
}
}
#[derive(Clone)]
pub struct LoginThrottleDAL<'a> {
dal: &'a DAL,
}
impl<'a> LoginThrottleDAL<'a> {
pub fn new(dal: &'a DAL) -> Self {
Self { dal }
}
pub async fn locked_until(&self, key: &str) -> Result<Option<DateTime<Utc>>, ValidationError> {
let key = key.to_string();
let now = UniversalTimestamp::now();
let row: Option<UnifiedLoginThrottle> = crate::interact_on_backend!(self.dal, |conn| {
login_throttle::table
.filter(login_throttle::throttle_key.eq(&key))
.first::<UnifiedLoginThrottle>(conn)
.optional()
})
.map_err(ValidationError::from)?;
Ok(row
.and_then(|r| r.locked_until)
.filter(|t| t.0 > now.0)
.map(|t| t.0))
}
pub async fn record_failure(
&self,
key: &str,
scope: &str,
policy: ThrottlePolicy,
) -> Result<ThrottleState, ValidationError> {
let key = key.to_string();
let scope = scope.to_string();
let now = UniversalTimestamp::now();
let state: ThrottleState = crate::interact_on_backend!(self.dal, |conn| {
conn.transaction::<ThrottleState, diesel::result::Error, _>(|conn| {
let existing: Option<UnifiedLoginThrottle> = login_throttle::table
.filter(login_throttle::throttle_key.eq(&key))
.first::<UnifiedLoginThrottle>(conn)
.optional()?;
let was_locked = existing
.as_ref()
.and_then(|r| r.locked_until)
.map(|t| t.0 > now.0)
.unwrap_or(false);
let (failure_count, first_failure_at) = match &existing {
Some(r) if now.0 - r.last_failure_at.0 > policy.decay => (1, now),
Some(r) => (r.failure_count.saturating_add(1), r.first_failure_at),
None => (1, now),
};
let locked_until = policy
.lock_for(failure_count)
.map(|d| UniversalTimestamp(now.0 + d));
let row = NewUnifiedLoginThrottle {
throttle_key: key.clone(),
scope: scope.clone(),
failure_count,
first_failure_at,
last_failure_at: now,
locked_until,
};
if existing.is_some() {
diesel::update(
login_throttle::table.filter(login_throttle::throttle_key.eq(&key)),
)
.set(&row)
.execute(conn)?;
} else {
diesel::insert_into(login_throttle::table)
.values(&row)
.execute(conn)?;
}
Ok(ThrottleState {
scope: scope.clone(),
failure_count,
locked_until: locked_until.map(|t| t.0),
newly_locked: locked_until.is_some() && !was_locked,
})
})
})
.map_err(ValidationError::from)?;
Ok(state)
}
pub async fn clear(&self, key: &str) -> Result<bool, ValidationError> {
let key = key.to_string();
let n: usize = crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(login_throttle::table.filter(login_throttle::throttle_key.eq(&key)))
.execute(conn)
})
.map_err(ValidationError::from)?;
Ok(n > 0)
}
pub async fn prune_idle(&self, older_than: ChronoDuration) -> Result<usize, ValidationError> {
let now = UniversalTimestamp::now();
let cutoff = UniversalTimestamp(now.0 - older_than);
crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(
login_throttle::table
.filter(login_throttle::last_failure_at.lt(cutoff))
.filter(
login_throttle::locked_until
.is_null()
.or(login_throttle::locked_until.lt(now)),
),
)
.execute(conn)
})
.map_err(ValidationError::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
#[test]
fn keys_are_scoped_and_do_not_collide() {
assert_eq!(username_key(Some("acme"), "bob"), "u:acme/bob");
assert_eq!(username_key(None, "bob"), "u:_/bob");
assert_ne!(username_key(Some("acme"), "bob"), ip_key("acme/bob"));
}
#[test]
fn backoff_is_exponential_and_capped() {
let p = ThrottlePolicy::username_default();
assert_eq!(p.lock_for(1), None, "under threshold must not lock");
assert_eq!(p.lock_for(5), None, "at threshold must not lock");
assert_eq!(p.lock_for(6), Some(ChronoDuration::seconds(30)));
assert_eq!(p.lock_for(7), Some(ChronoDuration::seconds(60)));
assert_eq!(p.lock_for(8), Some(ChronoDuration::seconds(120)));
assert_eq!(p.lock_for(100), Some(ChronoDuration::minutes(15)));
assert_eq!(p.lock_for(i32::MAX), Some(ChronoDuration::minutes(15)));
}
#[test]
fn ip_policy_is_far_looser_than_username_policy() {
assert!(
ThrottlePolicy::ip_default().threshold
> ThrottlePolicy::username_default().threshold * 5
);
}
#[cfg(feature = "sqlite")]
fn shared_url() -> String {
format!(
"file:login_throttle_test_{}?mode=memory&cache=shared",
uuid::Uuid::new_v4()
)
}
#[cfg(feature = "sqlite")]
async fn dal_for(url: &str) -> DAL {
let db = Database::new(url, "", 5);
db.run_migrations()
.await
.expect("migrations should succeed");
DAL::new(db)
}
#[cfg(feature = "sqlite")]
fn fast_policy() -> ThrottlePolicy {
ThrottlePolicy {
threshold: 3,
base_lock: ChronoDuration::milliseconds(150),
max_lock: ChronoDuration::milliseconds(150),
decay: ChronoDuration::minutes(15),
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn locks_after_threshold_then_expires() {
let dal = dal_for(&shared_url()).await;
let t = dal.login_throttle();
let key = username_key(Some("acme"), "bob");
let p = fast_policy();
for _ in 0..3 {
let s = t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
assert!(s.locked_until.is_none(), "must not lock at/below threshold");
}
assert!(t.locked_until(&key).await.unwrap().is_none());
let s = t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
assert_eq!(s.failure_count, 4);
assert!(s.newly_locked, "the crossing attempt reports the edge");
assert!(t.locked_until(&key).await.unwrap().is_some());
let s = t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
assert!(!s.newly_locked);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
assert!(
t.locked_until(&key).await.unwrap().is_none(),
"lock must expire on its own"
);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn success_clears_the_counter() {
let dal = dal_for(&shared_url()).await;
let t = dal.login_throttle();
let key = username_key(None, "carol");
let p = fast_policy();
t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
assert!(t.clear(&key).await.unwrap(), "row existed");
let s = t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
assert_eq!(s.failure_count, 1);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn throttle_state_is_shared_across_handles() {
let url = shared_url();
let dal_a = dal_for(&url).await;
let dal_b = DAL::new(Database::new(&url, "", 5));
let key = username_key(Some("acme"), "dave");
let p = ThrottlePolicy {
threshold: 3,
base_lock: ChronoDuration::seconds(60),
max_lock: ChronoDuration::seconds(60),
decay: ChronoDuration::minutes(15),
};
dal_a
.login_throttle()
.record_failure(&key, SCOPE_USERNAME, p)
.await
.unwrap();
dal_a
.login_throttle()
.record_failure(&key, SCOPE_USERNAME, p)
.await
.unwrap();
let s = dal_b
.login_throttle()
.record_failure(&key, SCOPE_USERNAME, p)
.await
.unwrap();
assert_eq!(s.failure_count, 3, "B must continue A's count, not restart");
let s = dal_b
.login_throttle()
.record_failure(&key, SCOPE_USERNAME, p)
.await
.unwrap();
assert!(s.newly_locked);
assert!(dal_a
.login_throttle()
.locked_until(&key)
.await
.unwrap()
.is_some());
dal_a.login_throttle().clear(&key).await.unwrap();
assert!(dal_b
.login_throttle()
.locked_until(&key)
.await
.unwrap()
.is_none());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn stale_counters_decay_and_prune() {
let dal = dal_for(&shared_url()).await;
let t = dal.login_throttle();
let key = username_key(None, "erin");
let p = ThrottlePolicy {
threshold: 3,
base_lock: ChronoDuration::milliseconds(50),
max_lock: ChronoDuration::milliseconds(50),
decay: ChronoDuration::milliseconds(100),
};
t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let s = t.record_failure(&key, SCOPE_USERNAME, p).await.unwrap();
assert_eq!(s.failure_count, 1, "idle counter restarts");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
t.prune_idle(ChronoDuration::milliseconds(100))
.await
.unwrap(),
1
);
}
}