use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LockStatus {
Unlocked,
TemporarilyLocked {
until: Instant,
reason: String,
},
PermanentlyLocked {
reason: String,
},
}
impl LockStatus {
pub fn is_locked(&self) -> bool {
match self {
LockStatus::Unlocked => false,
LockStatus::TemporarilyLocked { until, .. } => Instant::now() < *until,
LockStatus::PermanentlyLocked { .. } => true,
}
}
pub fn remaining_lock_duration(&self) -> Option<Duration> {
match self {
LockStatus::TemporarilyLocked { until, .. } => {
let now = Instant::now();
if now < *until {
Some(*until - now)
} else {
None
}
}
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct LockConfig {
pub max_attempts: u32,
pub lockout_duration: Duration,
pub attempt_window: Duration,
pub reset_on_success: bool,
pub progressive_lockout: bool,
pub max_lockout_duration: Duration,
pub permanent_lock_threshold: Option<u32>,
}
impl Default for LockConfig {
fn default() -> Self {
Self {
max_attempts: 5,
lockout_duration: Duration::from_secs(15 * 60), attempt_window: Duration::from_secs(60 * 60), reset_on_success: true,
progressive_lockout: true,
max_lockout_duration: Duration::from_secs(24 * 60 * 60), permanent_lock_threshold: Some(10),
}
}
}
impl LockConfig {
pub fn new() -> Self {
Self::default()
}
pub fn max_attempts(mut self, attempts: u32) -> Self {
self.max_attempts = attempts;
self
}
pub fn lockout_duration(mut self, duration: Duration) -> Self {
self.lockout_duration = duration;
self
}
pub fn attempt_window(mut self, window: Duration) -> Self {
self.attempt_window = window;
self
}
pub fn reset_on_success(mut self, reset: bool) -> Self {
self.reset_on_success = reset;
self
}
pub fn progressive_lockout(mut self, enabled: bool) -> Self {
self.progressive_lockout = enabled;
self
}
pub fn max_lockout_duration(mut self, duration: Duration) -> Self {
self.max_lockout_duration = duration;
self
}
pub fn permanent_lock_threshold(mut self, threshold: Option<u32>) -> Self {
self.permanent_lock_threshold = threshold;
self
}
pub fn strict() -> Self {
Self::new()
.max_attempts(3)
.lockout_duration(Duration::from_secs(30 * 60)) .permanent_lock_threshold(Some(5))
}
pub fn lenient() -> Self {
Self::new()
.max_attempts(10)
.lockout_duration(Duration::from_secs(5 * 60)) .progressive_lockout(false)
.permanent_lock_threshold(None)
}
}
#[derive(Debug, Clone)]
struct AttemptRecord {
attempts: Vec<Instant>,
lock_count: u32,
lock_status: LockStatus,
ip_addresses: Vec<String>,
}
impl AttemptRecord {
fn new() -> Self {
Self {
attempts: Vec::new(),
lock_count: 0,
lock_status: LockStatus::Unlocked,
ip_addresses: Vec::new(),
}
}
}
#[derive(Clone)]
pub struct AccountLockManager {
records: Arc<RwLock<HashMap<String, AttemptRecord>>>,
config: LockConfig,
}
impl AccountLockManager {
pub fn new(config: LockConfig) -> Self {
Self {
records: Arc::new(RwLock::new(HashMap::new())),
config,
}
}
pub fn with_defaults() -> Self {
Self::new(LockConfig::default())
}
pub async fn is_locked(&self, identifier: &str) -> bool {
let records = self.records.read().await;
if let Some(record) = records.get(identifier) {
record.lock_status.is_locked()
} else {
false
}
}
pub async fn get_lock_status(&self, identifier: &str) -> LockStatus {
let records = self.records.read().await;
if let Some(record) = records.get(identifier) {
record.lock_status.clone()
} else {
LockStatus::Unlocked
}
}
pub async fn get_failed_attempts(&self, identifier: &str) -> u32 {
let records = self.records.read().await;
if let Some(record) = records.get(identifier) {
let cutoff = Instant::now() - self.config.attempt_window;
record.attempts.iter().filter(|&&t| t > cutoff).count() as u32
} else {
0
}
}
pub async fn get_remaining_attempts(&self, identifier: &str) -> u32 {
let failed = self.get_failed_attempts(identifier).await;
self.config.max_attempts.saturating_sub(failed)
}
pub async fn record_failure(&self, identifier: &str) -> LockStatus {
self.record_failure_with_ip(identifier, None).await
}
pub async fn record_failure_with_ip(
&self,
identifier: &str,
ip_address: Option<&str>,
) -> LockStatus {
let mut records = self.records.write().await;
let record = records
.entry(identifier.to_string())
.or_insert_with(AttemptRecord::new);
if matches!(record.lock_status, LockStatus::PermanentlyLocked { .. }) {
return record.lock_status.clone();
}
if let LockStatus::TemporarilyLocked { until, .. } = &record.lock_status {
if Instant::now() >= *until {
record.lock_status = LockStatus::Unlocked;
}
}
let now = Instant::now();
let cutoff = now - self.config.attempt_window;
record.attempts.retain(|&t| t > cutoff);
record.attempts.push(now);
if let Some(ip) = ip_address {
if !record.ip_addresses.contains(&ip.to_string()) {
record.ip_addresses.push(ip.to_string());
}
}
if record.attempts.len() as u32 >= self.config.max_attempts {
record.lock_count += 1;
if let Some(threshold) = self.config.permanent_lock_threshold {
if record.lock_count >= threshold {
record.lock_status = LockStatus::PermanentlyLocked {
reason: format!(
"Too many failed attempts ({} lockouts)",
record.lock_count
),
};
return record.lock_status.clone();
}
}
let duration = if self.config.progressive_lockout {
let multiplier = 2u64.pow(record.lock_count.saturating_sub(1));
let progressive = self.config.lockout_duration * multiplier as u32;
progressive.min(self.config.max_lockout_duration)
} else {
self.config.lockout_duration
};
record.lock_status = LockStatus::TemporarilyLocked {
until: now + duration,
reason: format!(
"Too many failed login attempts ({} attempts)",
record.attempts.len()
),
};
record.attempts.clear();
}
record.lock_status.clone()
}
pub async fn record_success(&self, identifier: &str) {
if !self.config.reset_on_success {
return;
}
let mut records = self.records.write().await;
if let Some(record) = records.get_mut(identifier) {
if !matches!(record.lock_status, LockStatus::PermanentlyLocked { .. }) {
record.attempts.clear();
record.lock_status = LockStatus::Unlocked;
}
}
}
pub async fn unlock(&self, identifier: &str) {
let mut records = self.records.write().await;
if let Some(record) = records.get_mut(identifier) {
record.attempts.clear();
record.lock_status = LockStatus::Unlocked;
}
}
pub async fn lock_permanently(&self, identifier: &str, reason: &str) {
let mut records = self.records.write().await;
let record = records
.entry(identifier.to_string())
.or_insert_with(AttemptRecord::new);
record.lock_status = LockStatus::PermanentlyLocked {
reason: reason.to_string(),
};
}
pub async fn get_account_stats(&self, identifier: &str) -> AccountStats {
let records = self.records.read().await;
if let Some(record) = records.get(identifier) {
let cutoff = Instant::now() - self.config.attempt_window;
let recent_attempts = record.attempts.iter().filter(|&&t| t > cutoff).count() as u32;
AccountStats {
total_lockouts: record.lock_count,
recent_failed_attempts: recent_attempts,
is_locked: record.lock_status.is_locked(),
remaining_lock_duration: record.lock_status.remaining_lock_duration(),
associated_ips: record.ip_addresses.clone(),
}
} else {
AccountStats::default()
}
}
pub async fn cleanup(&self) {
let mut records = self.records.write().await;
let now = Instant::now();
let cleanup_threshold = self.config.attempt_window * 2;
records.retain(|_, record| {
if record.lock_status.is_locked() {
return true;
}
if let Some(last) = record.attempts.last() {
return now - *last < cleanup_threshold;
}
false
});
}
}
impl Default for AccountLockManager {
fn default() -> Self {
Self::with_defaults()
}
}
#[derive(Debug, Clone, Default)]
pub struct AccountStats {
pub total_lockouts: u32,
pub recent_failed_attempts: u32,
pub is_locked: bool,
pub remaining_lock_duration: Option<Duration>,
pub associated_ips: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum LoginCheckResult {
Allowed {
remaining_attempts: u32,
},
Blocked {
status: LockStatus,
message: String,
},
}
impl LoginCheckResult {
pub fn is_allowed(&self) -> bool {
matches!(self, LoginCheckResult::Allowed { .. })
}
}
pub async fn check_login(manager: &AccountLockManager, identifier: &str) -> LoginCheckResult {
let status = manager.get_lock_status(identifier).await;
match status {
LockStatus::Unlocked => {
let remaining = manager.get_remaining_attempts(identifier).await;
LoginCheckResult::Allowed {
remaining_attempts: remaining,
}
}
LockStatus::TemporarilyLocked { until, ref reason } => {
let remaining = until.saturating_duration_since(Instant::now());
let minutes = remaining.as_secs() / 60;
let message = format!(
"Account temporarily locked: {}. Try again in {} minutes.",
reason, minutes
);
LoginCheckResult::Blocked {
status: LockStatus::TemporarilyLocked {
until,
reason: reason.clone(),
},
message,
}
}
LockStatus::PermanentlyLocked { ref reason } => {
let message = format!(
"Account permanently locked: {}. Please contact support.",
reason
);
LoginCheckResult::Blocked {
status: LockStatus::PermanentlyLocked {
reason: reason.clone(),
},
message,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_account_not_locked_initially() {
let manager = AccountLockManager::with_defaults();
assert!(!manager.is_locked("user@example.com").await);
}
#[tokio::test]
async fn test_lock_after_max_attempts() {
let config = LockConfig::new()
.max_attempts(3)
.lockout_duration(Duration::from_secs(60));
let manager = AccountLockManager::new(config);
manager.record_failure("user@example.com").await;
manager.record_failure("user@example.com").await;
assert!(!manager.is_locked("user@example.com").await);
manager.record_failure("user@example.com").await;
assert!(manager.is_locked("user@example.com").await);
}
#[tokio::test]
async fn test_reset_on_success() {
let config = LockConfig::new().max_attempts(3).reset_on_success(true);
let manager = AccountLockManager::new(config);
manager.record_failure("user@example.com").await;
manager.record_failure("user@example.com").await;
assert_eq!(manager.get_failed_attempts("user@example.com").await, 2);
manager.record_success("user@example.com").await;
assert_eq!(manager.get_failed_attempts("user@example.com").await, 0);
}
#[tokio::test]
async fn test_manual_unlock() {
let config = LockConfig::new()
.max_attempts(2)
.lockout_duration(Duration::from_secs(3600));
let manager = AccountLockManager::new(config);
manager.record_failure("user@example.com").await;
manager.record_failure("user@example.com").await;
assert!(manager.is_locked("user@example.com").await);
manager.unlock("user@example.com").await;
assert!(!manager.is_locked("user@example.com").await);
}
#[tokio::test]
async fn test_permanent_lock() {
let config = LockConfig::new()
.max_attempts(2)
.lockout_duration(Duration::from_secs(1))
.permanent_lock_threshold(Some(2));
let manager = AccountLockManager::new(config);
manager.record_failure("user@example.com").await;
manager.record_failure("user@example.com").await;
tokio::time::sleep(Duration::from_secs(2)).await;
manager.record_failure("user@example.com").await;
manager.record_failure("user@example.com").await;
let status = manager.get_lock_status("user@example.com").await;
assert!(matches!(status, LockStatus::PermanentlyLocked { .. }));
}
#[tokio::test]
async fn test_remaining_attempts() {
let config = LockConfig::new().max_attempts(5);
let manager = AccountLockManager::new(config);
assert_eq!(manager.get_remaining_attempts("user@example.com").await, 5);
manager.record_failure("user@example.com").await;
assert_eq!(manager.get_remaining_attempts("user@example.com").await, 4);
manager.record_failure("user@example.com").await;
manager.record_failure("user@example.com").await;
assert_eq!(manager.get_remaining_attempts("user@example.com").await, 2);
}
#[tokio::test]
async fn test_ip_tracking() {
let manager = AccountLockManager::with_defaults();
manager
.record_failure_with_ip("user@example.com", Some("192.168.1.1"))
.await;
manager
.record_failure_with_ip("user@example.com", Some("10.0.0.1"))
.await;
let stats = manager.get_account_stats("user@example.com").await;
assert_eq!(stats.associated_ips.len(), 2);
assert!(stats.associated_ips.contains(&"192.168.1.1".to_string()));
assert!(stats.associated_ips.contains(&"10.0.0.1".to_string()));
}
#[test]
fn test_lock_status() {
let unlocked = LockStatus::Unlocked;
assert!(!unlocked.is_locked());
let temp_locked = LockStatus::TemporarilyLocked {
until: Instant::now() + Duration::from_secs(60),
reason: "test".to_string(),
};
assert!(temp_locked.is_locked());
let perm_locked = LockStatus::PermanentlyLocked {
reason: "test".to_string(),
};
assert!(perm_locked.is_locked());
}
#[test]
fn test_config_builder() {
let config = LockConfig::new()
.max_attempts(10)
.lockout_duration(Duration::from_secs(300))
.progressive_lockout(false);
assert_eq!(config.max_attempts, 10);
assert_eq!(config.lockout_duration, Duration::from_secs(300));
assert!(!config.progressive_lockout);
}
}