Skip to main content

actix_security_core/http/security/
account.rs

1//! Account locking and login attempt tracking.
2//!
3//! Provides protection against brute-force attacks by tracking failed login
4//! attempts and temporarily locking accounts.
5//!
6//! # Spring Security Equivalent
7//! Similar to Spring Security's `AuthenticationFailureHandler` with
8//! `LockoutPolicy` and `AccountStatusUserDetailsChecker`.
9//!
10//! # Example
11//!
12//! ```ignore
13//! use actix_security::http::security::account::{AccountLockManager, LockConfig};
14//!
15//! let lock_manager = AccountLockManager::new(
16//!     LockConfig::new()
17//!         .max_attempts(5)
18//!         .lockout_duration(Duration::from_secs(900)) // 15 minutes
19//! );
20//!
21//! // On login attempt
22//! if lock_manager.is_locked("user@example.com").await {
23//!     return Err(AuthError::AccountLocked);
24//! }
25//!
26//! // On failed login
27//! lock_manager.record_failure("user@example.com").await;
28//!
29//! // On successful login
30//! lock_manager.record_success("user@example.com").await;
31//! ```
32
33use std::collections::HashMap;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use tokio::sync::RwLock;
38
39/// Account lock status.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum LockStatus {
42    /// Account is not locked
43    Unlocked,
44    /// Account is temporarily locked
45    TemporarilyLocked {
46        /// When the lock expires
47        until: Instant,
48        /// Reason for locking
49        reason: String,
50    },
51    /// Account is permanently locked (requires admin intervention)
52    PermanentlyLocked {
53        /// Reason for locking
54        reason: String,
55    },
56}
57
58impl LockStatus {
59    /// Check if the account is locked.
60    pub fn is_locked(&self) -> bool {
61        match self {
62            LockStatus::Unlocked => false,
63            LockStatus::TemporarilyLocked { until, .. } => Instant::now() < *until,
64            LockStatus::PermanentlyLocked { .. } => true,
65        }
66    }
67
68    /// Get remaining lock duration if temporarily locked.
69    pub fn remaining_lock_duration(&self) -> Option<Duration> {
70        match self {
71            LockStatus::TemporarilyLocked { until, .. } => {
72                let now = Instant::now();
73                if now < *until {
74                    Some(*until - now)
75                } else {
76                    None
77                }
78            }
79            _ => None,
80        }
81    }
82}
83
84/// Account lock configuration.
85#[derive(Debug, Clone)]
86pub struct LockConfig {
87    /// Maximum failed attempts before locking
88    pub max_attempts: u32,
89    /// Duration to lock the account
90    pub lockout_duration: Duration,
91    /// Window for counting failed attempts
92    pub attempt_window: Duration,
93    /// Whether to reset attempts after successful login
94    pub reset_on_success: bool,
95    /// Whether to use progressive lockout (longer each time)
96    pub progressive_lockout: bool,
97    /// Maximum lockout duration for progressive lockout
98    pub max_lockout_duration: Duration,
99    /// Number of consecutive lockouts before permanent lock
100    pub permanent_lock_threshold: Option<u32>,
101}
102
103impl Default for LockConfig {
104    fn default() -> Self {
105        Self {
106            max_attempts: 5,
107            lockout_duration: Duration::from_secs(15 * 60), // 15 minutes
108            attempt_window: Duration::from_secs(60 * 60),   // 1 hour
109            reset_on_success: true,
110            progressive_lockout: true,
111            max_lockout_duration: Duration::from_secs(24 * 60 * 60), // 24 hours
112            permanent_lock_threshold: Some(10),
113        }
114    }
115}
116
117impl LockConfig {
118    /// Create a new lock configuration with default values.
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Set maximum failed attempts before locking.
124    pub fn max_attempts(mut self, attempts: u32) -> Self {
125        self.max_attempts = attempts;
126        self
127    }
128
129    /// Set lockout duration.
130    pub fn lockout_duration(mut self, duration: Duration) -> Self {
131        self.lockout_duration = duration;
132        self
133    }
134
135    /// Set the window for counting failed attempts.
136    pub fn attempt_window(mut self, window: Duration) -> Self {
137        self.attempt_window = window;
138        self
139    }
140
141    /// Set whether to reset attempts on successful login.
142    pub fn reset_on_success(mut self, reset: bool) -> Self {
143        self.reset_on_success = reset;
144        self
145    }
146
147    /// Enable/disable progressive lockout.
148    pub fn progressive_lockout(mut self, enabled: bool) -> Self {
149        self.progressive_lockout = enabled;
150        self
151    }
152
153    /// Set maximum lockout duration for progressive lockout.
154    pub fn max_lockout_duration(mut self, duration: Duration) -> Self {
155        self.max_lockout_duration = duration;
156        self
157    }
158
159    /// Set threshold for permanent lock (None to disable).
160    pub fn permanent_lock_threshold(mut self, threshold: Option<u32>) -> Self {
161        self.permanent_lock_threshold = threshold;
162        self
163    }
164
165    /// Create a strict configuration for sensitive applications.
166    pub fn strict() -> Self {
167        Self::new()
168            .max_attempts(3)
169            .lockout_duration(Duration::from_secs(30 * 60)) // 30 minutes
170            .permanent_lock_threshold(Some(5))
171    }
172
173    /// Create a lenient configuration for user-friendly applications.
174    pub fn lenient() -> Self {
175        Self::new()
176            .max_attempts(10)
177            .lockout_duration(Duration::from_secs(5 * 60)) // 5 minutes
178            .progressive_lockout(false)
179            .permanent_lock_threshold(None)
180    }
181}
182
183/// Failed attempt record.
184#[derive(Debug, Clone)]
185struct AttemptRecord {
186    /// Timestamps of failed attempts
187    attempts: Vec<Instant>,
188    /// Number of times account has been locked
189    lock_count: u32,
190    /// Current lock status
191    lock_status: LockStatus,
192    /// IP addresses associated with failures
193    ip_addresses: Vec<String>,
194}
195
196impl AttemptRecord {
197    fn new() -> Self {
198        Self {
199            attempts: Vec::new(),
200            lock_count: 0,
201            lock_status: LockStatus::Unlocked,
202            ip_addresses: Vec::new(),
203        }
204    }
205}
206
207/// Account lock manager for tracking failed attempts and locking accounts.
208#[derive(Clone)]
209pub struct AccountLockManager {
210    records: Arc<RwLock<HashMap<String, AttemptRecord>>>,
211    config: LockConfig,
212}
213
214impl AccountLockManager {
215    /// Create a new account lock manager with the given configuration.
216    pub fn new(config: LockConfig) -> Self {
217        Self {
218            records: Arc::new(RwLock::new(HashMap::new())),
219            config,
220        }
221    }
222
223    /// Create with default configuration.
224    pub fn with_defaults() -> Self {
225        Self::new(LockConfig::default())
226    }
227
228    /// Check if an account is locked.
229    pub async fn is_locked(&self, identifier: &str) -> bool {
230        let records = self.records.read().await;
231        if let Some(record) = records.get(identifier) {
232            record.lock_status.is_locked()
233        } else {
234            false
235        }
236    }
237
238    /// Get the lock status for an account.
239    pub async fn get_lock_status(&self, identifier: &str) -> LockStatus {
240        let records = self.records.read().await;
241        if let Some(record) = records.get(identifier) {
242            record.lock_status.clone()
243        } else {
244            LockStatus::Unlocked
245        }
246    }
247
248    /// Get the number of recent failed attempts.
249    pub async fn get_failed_attempts(&self, identifier: &str) -> u32 {
250        let records = self.records.read().await;
251        if let Some(record) = records.get(identifier) {
252            let cutoff = Instant::now() - self.config.attempt_window;
253            record.attempts.iter().filter(|&&t| t > cutoff).count() as u32
254        } else {
255            0
256        }
257    }
258
259    /// Get remaining attempts before lockout.
260    pub async fn get_remaining_attempts(&self, identifier: &str) -> u32 {
261        let failed = self.get_failed_attempts(identifier).await;
262        self.config.max_attempts.saturating_sub(failed)
263    }
264
265    /// Record a failed login attempt.
266    ///
267    /// Returns the new lock status after recording the failure.
268    pub async fn record_failure(&self, identifier: &str) -> LockStatus {
269        self.record_failure_with_ip(identifier, None).await
270    }
271
272    /// Record a failed login attempt with IP address.
273    pub async fn record_failure_with_ip(
274        &self,
275        identifier: &str,
276        ip_address: Option<&str>,
277    ) -> LockStatus {
278        let mut records = self.records.write().await;
279        let record = records
280            .entry(identifier.to_string())
281            .or_insert_with(AttemptRecord::new);
282
283        // Check if already permanently locked
284        if matches!(record.lock_status, LockStatus::PermanentlyLocked { .. }) {
285            return record.lock_status.clone();
286        }
287
288        // Check if temporarily locked and lock has expired
289        if let LockStatus::TemporarilyLocked { until, .. } = &record.lock_status {
290            if Instant::now() >= *until {
291                record.lock_status = LockStatus::Unlocked;
292            }
293        }
294
295        let now = Instant::now();
296
297        // Clean up old attempts outside the window
298        let cutoff = now - self.config.attempt_window;
299        record.attempts.retain(|&t| t > cutoff);
300
301        // Record this attempt
302        record.attempts.push(now);
303
304        // Record IP if provided
305        if let Some(ip) = ip_address {
306            if !record.ip_addresses.contains(&ip.to_string()) {
307                record.ip_addresses.push(ip.to_string());
308            }
309        }
310
311        // Check if we should lock
312        if record.attempts.len() as u32 >= self.config.max_attempts {
313            record.lock_count += 1;
314
315            // Check for permanent lock
316            if let Some(threshold) = self.config.permanent_lock_threshold {
317                if record.lock_count >= threshold {
318                    record.lock_status = LockStatus::PermanentlyLocked {
319                        reason: format!(
320                            "Too many failed attempts ({} lockouts)",
321                            record.lock_count
322                        ),
323                    };
324                    return record.lock_status.clone();
325                }
326            }
327
328            // Calculate lockout duration
329            let duration = if self.config.progressive_lockout {
330                let multiplier = 2u64.pow(record.lock_count.saturating_sub(1));
331                let progressive = self.config.lockout_duration * multiplier as u32;
332                progressive.min(self.config.max_lockout_duration)
333            } else {
334                self.config.lockout_duration
335            };
336
337            record.lock_status = LockStatus::TemporarilyLocked {
338                until: now + duration,
339                reason: format!(
340                    "Too many failed login attempts ({} attempts)",
341                    record.attempts.len()
342                ),
343            };
344
345            // Clear attempts after locking
346            record.attempts.clear();
347        }
348
349        record.lock_status.clone()
350    }
351
352    /// Record a successful login.
353    ///
354    /// This resets the failed attempt counter if `reset_on_success` is enabled.
355    pub async fn record_success(&self, identifier: &str) {
356        if !self.config.reset_on_success {
357            return;
358        }
359
360        let mut records = self.records.write().await;
361        if let Some(record) = records.get_mut(identifier) {
362            // Only reset if not permanently locked
363            if !matches!(record.lock_status, LockStatus::PermanentlyLocked { .. }) {
364                record.attempts.clear();
365                record.lock_status = LockStatus::Unlocked;
366                // Note: we don't reset lock_count to track total lockouts
367            }
368        }
369    }
370
371    /// Manually unlock an account.
372    pub async fn unlock(&self, identifier: &str) {
373        let mut records = self.records.write().await;
374        if let Some(record) = records.get_mut(identifier) {
375            record.attempts.clear();
376            record.lock_status = LockStatus::Unlocked;
377        }
378    }
379
380    /// Manually lock an account permanently.
381    pub async fn lock_permanently(&self, identifier: &str, reason: &str) {
382        let mut records = self.records.write().await;
383        let record = records
384            .entry(identifier.to_string())
385            .or_insert_with(AttemptRecord::new);
386        record.lock_status = LockStatus::PermanentlyLocked {
387            reason: reason.to_string(),
388        };
389    }
390
391    /// Get statistics for an account.
392    pub async fn get_account_stats(&self, identifier: &str) -> AccountStats {
393        let records = self.records.read().await;
394        if let Some(record) = records.get(identifier) {
395            let cutoff = Instant::now() - self.config.attempt_window;
396            let recent_attempts = record.attempts.iter().filter(|&&t| t > cutoff).count() as u32;
397
398            AccountStats {
399                total_lockouts: record.lock_count,
400                recent_failed_attempts: recent_attempts,
401                is_locked: record.lock_status.is_locked(),
402                remaining_lock_duration: record.lock_status.remaining_lock_duration(),
403                associated_ips: record.ip_addresses.clone(),
404            }
405        } else {
406            AccountStats::default()
407        }
408    }
409
410    /// Clean up expired records.
411    pub async fn cleanup(&self) {
412        let mut records = self.records.write().await;
413        let now = Instant::now();
414        let cleanup_threshold = self.config.attempt_window * 2;
415
416        records.retain(|_, record| {
417            // Keep if locked
418            if record.lock_status.is_locked() {
419                return true;
420            }
421            // Keep if has recent attempts
422            if let Some(last) = record.attempts.last() {
423                return now - *last < cleanup_threshold;
424            }
425            // Remove if no attempts and not locked
426            false
427        });
428    }
429}
430
431impl Default for AccountLockManager {
432    fn default() -> Self {
433        Self::with_defaults()
434    }
435}
436
437/// Account statistics.
438#[derive(Debug, Clone, Default)]
439pub struct AccountStats {
440    /// Total number of times this account has been locked.
441    pub total_lockouts: u32,
442    /// Number of recent failed attempts.
443    pub recent_failed_attempts: u32,
444    /// Whether the account is currently locked.
445    pub is_locked: bool,
446    /// Remaining lock duration if temporarily locked.
447    pub remaining_lock_duration: Option<Duration>,
448    /// IP addresses associated with failed attempts.
449    pub associated_ips: Vec<String>,
450}
451
452/// Result of a login check.
453#[derive(Debug, Clone)]
454pub enum LoginCheckResult {
455    /// Login allowed
456    Allowed {
457        /// Remaining attempts before lockout
458        remaining_attempts: u32,
459    },
460    /// Login blocked due to account lock
461    Blocked {
462        /// Lock status
463        status: LockStatus,
464        /// Message to display
465        message: String,
466    },
467}
468
469impl LoginCheckResult {
470    /// Check if login is allowed.
471    pub fn is_allowed(&self) -> bool {
472        matches!(self, LoginCheckResult::Allowed { .. })
473    }
474}
475
476/// Helper function to check login and return detailed result.
477pub async fn check_login(manager: &AccountLockManager, identifier: &str) -> LoginCheckResult {
478    let status = manager.get_lock_status(identifier).await;
479
480    match status {
481        LockStatus::Unlocked => {
482            let remaining = manager.get_remaining_attempts(identifier).await;
483            LoginCheckResult::Allowed {
484                remaining_attempts: remaining,
485            }
486        }
487        LockStatus::TemporarilyLocked { until, ref reason } => {
488            let remaining = until.saturating_duration_since(Instant::now());
489            let minutes = remaining.as_secs() / 60;
490            let message = format!(
491                "Account temporarily locked: {}. Try again in {} minutes.",
492                reason, minutes
493            );
494            LoginCheckResult::Blocked {
495                status: LockStatus::TemporarilyLocked {
496                    until,
497                    reason: reason.clone(),
498                },
499                message,
500            }
501        }
502        LockStatus::PermanentlyLocked { ref reason } => {
503            let message = format!(
504                "Account permanently locked: {}. Please contact support.",
505                reason
506            );
507            LoginCheckResult::Blocked {
508                status: LockStatus::PermanentlyLocked {
509                    reason: reason.clone(),
510                },
511                message,
512            }
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[tokio::test]
522    async fn test_account_not_locked_initially() {
523        let manager = AccountLockManager::with_defaults();
524        assert!(!manager.is_locked("user@example.com").await);
525    }
526
527    #[tokio::test]
528    async fn test_lock_after_max_attempts() {
529        let config = LockConfig::new()
530            .max_attempts(3)
531            .lockout_duration(Duration::from_secs(60));
532        let manager = AccountLockManager::new(config);
533
534        // First 2 attempts should not lock
535        manager.record_failure("user@example.com").await;
536        manager.record_failure("user@example.com").await;
537        assert!(!manager.is_locked("user@example.com").await);
538
539        // 3rd attempt should lock
540        manager.record_failure("user@example.com").await;
541        assert!(manager.is_locked("user@example.com").await);
542    }
543
544    #[tokio::test]
545    async fn test_reset_on_success() {
546        let config = LockConfig::new().max_attempts(3).reset_on_success(true);
547        let manager = AccountLockManager::new(config);
548
549        // Record 2 failures
550        manager.record_failure("user@example.com").await;
551        manager.record_failure("user@example.com").await;
552        assert_eq!(manager.get_failed_attempts("user@example.com").await, 2);
553
554        // Success should reset
555        manager.record_success("user@example.com").await;
556        assert_eq!(manager.get_failed_attempts("user@example.com").await, 0);
557    }
558
559    #[tokio::test]
560    async fn test_manual_unlock() {
561        let config = LockConfig::new()
562            .max_attempts(2)
563            .lockout_duration(Duration::from_secs(3600));
564        let manager = AccountLockManager::new(config);
565
566        // Lock the account
567        manager.record_failure("user@example.com").await;
568        manager.record_failure("user@example.com").await;
569        assert!(manager.is_locked("user@example.com").await);
570
571        // Manual unlock
572        manager.unlock("user@example.com").await;
573        assert!(!manager.is_locked("user@example.com").await);
574    }
575
576    #[tokio::test]
577    async fn test_permanent_lock() {
578        let config = LockConfig::new()
579            .max_attempts(2)
580            .lockout_duration(Duration::from_secs(1))
581            .permanent_lock_threshold(Some(2));
582        let manager = AccountLockManager::new(config);
583
584        // First lockout
585        manager.record_failure("user@example.com").await;
586        manager.record_failure("user@example.com").await;
587
588        // Wait for lock to expire
589        tokio::time::sleep(Duration::from_secs(2)).await;
590
591        // Second lockout should trigger permanent lock
592        manager.record_failure("user@example.com").await;
593        manager.record_failure("user@example.com").await;
594
595        let status = manager.get_lock_status("user@example.com").await;
596        assert!(matches!(status, LockStatus::PermanentlyLocked { .. }));
597    }
598
599    #[tokio::test]
600    async fn test_remaining_attempts() {
601        let config = LockConfig::new().max_attempts(5);
602        let manager = AccountLockManager::new(config);
603
604        assert_eq!(manager.get_remaining_attempts("user@example.com").await, 5);
605
606        manager.record_failure("user@example.com").await;
607        assert_eq!(manager.get_remaining_attempts("user@example.com").await, 4);
608
609        manager.record_failure("user@example.com").await;
610        manager.record_failure("user@example.com").await;
611        assert_eq!(manager.get_remaining_attempts("user@example.com").await, 2);
612    }
613
614    #[tokio::test]
615    async fn test_ip_tracking() {
616        let manager = AccountLockManager::with_defaults();
617
618        manager
619            .record_failure_with_ip("user@example.com", Some("192.168.1.1"))
620            .await;
621        manager
622            .record_failure_with_ip("user@example.com", Some("10.0.0.1"))
623            .await;
624
625        let stats = manager.get_account_stats("user@example.com").await;
626        assert_eq!(stats.associated_ips.len(), 2);
627        assert!(stats.associated_ips.contains(&"192.168.1.1".to_string()));
628        assert!(stats.associated_ips.contains(&"10.0.0.1".to_string()));
629    }
630
631    #[test]
632    fn test_lock_status() {
633        let unlocked = LockStatus::Unlocked;
634        assert!(!unlocked.is_locked());
635
636        let temp_locked = LockStatus::TemporarilyLocked {
637            until: Instant::now() + Duration::from_secs(60),
638            reason: "test".to_string(),
639        };
640        assert!(temp_locked.is_locked());
641
642        let perm_locked = LockStatus::PermanentlyLocked {
643            reason: "test".to_string(),
644        };
645        assert!(perm_locked.is_locked());
646    }
647
648    #[test]
649    fn test_config_builder() {
650        let config = LockConfig::new()
651            .max_attempts(10)
652            .lockout_duration(Duration::from_secs(300))
653            .progressive_lockout(false);
654
655        assert_eq!(config.max_attempts, 10);
656        assert_eq!(config.lockout_duration, Duration::from_secs(300));
657        assert!(!config.progressive_lockout);
658    }
659}