mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
899 lines
28 KiB
Rust
899 lines
28 KiB
Rust
//! Brute-force protection tests
|
|
//!
|
|
//! Tests the `InMemoryAttemptTracker`, `BruteForceProtection` logic, and
|
|
//! (with testcontainers) the `RedisAttemptTracker`.
|
|
//!
|
|
//! ## Degradation Testing
|
|
//!
|
|
//! Tests in this module verify that `RedisAttemptTracker` properly handles
|
|
//! Redis failures by falling back to in-memory storage. This fallback behavior
|
|
//! is tracked via `is_degraded()` and `degraded_operation_count()` methods.
|
|
//!
|
|
//! **WARNING**: In multi-replica deployments, degraded mode means each replica
|
|
//! maintains independent brute-force counters. Monitor the `is_degraded()` flag
|
|
//! or `degraded_operation_count()` to detect Redis connectivity issues.
|
|
//!
|
|
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
use std::sync::Arc;
|
|
use synctv_core::service::{
|
|
AttemptTracker, BruteForceProtection, InMemoryAttemptTracker, RedisAttemptTracker,
|
|
};
|
|
use synctv_core_testing::start_redis;
|
|
use synctv_core_testing::{ok, TestResultExt};
|
|
use tokio::sync::RwLock;
|
|
|
|
const TIER1_THRESHOLD: u32 = 5;
|
|
const PROXY_IP: &str = "10.0.0.1";
|
|
|
|
fn redis_brute_force_protection(
|
|
conn: Arc<RwLock<redis::aio::ConnectionManager>>,
|
|
key_prefix: String,
|
|
) -> BruteForceProtection {
|
|
let config = synctv_core::service::BruteForceConfig::default();
|
|
let username_tracker = Arc::new(RedisAttemptTracker::new(
|
|
conn.clone(),
|
|
50_000,
|
|
config.attempts_ttl_secs,
|
|
));
|
|
let ip_tracker = Arc::new(RedisAttemptTracker::new(
|
|
conn,
|
|
100_000,
|
|
config.ip_attempts_ttl_secs,
|
|
));
|
|
BruteForceProtection::new_with_config(key_prefix, username_tracker, ip_tracker, config)
|
|
}
|
|
|
|
// InMemoryAttemptTracker tests
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_tracker_record_and_get() {
|
|
let tracker = InMemoryAttemptTracker::new(1000, 900);
|
|
let key = "user:alice";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
// Initially no attempts
|
|
let (count, _ts) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 0);
|
|
|
|
// Record failures
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now + 1, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now + 2, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let (count, last_ts) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 3);
|
|
assert_eq!(last_ts, now + 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_in_memory_tracker_reset_clears() {
|
|
let tracker = InMemoryAttemptTracker::new(1000, 900);
|
|
let key = "user:bob";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 2);
|
|
|
|
tracker
|
|
.reset(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 0);
|
|
}
|
|
|
|
// BruteForceProtection tests
|
|
|
|
#[tokio::test]
|
|
async fn test_brute_force_below_threshold_allowed() {
|
|
let protection = BruteForceProtection::in_memory("test".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
|
|
|
|
// Record 4 failures (below tier1 threshold)
|
|
for _ in 0..(TIER1_THRESHOLD - 1) {
|
|
protection
|
|
.record_failure("alice", ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// Should still be allowed
|
|
let result = protection.check_allowed("alice", ip).await;
|
|
assert!(result.is_ok(), "4 failures should not lock out");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_brute_force_at_tier1_threshold_locked() {
|
|
let protection = BruteForceProtection::in_memory("test".to_string());
|
|
let ip = Some(ok(PROXY_IP.parse(), "proxy IP should parse"));
|
|
|
|
// Record exactly tier1 threshold failures
|
|
for _ in 0..TIER1_THRESHOLD {
|
|
protection
|
|
.record_failure("bob", ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
let result = protection.check_allowed("bob", ip).await;
|
|
assert!(result.is_err(), "5 failures should trigger tier1 lockout");
|
|
|
|
let err = result.failed("operation should fail");
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("Too many failed login attempts"),
|
|
"Error should mention lockout: {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_brute_force_tier1_expired_window_unlocks() {
|
|
// This test uses the InMemoryAttemptTracker directly to simulate
|
|
// time passing (by setting last_failure_at far in the past).
|
|
let username_tracker = Arc::new(InMemoryAttemptTracker::new(50_000, 900));
|
|
let ip_tracker = Arc::new(InMemoryAttemptTracker::new(100_000, 600));
|
|
|
|
let protection =
|
|
BruteForceProtection::new("test".to_string(), username_tracker.clone(), ip_tracker);
|
|
|
|
// Record 5 failures with a timestamp far enough in the past that
|
|
// the 60-second tier1 lockout has expired.
|
|
let past = chrono::Utc::now().timestamp() - 120; // 2 minutes ago
|
|
let key = "test:auth:login_attempts:charlie";
|
|
for i in 0..5 {
|
|
username_tracker
|
|
.record_failure(key, past + i, 900)
|
|
.await
|
|
.checked("failure should be recorded");
|
|
}
|
|
|
|
// Lockout should have expired (60s window, 120s ago)
|
|
let result = protection.check_allowed("charlie", None).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Tier1 lockout should have expired after 60s"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_brute_force_ip_lockout() {
|
|
let protection = BruteForceProtection::in_memory("test".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)));
|
|
|
|
// Record 20 failures from same IP (across different usernames)
|
|
for i in 0..20 {
|
|
let username = format!("user_{i}");
|
|
protection
|
|
.record_failure(&username, ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// IP should be locked out even for a brand-new username
|
|
let result = protection.check_allowed("brand_new_user", ip).await;
|
|
assert!(result.is_err(), "IP with 20 failures should be locked out");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_brute_force_reset_unlocks() {
|
|
let protection = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
// Record 5 failures
|
|
for _ in 0..5 {
|
|
protection
|
|
.record_failure("dave", None)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
assert!(protection.check_allowed("dave", None).await.is_err());
|
|
|
|
// Reset
|
|
protection
|
|
.reset("dave")
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert!(
|
|
protection.check_allowed("dave", None).await.is_ok(),
|
|
"Reset should unlock the account"
|
|
);
|
|
}
|
|
|
|
// RedisAttemptTracker tests (require testcontainers)
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_tracker_record_and_get() {
|
|
let (_container, conn) = start_redis().await;
|
|
let tracker = RedisAttemptTracker::new(Arc::new(RwLock::new(conn)), 50_000, 900);
|
|
|
|
let key = "test:bf:redis_rg:alice";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
// Initially no attempts
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 0);
|
|
|
|
// Record failures
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now + 1, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let (count, last_ts) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 2);
|
|
assert_eq!(last_ts, now + 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_tracker_reset() {
|
|
let (_container, conn) = start_redis().await;
|
|
let tracker = RedisAttemptTracker::new(Arc::new(RwLock::new(conn)), 50_000, 900);
|
|
|
|
let key = "test:bf:redis_reset:bob";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 3);
|
|
|
|
// Reset clears both Redis and fallback state.
|
|
tracker
|
|
.reset(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 0, "Reset should clear the Redis key");
|
|
}
|
|
|
|
// BruteForceProtection::with_redis E2E tests
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_brute_force_with_redis_e2e_lockout_and_reset() {
|
|
let (_container, conn) = start_redis().await;
|
|
let protection =
|
|
redis_brute_force_protection(Arc::new(RwLock::new(conn)), "test_e2e:".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 0, 1)));
|
|
|
|
// Record 5 failures to trigger tier1 lockout
|
|
for _ in 0..5 {
|
|
protection
|
|
.record_failure("redis_user", ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
let result = protection.check_allowed("redis_user", ip).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"5 failures via Redis should trigger lockout"
|
|
);
|
|
let err_msg = result.failed("operation should fail").to_string();
|
|
assert!(
|
|
err_msg.contains("Too many failed login attempts"),
|
|
"Error should mention lockout: {err_msg}"
|
|
);
|
|
|
|
// Reset should unlock
|
|
protection
|
|
.reset("redis_user")
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let result = protection.check_allowed("redis_user", ip).await;
|
|
assert!(result.is_ok(), "Reset should unlock the account via Redis");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_brute_force_with_redis_ip_lockout_and_reset() {
|
|
let (_container, conn) = start_redis().await;
|
|
let protection =
|
|
redis_brute_force_protection(Arc::new(RwLock::new(conn)), "test_ip_e2e:".to_string());
|
|
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 50, 1));
|
|
|
|
// Record 20 failures from the same IP across different usernames
|
|
for i in 0..20 {
|
|
let username = format!("ip_user_{i}");
|
|
protection
|
|
.record_failure(&username, Some(ip))
|
|
.await
|
|
.checked("failure should be recorded");
|
|
}
|
|
|
|
// IP should be locked out even for a brand-new username
|
|
let result = protection
|
|
.check_allowed("brand_new_ip_user", Some(ip))
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"IP with 20 failures should be locked out via Redis"
|
|
);
|
|
|
|
// Reset the IP
|
|
protection
|
|
.reset_ip(&ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// IP should be unlocked now
|
|
let result = protection
|
|
.check_allowed("brand_new_ip_user", Some(ip))
|
|
.await;
|
|
assert!(result.is_ok(), "reset_ip should unlock the IP via Redis");
|
|
}
|
|
|
|
// RedisAttemptTracker degradation tracking tests
|
|
|
|
/// Test that `RedisAttemptTracker` tracks degradation state correctly
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_tracker_degradation_tracking() {
|
|
let (_container, conn) = start_redis().await;
|
|
let tracker = RedisAttemptTracker::new(Arc::new(RwLock::new(conn)), 50_000, 900);
|
|
|
|
// Initially should not be degraded
|
|
assert!(
|
|
!tracker.is_degraded(),
|
|
"Tracker should start in non-degraded state"
|
|
);
|
|
assert_eq!(
|
|
tracker.degraded_operation_count(),
|
|
0,
|
|
"No degraded operations yet"
|
|
);
|
|
|
|
let key = "test:degradation:user1";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
// Successful operation should keep tracker in non-degraded state
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
!tracker.is_degraded(),
|
|
"After successful operation, should not be degraded"
|
|
);
|
|
assert_eq!(
|
|
tracker.degraded_operation_count(),
|
|
0,
|
|
"No degraded operations after success"
|
|
);
|
|
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 1);
|
|
assert!(
|
|
!tracker.is_degraded(),
|
|
"After successful get, should not be degraded"
|
|
);
|
|
}
|
|
|
|
/// Test that `RedisAttemptTracker` increments degraded counter on failures
|
|
///
|
|
/// NOTE: This test cannot easily simulate Redis failures without stopping
|
|
/// the container. The degradation behavior is tested indirectly through
|
|
/// unit tests that verify the atomic state management.
|
|
///
|
|
/// In production, you can monitor `is_degraded()` and `degraded_operation_count()`
|
|
/// to detect Redis connectivity issues. When degraded:
|
|
/// - Each replica maintains independent brute-force counters
|
|
/// - Attackers may bypass lockouts by distributing requests across replicas
|
|
/// - WARN-level logs are emitted with key "Redis degraded to fallback"
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_tracker_counter_is_monotonically_increasing() {
|
|
let (_container, conn) = start_redis().await;
|
|
let tracker = RedisAttemptTracker::new(Arc::new(RwLock::new(conn)), 50_000, 900);
|
|
|
|
// The degraded_operation_count should be monotonically increasing
|
|
// (even if we can't easily simulate failures in this test)
|
|
let initial_count = tracker.degraded_operation_count();
|
|
|
|
// Perform some operations - these should succeed
|
|
let key = "test:monotonic:user";
|
|
let now = chrono::Utc::now().timestamp();
|
|
for _ in 0..5 {
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// Counter should still be at initial (no failures)
|
|
assert_eq!(
|
|
tracker.degraded_operation_count(),
|
|
initial_count,
|
|
"Counter should not increase when Redis is healthy"
|
|
);
|
|
}
|
|
|
|
/// Test behavior when Redis operations succeed after the tracker is created
|
|
///
|
|
/// This verifies that the tracker properly clears the degraded flag on success.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_tracker_success_clears_degraded_flag() {
|
|
let (_container, conn) = start_redis().await;
|
|
let tracker = RedisAttemptTracker::new(Arc::new(RwLock::new(conn)), 50_000, 900);
|
|
|
|
let key = "test:clear_degraded:user";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
// Multiple successful operations
|
|
for i in 1..=3 {
|
|
tracker
|
|
.record_failure(key, now + i, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
!tracker.is_degraded(),
|
|
"After successful record_failure #{i}, should not be degraded"
|
|
);
|
|
}
|
|
|
|
let (count, ts) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 3);
|
|
assert_eq!(ts, now + 3);
|
|
assert!(
|
|
!tracker.is_degraded(),
|
|
"After successful get_attempts, should not be degraded"
|
|
);
|
|
|
|
// Reset should also clear degraded flag
|
|
tracker
|
|
.reset(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
!tracker.is_degraded(),
|
|
"After successful reset, should not be degraded"
|
|
);
|
|
|
|
let (count, _) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 0);
|
|
}
|
|
|
|
/// Test fallback cache maintains state during Redis operations
|
|
///
|
|
/// The fallback cache in `RedisAttemptTracker` should maintain consistent state
|
|
/// even when Redis is available - it's only used as a fallback, not as primary.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_redis_tracker_fallback_not_used_when_redis_healthy() {
|
|
let (_container, conn) = start_redis().await;
|
|
let tracker = RedisAttemptTracker::new(Arc::new(RwLock::new(conn)), 50_000, 900);
|
|
|
|
let key = "test:fallback:not_used";
|
|
let now = chrono::Utc::now().timestamp();
|
|
|
|
// Record failures - should go to Redis, not fallback
|
|
tracker
|
|
.record_failure(key, now, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now + 1, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tracker
|
|
.record_failure(key, now + 2, 900)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Read back - should get data from Redis
|
|
let (count, ts) = tracker
|
|
.get_attempts(key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(count, 3, "Should read from Redis, not fallback");
|
|
assert_eq!(ts, now + 2);
|
|
|
|
// Should not be degraded
|
|
assert!(!tracker.is_degraded());
|
|
assert_eq!(tracker.degraded_operation_count(), 0);
|
|
}
|
|
|
|
use synctv_core::service::BruteForceConfig;
|
|
|
|
/// Test `BruteForceProtection` uses custom thresholds via config
|
|
#[tokio::test]
|
|
async fn test_brute_force_with_custom_tier1_threshold() {
|
|
let custom_config = BruteForceConfig {
|
|
tier1_threshold: 3, // Lower than default (5)
|
|
tier1_lockout_secs: 30,
|
|
..BruteForceConfig::default()
|
|
};
|
|
|
|
let protection = BruteForceProtection::in_memory_with_config(
|
|
"test_custom_tier1:".to_string(),
|
|
custom_config,
|
|
);
|
|
|
|
// Record 3 failures (custom tier1 threshold)
|
|
for _ in 0..3 {
|
|
protection
|
|
.record_failure("custom_user", None)
|
|
.await
|
|
.checked("failure should be recorded");
|
|
}
|
|
|
|
let result = protection.check_allowed("custom_user", None).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Should be locked out at 3 failures with custom threshold"
|
|
);
|
|
|
|
let err_msg = result.failed("operation should fail").to_string();
|
|
assert!(
|
|
err_msg.contains("Too many failed login attempts"),
|
|
"Error should mention lockout: {err_msg}"
|
|
);
|
|
}
|
|
|
|
/// Test `BruteForceProtection` uses custom lockout duration
|
|
#[tokio::test]
|
|
async fn test_brute_force_with_custom_lockout_duration() {
|
|
let custom_config = BruteForceConfig {
|
|
tier1_threshold: 2,
|
|
tier1_lockout_secs: 5, // Very short for testing
|
|
..BruteForceConfig::default()
|
|
};
|
|
|
|
let username_tracker = Arc::new(InMemoryAttemptTracker::new(50_000, 900));
|
|
let ip_tracker = Arc::new(InMemoryAttemptTracker::new(100_000, 600));
|
|
|
|
let protection = BruteForceProtection::new_with_config(
|
|
"test_custom_duration:".to_string(),
|
|
username_tracker.clone(),
|
|
ip_tracker,
|
|
custom_config,
|
|
);
|
|
|
|
// Record 2 failures with timestamp far in the past (beyond lockout)
|
|
let past = chrono::Utc::now().timestamp() - 10; // 10 seconds ago (beyond 5s lockout)
|
|
let key = "test_custom_duration:auth:login_attempts:expired_user";
|
|
for i in 0..2 {
|
|
username_tracker
|
|
.record_failure(key, past + i, 900)
|
|
.await
|
|
.checked("failure should be recorded");
|
|
}
|
|
|
|
// Lockout should have expired (5s lockout, failures 10s ago)
|
|
let result = protection.check_allowed("expired_user", None).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Custom lockout should have expired after 5 seconds"
|
|
);
|
|
}
|
|
|
|
/// Test `BruteForceProtection` uses custom IP thresholds
|
|
#[tokio::test]
|
|
async fn test_brute_force_with_custom_ip_threshold() {
|
|
let custom_config = BruteForceConfig {
|
|
ip_threshold: 5, // Lower than default (20)
|
|
ip_lockout_secs: 60,
|
|
ip_attempts_ttl_secs: 300,
|
|
..BruteForceConfig::default()
|
|
};
|
|
|
|
let protection =
|
|
BruteForceProtection::in_memory_with_config("test_custom_ip:".to_string(), custom_config);
|
|
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)));
|
|
|
|
// Record 5 failures from same IP (custom IP threshold)
|
|
for i in 0..5 {
|
|
let username = format!("ip_user_{i}");
|
|
protection
|
|
.record_failure(&username, ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// IP should be locked out at 5 failures (not 20)
|
|
let result = protection.check_allowed("brand_new_user", ip).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"IP should be locked out at 5 failures with custom threshold"
|
|
);
|
|
}
|
|
|
|
/// Test `BruteForceConfig` validation: thresholds should be increasing
|
|
#[test]
|
|
fn test_brute_force_config_threshold_ordering() {
|
|
// Valid: thresholds are in increasing order
|
|
let valid = BruteForceConfig {
|
|
tier1_threshold: 5,
|
|
tier2_threshold: 10,
|
|
tier3_threshold: 15,
|
|
..BruteForceConfig::default()
|
|
};
|
|
assert!(valid.tier1_threshold < valid.tier2_threshold);
|
|
assert!(valid.tier2_threshold < valid.tier3_threshold);
|
|
|
|
// Invalid but allowed (enforcement is in validation, not types)
|
|
let _invalid = BruteForceConfig {
|
|
tier1_threshold: 20, // Higher than tier2
|
|
tier2_threshold: 10,
|
|
tier3_threshold: 5,
|
|
..BruteForceConfig::default()
|
|
};
|
|
// Note: Config validation should reject this, but the type allows it
|
|
}
|
|
|
|
/// Test that `BruteForceConfig` can be parsed from JSON (for settings integration)
|
|
#[test]
|
|
fn test_brute_force_config_from_json() {
|
|
let json = serde_json::json!({
|
|
"tier1_threshold": 4,
|
|
"tier1_lockout_secs": 45,
|
|
"tier2_threshold": 8,
|
|
"tier2_lockout_secs": 180,
|
|
"tier3_threshold": 12,
|
|
"tier3_lockout_secs": 600,
|
|
"ip_threshold": 15,
|
|
"ip_lockout_secs": 300,
|
|
"attempts_ttl_secs": 1200,
|
|
"ip_attempts_ttl_secs": 900
|
|
});
|
|
|
|
let config: BruteForceConfig = ok(serde_json::from_value(json), "config JSON should parse");
|
|
|
|
assert_eq!(config.tier1_threshold, 4);
|
|
assert_eq!(config.tier1_lockout_secs, 45);
|
|
assert_eq!(config.tier2_threshold, 8);
|
|
assert_eq!(config.tier2_lockout_secs, 180);
|
|
assert_eq!(config.tier3_threshold, 12);
|
|
assert_eq!(config.tier3_lockout_secs, 600);
|
|
assert_eq!(config.ip_threshold, 15);
|
|
assert_eq!(config.ip_lockout_secs, 300);
|
|
assert_eq!(config.attempts_ttl_secs, 1200);
|
|
assert_eq!(config.ip_attempts_ttl_secs, 900);
|
|
}
|
|
|
|
/// Test `record_ip_failure` only increments IP counter, not username counter
|
|
#[tokio::test]
|
|
async fn test_record_ip_failure_only_affects_ip_counter() {
|
|
let protection = BruteForceProtection::in_memory("test_ip_only:".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)));
|
|
|
|
// Record IP-only failure multiple times
|
|
for _ in 0..5 {
|
|
protection
|
|
.record_ip_failure(ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// IP should be tracked
|
|
assert!(
|
|
protection.check_ip_allowed(ip).await.is_ok(),
|
|
"IP should not be locked yet"
|
|
);
|
|
|
|
// Record more failures to trigger IP lockout
|
|
for _ in 0..15 {
|
|
protection
|
|
.record_ip_failure(ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// Now IP should be locked out (default threshold is 20)
|
|
let result = protection.check_ip_allowed(ip).await;
|
|
assert!(result.is_err(), "IP should be locked out after 20 failures");
|
|
}
|
|
|
|
/// Test `check_ip_allowed` with None IP always returns Ok
|
|
#[tokio::test]
|
|
async fn test_check_ip_allowed_with_none_is_always_ok() {
|
|
let protection = BruteForceProtection::in_memory("test_none_ip:".to_string());
|
|
|
|
// Should always succeed when no IP is provided
|
|
let result = protection.check_ip_allowed(None).await;
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
/// Test that username is NOT locked when only IP failures are recorded
|
|
#[tokio::test]
|
|
async fn test_ip_only_failure_does_not_lock_username() {
|
|
let protection = BruteForceProtection::in_memory("test_username_safe:".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 50)));
|
|
|
|
// Record many IP-only failures
|
|
for _ in 0..25 {
|
|
protection
|
|
.record_ip_failure(ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// IP should be locked
|
|
assert!(protection.check_ip_allowed(ip).await.is_err());
|
|
|
|
// But a legitimate user with a different IP should be able to log in
|
|
// (this is the key behavior - random username guessing doesn't lock out real users)
|
|
let different_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 99)));
|
|
assert!(protection
|
|
.check_allowed("legitimate_user", different_ip)
|
|
.await
|
|
.is_ok());
|
|
|
|
// And the username that was attacked should also be accessible from different IP
|
|
assert!(protection
|
|
.check_allowed("nonexistent_user_tried_earlier", different_ip)
|
|
.await
|
|
.is_ok());
|
|
}
|
|
|
|
/// Test differentiated failure: wrong password for existing user locks both
|
|
#[tokio::test]
|
|
async fn test_wrong_password_for_existing_user_locks_both() {
|
|
let protection = BruteForceProtection::in_memory("test_both_lock:".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)));
|
|
let username = "existing_user";
|
|
|
|
// Simulate wrong password attempts for existing user (record_failure)
|
|
for _ in 0..5 {
|
|
protection
|
|
.record_failure(username, ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// Username should be locked at tier 1 threshold (5)
|
|
let result = protection.check_allowed(username, None).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Username should be locked after 5 failures"
|
|
);
|
|
}
|
|
|
|
/// Test that legitimate user from same IP gets locked when using wrong password
|
|
#[tokio::test]
|
|
async fn test_legitimate_user_wrong_password_locks_username() {
|
|
let protection = BruteForceProtection::in_memory("test_legit_lock:".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 50)));
|
|
let username = "alice";
|
|
|
|
// Alice enters wrong password 5 times
|
|
for _ in 0..5 {
|
|
protection
|
|
.record_failure(username, ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// Alice should be locked out
|
|
let result = protection.check_allowed(username, ip).await;
|
|
assert!(result.is_err(), "Alice should be locked out");
|
|
}
|
|
|
|
/// Test attacker cannot lock out legitimate user by trying non-existent usernames
|
|
#[tokio::test]
|
|
async fn test_attacker_cannot_lock_legitimate_user() {
|
|
let protection = BruteForceProtection::in_memory("test_no_lock:".to_string());
|
|
let attacker_ip = Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 50)));
|
|
|
|
// Attacker tries many non-existent usernames (only IP tracking)
|
|
for _ in 0..19 {
|
|
protection
|
|
.record_ip_failure(attacker_ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// Legitimate user "bob" should NOT be locked out
|
|
// (even though attacker used the same IP, they didn't use "bob" as username)
|
|
let result = protection.check_allowed("bob", attacker_ip).await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"Bob should not be locked out by attacker's random username attempts"
|
|
);
|
|
}
|
|
|
|
/// Test IP lockout still works with IP-only failures
|
|
#[tokio::test]
|
|
async fn test_ip_lockout_works_with_ip_only_failures() {
|
|
let protection = BruteForceProtection::in_memory("test_ip_lockout:".to_string());
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)));
|
|
|
|
// Record IP-only failures up to threshold (default: 20)
|
|
for _ in 0..20 {
|
|
protection
|
|
.record_ip_failure(ip)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
}
|
|
|
|
// IP should be locked
|
|
let result = protection.check_ip_allowed(ip).await;
|
|
assert!(result.is_err(), "IP should be locked out");
|
|
|
|
// Any username from this IP should also be blocked
|
|
let result = protection.check_allowed("any_username", ip).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Any username from locked IP should be blocked"
|
|
);
|
|
}
|