use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;
use tracing::{info, warn};
use uvb_core::TenantId;
#[derive(Debug, Error)]
pub enum BruteForceError {
#[error("account locked: {0}")]
AccountLocked(String),
#[error("too many attempts: {0}")]
TooManyAttempts(String),
#[error("storage error: {0}")]
StorageError(String),
#[error("internal error: {0}")]
Internal(String),
}
#[derive(Clone, Debug)]
pub struct BruteForceConfig {
pub max_failed_attempts: u32,
pub lockout_duration_seconds: i64,
pub progressive_delay_enabled: bool,
pub progressive_delay_base_ms: u64,
pub exponential_lockout: bool,
pub max_lockout_duration_seconds: i64,
pub ip_based_tracking: bool,
pub max_failed_attempts_per_ip: u32,
pub ip_lockout_duration_seconds: i64,
pub reset_on_success: bool,
pub auto_unlock: bool,
}
impl Default for BruteForceConfig {
fn default() -> Self {
Self {
max_failed_attempts: 5,
lockout_duration_seconds: 900, progressive_delay_enabled: true,
progressive_delay_base_ms: 1000, exponential_lockout: true,
max_lockout_duration_seconds: 86400, ip_based_tracking: true,
max_failed_attempts_per_ip: 20,
ip_lockout_duration_seconds: 3600, reset_on_success: true,
auto_unlock: true,
}
}
}
impl BruteForceConfig {
pub fn strict() -> Self {
Self {
max_failed_attempts: 3,
lockout_duration_seconds: 1800, progressive_delay_enabled: true,
progressive_delay_base_ms: 2000, exponential_lockout: true,
max_lockout_duration_seconds: 172800, ip_based_tracking: true,
max_failed_attempts_per_ip: 10,
ip_lockout_duration_seconds: 7200, reset_on_success: true,
auto_unlock: true,
}
}
pub fn lenient() -> Self {
Self {
max_failed_attempts: 10,
lockout_duration_seconds: 300, progressive_delay_enabled: false,
progressive_delay_base_ms: 500,
exponential_lockout: false,
max_lockout_duration_seconds: 3600, ip_based_tracking: false,
max_failed_attempts_per_ip: 50,
ip_lockout_duration_seconds: 600, reset_on_success: true,
auto_unlock: true,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FailedAttempt {
pub timestamp: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub reason: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LockoutRecord {
pub locked_at: DateTime<Utc>,
pub unlock_at: DateTime<Utc>,
pub lockout_count: u32, pub failed_attempts: Vec<FailedAttempt>,
pub reason: String,
}
#[derive(Clone, Debug)]
pub struct BruteForceCheckResult {
pub allowed: bool,
pub delay_ms: Option<u64>,
pub remaining_attempts: Option<u32>,
pub locked_until: Option<DateTime<Utc>>,
pub reason: Option<String>,
}
#[async_trait]
pub trait BruteForceStore: Send + Sync {
async fn record_failed_attempt(
&self,
tenant_id: &TenantId,
user_id: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), BruteForceError>;
async fn get_failed_attempts(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<Vec<FailedAttempt>, BruteForceError>;
async fn clear_failed_attempts(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<(), BruteForceError>;
async fn set_lockout(
&self,
tenant_id: &TenantId,
user_id: &str,
lockout: LockoutRecord,
) -> Result<(), BruteForceError>;
async fn get_lockout(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<Option<LockoutRecord>, BruteForceError>;
async fn remove_lockout(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<(), BruteForceError>;
async fn record_failed_attempt_by_ip(&self, ip_address: &str) -> Result<(), BruteForceError>;
async fn get_failed_attempts_by_ip(
&self,
ip_address: &str,
) -> Result<Vec<FailedAttempt>, BruteForceError>;
async fn is_ip_locked(&self, ip_address: &str) -> Result<bool, BruteForceError>;
}
pub struct MemoryBruteForceStore {
user_attempts: Arc<RwLock<HashMap<String, Vec<FailedAttempt>>>>,
user_lockouts: Arc<RwLock<HashMap<String, LockoutRecord>>>,
ip_attempts: Arc<RwLock<HashMap<String, Vec<FailedAttempt>>>>,
ip_lockouts: Arc<RwLock<HashMap<String, DateTime<Utc>>>>,
}
impl MemoryBruteForceStore {
pub fn new() -> Self {
Self {
user_attempts: Arc::new(RwLock::new(HashMap::new())),
user_lockouts: Arc::new(RwLock::new(HashMap::new())),
ip_attempts: Arc::new(RwLock::new(HashMap::new())),
ip_lockouts: Arc::new(RwLock::new(HashMap::new())),
}
}
fn user_key(tenant_id: &TenantId, user_id: &str) -> String {
format!("{}:{}", tenant_id, user_id)
}
}
impl Default for MemoryBruteForceStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl BruteForceStore for MemoryBruteForceStore {
async fn record_failed_attempt(
&self,
tenant_id: &TenantId,
user_id: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), BruteForceError> {
let key = Self::user_key(tenant_id, user_id);
let attempt = FailedAttempt {
timestamp: Utc::now(),
ip_address: ip_address.map(|s| s.to_string()),
user_agent: user_agent.map(|s| s.to_string()),
reason: None,
};
let mut attempts = self.user_attempts.write().await;
attempts.entry(key).or_insert_with(Vec::new).push(attempt);
Ok(())
}
async fn get_failed_attempts(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<Vec<FailedAttempt>, BruteForceError> {
let key = Self::user_key(tenant_id, user_id);
let attempts = self.user_attempts.read().await;
Ok(attempts.get(&key).cloned().unwrap_or_default())
}
async fn clear_failed_attempts(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<(), BruteForceError> {
let key = Self::user_key(tenant_id, user_id);
let mut attempts = self.user_attempts.write().await;
attempts.remove(&key);
Ok(())
}
async fn set_lockout(
&self,
tenant_id: &TenantId,
user_id: &str,
lockout: LockoutRecord,
) -> Result<(), BruteForceError> {
let key = Self::user_key(tenant_id, user_id);
let mut lockouts = self.user_lockouts.write().await;
lockouts.insert(key, lockout);
Ok(())
}
async fn get_lockout(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<Option<LockoutRecord>, BruteForceError> {
let key = Self::user_key(tenant_id, user_id);
let lockouts = self.user_lockouts.read().await;
Ok(lockouts.get(&key).cloned())
}
async fn remove_lockout(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<(), BruteForceError> {
let key = Self::user_key(tenant_id, user_id);
let mut lockouts = self.user_lockouts.write().await;
lockouts.remove(&key);
Ok(())
}
async fn record_failed_attempt_by_ip(&self, ip_address: &str) -> Result<(), BruteForceError> {
let attempt = FailedAttempt {
timestamp: Utc::now(),
ip_address: Some(ip_address.to_string()),
user_agent: None,
reason: None,
};
let mut attempts = self.ip_attempts.write().await;
attempts
.entry(ip_address.to_string())
.or_insert_with(Vec::new)
.push(attempt);
Ok(())
}
async fn get_failed_attempts_by_ip(
&self,
ip_address: &str,
) -> Result<Vec<FailedAttempt>, BruteForceError> {
let attempts = self.ip_attempts.read().await;
Ok(attempts.get(ip_address).cloned().unwrap_or_default())
}
async fn is_ip_locked(&self, ip_address: &str) -> Result<bool, BruteForceError> {
let lockouts = self.ip_lockouts.read().await;
if let Some(unlock_at) = lockouts.get(ip_address) {
Ok(Utc::now() < *unlock_at)
} else {
Ok(false)
}
}
}
pub struct BruteForceProtection {
config: BruteForceConfig,
store: Arc<dyn BruteForceStore>,
}
impl BruteForceProtection {
pub fn new(config: BruteForceConfig, store: Arc<dyn BruteForceStore>) -> Self {
Self { config, store }
}
pub async fn check_authentication_allowed(
&self,
tenant_id: &TenantId,
user_id: &str,
ip_address: Option<&str>,
) -> Result<BruteForceCheckResult, BruteForceError> {
if self.config.ip_based_tracking {
if let Some(ip) = ip_address {
if self.store.is_ip_locked(ip).await? {
return Ok(BruteForceCheckResult {
allowed: false,
delay_ms: None,
remaining_attempts: None,
locked_until: None,
reason: Some("IP address is temporarily blocked".to_string()),
});
}
let ip_attempts = self.store.get_failed_attempts_by_ip(ip).await?;
let recent_ip_attempts = self.count_recent_attempts(&ip_attempts);
if recent_ip_attempts >= self.config.max_failed_attempts_per_ip {
warn!("IP {} exceeded maximum attempts", ip);
return Ok(BruteForceCheckResult {
allowed: false,
delay_ms: None,
remaining_attempts: None,
locked_until: None,
reason: Some("Too many failed attempts from this IP address".to_string()),
});
}
}
}
if let Some(lockout) = self.store.get_lockout(tenant_id, user_id).await? {
if self.config.auto_unlock && Utc::now() >= lockout.unlock_at {
self.store.remove_lockout(tenant_id, user_id).await?;
info!("Auto-unlocked account for user {}", user_id);
} else {
warn!("Account locked for user {}", user_id);
return Ok(BruteForceCheckResult {
allowed: false,
delay_ms: None,
remaining_attempts: None,
locked_until: Some(lockout.unlock_at),
reason: Some(lockout.reason),
});
}
}
let attempts = self.store.get_failed_attempts(tenant_id, user_id).await?;
let recent_attempts = self.count_recent_attempts(&attempts);
if recent_attempts >= self.config.max_failed_attempts {
self.lock_account(tenant_id, user_id, attempts).await?;
return Ok(BruteForceCheckResult {
allowed: false,
delay_ms: None,
remaining_attempts: Some(0),
locked_until: None,
reason: Some("Account locked due to too many failed attempts".to_string()),
});
}
let delay_ms = if self.config.progressive_delay_enabled && recent_attempts > 0 {
Some(self.calculate_progressive_delay(recent_attempts))
} else {
None
};
Ok(BruteForceCheckResult {
allowed: true,
delay_ms,
remaining_attempts: Some(self.config.max_failed_attempts - recent_attempts),
locked_until: None,
reason: None,
})
}
pub async fn record_failed_attempt(
&self,
tenant_id: &TenantId,
user_id: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), BruteForceError> {
self.store
.record_failed_attempt(tenant_id, user_id, ip_address, user_agent)
.await?;
if self.config.ip_based_tracking {
if let Some(ip) = ip_address {
self.store.record_failed_attempt_by_ip(ip).await?;
}
}
info!("Recorded failed attempt for user {}", user_id);
Ok(())
}
pub async fn record_successful_attempt(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<(), BruteForceError> {
if self.config.reset_on_success {
self.store.clear_failed_attempts(tenant_id, user_id).await?;
info!(
"Cleared failed attempts for user {} after successful login",
user_id
);
}
Ok(())
}
pub async fn unlock_account(
&self,
tenant_id: &TenantId,
user_id: &str,
) -> Result<(), BruteForceError> {
self.store.remove_lockout(tenant_id, user_id).await?;
self.store.clear_failed_attempts(tenant_id, user_id).await?;
info!("Manually unlocked account for user {}", user_id);
Ok(())
}
fn count_recent_attempts(&self, attempts: &[FailedAttempt]) -> u32 {
let cutoff = Utc::now() - Duration::seconds(self.config.lockout_duration_seconds);
attempts.iter().filter(|a| a.timestamp > cutoff).count() as u32
}
fn calculate_progressive_delay(&self, attempt_count: u32) -> u64 {
let multiplier = 2u64.pow(attempt_count.saturating_sub(1));
(self.config.progressive_delay_base_ms * multiplier).min(30000) }
async fn lock_account(
&self,
tenant_id: &TenantId,
user_id: &str,
attempts: Vec<FailedAttempt>,
) -> Result<(), BruteForceError> {
let existing_lockout = self.store.get_lockout(tenant_id, user_id).await?;
let lockout_count = existing_lockout.map(|l| l.lockout_count).unwrap_or(0) + 1;
let lockout_duration = if self.config.exponential_lockout {
let duration = self.config.lockout_duration_seconds * (2i64.pow(lockout_count - 1));
duration.min(self.config.max_lockout_duration_seconds)
} else {
self.config.lockout_duration_seconds
};
let lockout = LockoutRecord {
locked_at: Utc::now(),
unlock_at: Utc::now() + Duration::seconds(lockout_duration),
lockout_count,
failed_attempts: attempts,
reason: format!(
"Account locked for {} seconds due to {} failed login attempts",
lockout_duration, self.config.max_failed_attempts
),
};
self.store.set_lockout(tenant_id, user_id, lockout).await?;
warn!(
"Locked account for user {} (lockout #{}, duration: {}s)",
user_id, lockout_count, lockout_duration
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_tenant_id() -> TenantId {
TenantId::new("test_tenant")
}
#[tokio::test]
async fn test_default_config() {
let config = BruteForceConfig::default();
assert_eq!(config.max_failed_attempts, 5);
assert_eq!(config.lockout_duration_seconds, 900);
assert!(config.progressive_delay_enabled);
}
#[tokio::test]
async fn test_allow_authentication_no_attempts() {
let config = BruteForceConfig::default();
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let result = protection
.check_authentication_allowed(&test_tenant_id(), "user1", None)
.await
.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining_attempts, Some(5));
}
#[tokio::test]
async fn test_failed_attempts_counting() {
let config = BruteForceConfig::default();
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let user_id = "user1";
for _ in 0..3 {
protection
.record_failed_attempt(&tenant_id, user_id, Some("192.168.1.1"), None)
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, user_id, Some("192.168.1.1"))
.await
.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining_attempts, Some(2)); }
#[tokio::test]
async fn test_account_lockout() {
let config = BruteForceConfig::default();
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let user_id = "user1";
for _ in 0..5 {
protection
.record_failed_attempt(&tenant_id, user_id, Some("192.168.1.1"), None)
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, user_id, Some("192.168.1.1"))
.await
.unwrap();
assert!(!result.allowed);
assert_eq!(result.remaining_attempts, Some(0));
assert!(result.locked_until.is_some() || result.reason.is_some());
}
#[tokio::test]
async fn test_progressive_delay() {
let config = BruteForceConfig::default();
let protection = BruteForceProtection::new(config, Arc::new(MemoryBruteForceStore::new()));
assert_eq!(protection.calculate_progressive_delay(1), 1000); assert_eq!(protection.calculate_progressive_delay(2), 2000); assert_eq!(protection.calculate_progressive_delay(3), 4000); assert_eq!(protection.calculate_progressive_delay(4), 8000); }
#[tokio::test]
async fn test_reset_on_success() {
let config = BruteForceConfig {
reset_on_success: true,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store.clone());
let tenant_id = test_tenant_id();
let user_id = "user1";
for _ in 0..3 {
protection
.record_failed_attempt(&tenant_id, user_id, None, None)
.await
.unwrap();
}
let attempts = store
.get_failed_attempts(&tenant_id, user_id)
.await
.unwrap();
assert_eq!(attempts.len(), 3);
protection
.record_successful_attempt(&tenant_id, user_id)
.await
.unwrap();
let attempts = store
.get_failed_attempts(&tenant_id, user_id)
.await
.unwrap();
assert_eq!(attempts.len(), 0);
}
#[tokio::test]
async fn test_manual_unlock() {
let config = BruteForceConfig::default();
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store.clone());
let tenant_id = test_tenant_id();
let user_id = "user1";
for _ in 0..5 {
protection
.record_failed_attempt(&tenant_id, user_id, None, None)
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, user_id, None)
.await
.unwrap();
assert!(!result.allowed);
protection
.unlock_account(&tenant_id, user_id)
.await
.unwrap();
let result = protection
.check_authentication_allowed(&tenant_id, user_id, None)
.await
.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn test_ip_based_tracking() {
let config = BruteForceConfig {
ip_based_tracking: true,
max_failed_attempts_per_ip: 3,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
for i in 0..3 {
protection
.record_failed_attempt(&tenant_id, &format!("user{}", i), Some("192.168.1.1"), None)
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, "user999", Some("192.168.1.1"))
.await
.unwrap();
assert!(!result.allowed);
assert!(result.reason.is_some());
}
#[tokio::test]
async fn test_distributed_attack_from_multiple_ips() {
let config = BruteForceConfig::default();
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let user_id = "target_user";
let ips = vec![
"192.168.1.1",
"192.168.1.2",
"192.168.1.3",
"192.168.1.4",
"192.168.1.5",
"10.0.0.1",
"10.0.0.2",
"10.0.0.3",
"10.0.0.4",
"10.0.0.5",
];
for ip in &ips {
for _ in 0..2 {
protection
.record_failed_attempt(&tenant_id, user_id, Some(ip), Some("Attack-UA"))
.await
.unwrap();
}
}
let result = protection
.check_authentication_allowed(&tenant_id, user_id, Some("192.168.1.100"))
.await
.unwrap();
assert!(
!result.allowed,
"User account should be locked despite distributed IPs"
);
assert_eq!(result.remaining_attempts, Some(0));
let result_ip = protection
.check_authentication_allowed(&tenant_id, "different_user", Some("192.168.1.1"))
.await
.unwrap();
assert!(
result_ip.allowed,
"IP should not be blocked (only 2 attempts from this IP)"
);
}
#[tokio::test]
async fn test_credential_stuffing_pattern() {
let config = BruteForceConfig {
ip_based_tracking: true,
max_failed_attempts_per_ip: 10,
max_failed_attempts: 3,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let attacker_ip = "203.0.113.50";
for i in 0..15 {
let user_id = format!("victim_{}", i);
protection
.record_failed_attempt(
&tenant_id,
&user_id,
Some(attacker_ip),
Some("Credential-Stuffer"),
)
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, "new_victim", Some(attacker_ip))
.await
.unwrap();
assert!(
!result.allowed,
"IP should be blocked after credential stuffing attempts"
);
assert!(result.reason.unwrap().contains("IP"));
}
#[tokio::test]
async fn test_password_spraying_attack() {
let config = BruteForceConfig {
ip_based_tracking: true,
max_failed_attempts_per_ip: 20,
max_failed_attempts: 5,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let attacker_ip = "198.51.100.25";
for i in 0..25 {
let user_id = format!("employee_{}", i);
protection
.record_failed_attempt(
&tenant_id,
&user_id,
Some(attacker_ip),
Some("Password-Sprayer"),
)
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, "employee_26", Some(attacker_ip))
.await
.unwrap();
assert!(
!result.allowed,
"Password spraying should be detected via IP tracking"
);
let result_user = protection
.check_authentication_allowed(&tenant_id, "employee_5", Some("192.168.1.1"))
.await
.unwrap();
assert!(
result_user.allowed,
"Individual accounts should not be locked with only 1 attempt"
);
assert_eq!(result_user.remaining_attempts, Some(4));
}
#[tokio::test]
async fn test_account_enumeration_attempt() {
let config = BruteForceConfig {
ip_based_tracking: true,
max_failed_attempts_per_ip: 50,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let scanner_ip = "192.0.2.100";
for i in 0..60 {
let user_id = format!("test_user_{}", i);
protection
.record_failed_attempt(&tenant_id, &user_id, Some(scanner_ip), Some("Scanner"))
.await
.unwrap();
}
let result = protection
.check_authentication_allowed(&tenant_id, "admin", Some(scanner_ip))
.await
.unwrap();
assert!(
!result.allowed,
"Account enumeration should be blocked via IP rate limiting"
);
}
#[tokio::test]
async fn test_timing_attack_resistance() {
let config = BruteForceConfig {
progressive_delay_enabled: true,
progressive_delay_base_ms: 1000,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let user_id = "timing_target";
let mut previous_delay = 0u64;
for attempt in 1..=4 {
protection
.record_failed_attempt(&tenant_id, user_id, Some("192.168.1.1"), None)
.await
.unwrap();
let result = protection
.check_authentication_allowed(&tenant_id, user_id, Some("192.168.1.1"))
.await
.unwrap();
if let Some(delay) = result.delay_ms {
assert!(
delay > previous_delay,
"Delay should increase progressively (attempt {}: {}ms vs previous {}ms)",
attempt,
delay,
previous_delay
);
previous_delay = delay;
}
}
assert!(
previous_delay >= 4000,
"After 4 attempts, delay should be at least 4 seconds"
);
}
#[tokio::test]
async fn test_slow_distributed_attack() {
let config = BruteForceConfig {
ip_based_tracking: true,
max_failed_attempts_per_ip: 5,
max_failed_attempts: 8,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
let user_id = "high_value_target";
let ips = vec!["10.1.1.1", "10.2.2.2", "10.3.3.3"];
for ip in &ips {
for _ in 0..4 {
protection
.record_failed_attempt(&tenant_id, user_id, Some(ip), None)
.await
.unwrap();
}
}
let result = protection
.check_authentication_allowed(&tenant_id, user_id, Some("10.4.4.4"))
.await
.unwrap();
assert!(
!result.allowed,
"User should be locked despite distributed slow attack"
);
for ip in &ips {
let result_ip = protection
.check_authentication_allowed(&tenant_id, "other_user", Some(ip))
.await
.unwrap();
assert!(result_ip.allowed, "IP {} should not be blocked", ip);
}
}
#[tokio::test]
async fn test_exponential_lockout_escalation() {
let config = BruteForceConfig {
max_failed_attempts: 3,
exponential_lockout: true,
lockout_duration_seconds: 60, max_lockout_duration_seconds: 480, auto_unlock: true,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store.clone());
let tenant_id = test_tenant_id();
let user_id = "repeat_offender";
for _ in 0..3 {
protection
.record_failed_attempt(&tenant_id, user_id, None, None)
.await
.unwrap();
}
protection
.check_authentication_allowed(&tenant_id, user_id, None)
.await
.unwrap();
let lockout1 = store.get_lockout(&tenant_id, user_id).await.unwrap();
assert!(lockout1.is_some(), "First lockout should exist");
let lockout1 = lockout1.unwrap();
assert_eq!(lockout1.lockout_count, 1);
let duration1 = (lockout1.unlock_at - lockout1.locked_at).num_seconds();
assert_eq!(duration1, 60, "First lockout should be 60 seconds");
let test_lockout = LockoutRecord {
locked_at: Utc::now(),
unlock_at: Utc::now() + Duration::seconds(120), lockout_count: 2,
failed_attempts: vec![],
reason: "Test".to_string(),
};
store
.set_lockout(&tenant_id, "test_user2", test_lockout)
.await
.unwrap();
let lockout2 = store.get_lockout(&tenant_id, "test_user2").await.unwrap();
assert!(lockout2.is_some());
let lockout2 = lockout2.unwrap();
assert_eq!(lockout2.lockout_count, 2);
}
#[tokio::test]
async fn test_mixed_attack_vectors() {
let config = BruteForceConfig {
ip_based_tracking: true,
max_failed_attempts_per_ip: 15,
max_failed_attempts: 5,
progressive_delay_enabled: true,
..Default::default()
};
let store = Arc::new(MemoryBruteForceStore::new());
let protection = BruteForceProtection::new(config, store);
let tenant_id = test_tenant_id();
for i in 0..8 {
protection
.record_failed_attempt(
&tenant_id,
&format!("user_{}", i),
Some("203.0.113.1"),
None,
)
.await
.unwrap();
}
for i in 0..12 {
protection
.record_failed_attempt(
&tenant_id,
&format!("admin_{}", i),
Some("203.0.113.2"),
None,
)
.await
.unwrap();
}
for _ in 0..5 {
protection
.record_failed_attempt(&tenant_id, "ceo", Some("203.0.113.3"), None)
.await
.unwrap();
}
let result1 = protection
.check_authentication_allowed(&tenant_id, "new_user", Some("203.0.113.1"))
.await
.unwrap();
assert!(result1.allowed);
let result2 = protection
.check_authentication_allowed(&tenant_id, "new_admin", Some("203.0.113.2"))
.await
.unwrap();
assert!(result2.allowed);
let result3 = protection
.check_authentication_allowed(&tenant_id, "ceo", Some("203.0.113.4"))
.await
.unwrap();
assert!(!result3.allowed, "Targeted account should be locked");
}
}