actix_security_core/http/security/
account.rs1use std::collections::HashMap;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use tokio::sync::RwLock;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum LockStatus {
42 Unlocked,
44 TemporarilyLocked {
46 until: Instant,
48 reason: String,
50 },
51 PermanentlyLocked {
53 reason: String,
55 },
56}
57
58impl LockStatus {
59 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 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#[derive(Debug, Clone)]
86pub struct LockConfig {
87 pub max_attempts: u32,
89 pub lockout_duration: Duration,
91 pub attempt_window: Duration,
93 pub reset_on_success: bool,
95 pub progressive_lockout: bool,
97 pub max_lockout_duration: Duration,
99 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), attempt_window: Duration::from_secs(60 * 60), reset_on_success: true,
110 progressive_lockout: true,
111 max_lockout_duration: Duration::from_secs(24 * 60 * 60), permanent_lock_threshold: Some(10),
113 }
114 }
115}
116
117impl LockConfig {
118 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn max_attempts(mut self, attempts: u32) -> Self {
125 self.max_attempts = attempts;
126 self
127 }
128
129 pub fn lockout_duration(mut self, duration: Duration) -> Self {
131 self.lockout_duration = duration;
132 self
133 }
134
135 pub fn attempt_window(mut self, window: Duration) -> Self {
137 self.attempt_window = window;
138 self
139 }
140
141 pub fn reset_on_success(mut self, reset: bool) -> Self {
143 self.reset_on_success = reset;
144 self
145 }
146
147 pub fn progressive_lockout(mut self, enabled: bool) -> Self {
149 self.progressive_lockout = enabled;
150 self
151 }
152
153 pub fn max_lockout_duration(mut self, duration: Duration) -> Self {
155 self.max_lockout_duration = duration;
156 self
157 }
158
159 pub fn permanent_lock_threshold(mut self, threshold: Option<u32>) -> Self {
161 self.permanent_lock_threshold = threshold;
162 self
163 }
164
165 pub fn strict() -> Self {
167 Self::new()
168 .max_attempts(3)
169 .lockout_duration(Duration::from_secs(30 * 60)) .permanent_lock_threshold(Some(5))
171 }
172
173 pub fn lenient() -> Self {
175 Self::new()
176 .max_attempts(10)
177 .lockout_duration(Duration::from_secs(5 * 60)) .progressive_lockout(false)
179 .permanent_lock_threshold(None)
180 }
181}
182
183#[derive(Debug, Clone)]
185struct AttemptRecord {
186 attempts: Vec<Instant>,
188 lock_count: u32,
190 lock_status: LockStatus,
192 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#[derive(Clone)]
209pub struct AccountLockManager {
210 records: Arc<RwLock<HashMap<String, AttemptRecord>>>,
211 config: LockConfig,
212}
213
214impl AccountLockManager {
215 pub fn new(config: LockConfig) -> Self {
217 Self {
218 records: Arc::new(RwLock::new(HashMap::new())),
219 config,
220 }
221 }
222
223 pub fn with_defaults() -> Self {
225 Self::new(LockConfig::default())
226 }
227
228 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 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 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 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 pub async fn record_failure(&self, identifier: &str) -> LockStatus {
269 self.record_failure_with_ip(identifier, None).await
270 }
271
272 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 if matches!(record.lock_status, LockStatus::PermanentlyLocked { .. }) {
285 return record.lock_status.clone();
286 }
287
288 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 let cutoff = now - self.config.attempt_window;
299 record.attempts.retain(|&t| t > cutoff);
300
301 record.attempts.push(now);
303
304 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 if record.attempts.len() as u32 >= self.config.max_attempts {
313 record.lock_count += 1;
314
315 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 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 record.attempts.clear();
347 }
348
349 record.lock_status.clone()
350 }
351
352 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 if !matches!(record.lock_status, LockStatus::PermanentlyLocked { .. }) {
364 record.attempts.clear();
365 record.lock_status = LockStatus::Unlocked;
366 }
368 }
369 }
370
371 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 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 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 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 if record.lock_status.is_locked() {
419 return true;
420 }
421 if let Some(last) = record.attempts.last() {
423 return now - *last < cleanup_threshold;
424 }
425 false
427 });
428 }
429}
430
431impl Default for AccountLockManager {
432 fn default() -> Self {
433 Self::with_defaults()
434 }
435}
436
437#[derive(Debug, Clone, Default)]
439pub struct AccountStats {
440 pub total_lockouts: u32,
442 pub recent_failed_attempts: u32,
444 pub is_locked: bool,
446 pub remaining_lock_duration: Option<Duration>,
448 pub associated_ips: Vec<String>,
450}
451
452#[derive(Debug, Clone)]
454pub enum LoginCheckResult {
455 Allowed {
457 remaining_attempts: u32,
459 },
460 Blocked {
462 status: LockStatus,
464 message: String,
466 },
467}
468
469impl LoginCheckResult {
470 pub fn is_allowed(&self) -> bool {
472 matches!(self, LoginCheckResult::Allowed { .. })
473 }
474}
475
476pub 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 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 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 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 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 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 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 manager.record_failure("user@example.com").await;
586 manager.record_failure("user@example.com").await;
587
588 tokio::time::sleep(Duration::from_secs(2)).await;
590
591 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}