Skip to main content

mailrs_auth_guard/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::net::IpAddr;
6use std::time::{Duration, Instant};
7
8use dashmap::DashMap;
9
10/// Tunable thresholds for the auth-guard's lockout policy.
11///
12/// Defaults are tuned for an SMTP/IMAP submission server:
13/// - 5 failures per (IP, username) in a 15-minute window → 30-minute lockout
14/// - 20 failures per IP (any user) in a 60-minute window → 1-hour lockout
15/// - Exponential backoff with multiplier 2.0, capped at 24 hours
16pub struct AuthGuardConfig {
17    /// Failures within `account_window_secs` to trigger an account-level
18    /// lockout. Counts per (IP, username) pair.
19    pub max_failures_account: u32,
20    /// Sliding-window length for counting account-level failures.
21    pub account_window_secs: u64,
22    /// First-lockout duration for account-level breaches. Subsequent
23    /// lockouts grow by `backoff_multiplier` ^ consecutive_lockouts.
24    pub base_lockout_secs: u64,
25    /// Failures within `ip_window_secs` (any user) to trigger an
26    /// IP-level lockout.
27    pub max_failures_ip: u32,
28    /// Sliding-window length for counting IP-level failures.
29    pub ip_window_secs: u64,
30    /// First-lockout duration for IP-level breaches.
31    pub ip_base_lockout_secs: u64,
32    /// Exponential-backoff multiplier applied to repeat offenders.
33    /// 2.0 → each subsequent lockout doubles the previous duration.
34    pub backoff_multiplier: f64,
35    /// Hard ceiling on lockout duration regardless of backoff.
36    pub max_lockout_secs: u64,
37}
38
39impl Default for AuthGuardConfig {
40    fn default() -> Self {
41        Self {
42            max_failures_account: 5,
43            account_window_secs: 900,
44            base_lockout_secs: 1800,
45            max_failures_ip: 20,
46            ip_window_secs: 3600,
47            ip_base_lockout_secs: 3600,
48            backoff_multiplier: 2.0,
49            max_lockout_secs: 86400,
50        }
51    }
52}
53
54struct FailureRecord {
55    failures: Vec<Instant>,
56    lockout_until: Option<Instant>,
57    consecutive_lockouts: u32,
58}
59
60/// Result of [`AuthGuard::check`].
61pub enum AuthCheck {
62    /// No active lockout — the caller should proceed to actually verify
63    /// the password.
64    Allowed,
65    /// Currently locked out; reject the auth attempt without checking
66    /// the password. `remaining_secs` is the wall-clock time until
67    /// the lockout expires.
68    LockedOut {
69        /// Seconds until the lockout expires.
70        remaining_secs: u64,
71    },
72}
73
74/// Sharded in-process tracker of failed auth attempts plus lockout
75/// state, keyed by `(IpAddr, username)` and by `IpAddr` alone.
76///
77/// Both counters slide over time windows configured in
78/// [`AuthGuardConfig`]. The IP-only counter applies regardless of
79/// which username was attempted, so a single attacker spraying many
80/// usernames eventually hits the IP-level lockout.
81pub struct AuthGuard {
82    config: AuthGuardConfig,
83    account_failures: DashMap<(IpAddr, String), FailureRecord>,
84    ip_failures: DashMap<IpAddr, FailureRecord>,
85}
86
87/// Compute the lockout duration with exponential backoff: `base ×
88/// multiplier^consecutive_lockouts`, capped at `max_secs`.
89///
90/// Returns seconds. Equivalent to constructing a [`mailrs_backoff::Backoff`]
91/// with `Jitter::None` and reading `base_delay(consecutive_lockouts)`,
92/// which is exactly what this function does internally. Lockouts are
93/// deterministic by design — you want every offender to see the same
94/// penalty — so no jitter.
95pub fn lockout_duration(
96    base_secs: u64,
97    consecutive_lockouts: u32,
98    multiplier: f64,
99    max_secs: u64,
100) -> u64 {
101    let backoff = mailrs_backoff::Backoff {
102        initial: Duration::from_secs(base_secs),
103        multiplier,
104        max: Duration::from_secs(max_secs),
105        jitter: mailrs_backoff::Jitter::None,
106    };
107    backoff.base_delay(consecutive_lockouts).as_secs()
108}
109
110/// normalize IPv6 to /64 prefix for rate limiting
111fn normalize_ip(ip: IpAddr) -> IpAddr {
112    match ip {
113        IpAddr::V6(v6) => {
114            let segments = v6.segments();
115            let masked = std::net::Ipv6Addr::new(
116                segments[0],
117                segments[1],
118                segments[2],
119                segments[3],
120                0,
121                0,
122                0,
123                0,
124            );
125            IpAddr::V6(masked)
126        }
127        ip => ip,
128    }
129}
130
131impl AuthGuard {
132    /// Construct a guard with the given thresholds. Use
133    /// `AuthGuardConfig::default()` for the SMTP/IMAP-tuned defaults.
134    pub fn new(config: AuthGuardConfig) -> Self {
135        Self {
136            config,
137            account_failures: DashMap::new(),
138            ip_failures: DashMap::new(),
139        }
140    }
141
142    /// Check whether `(ip, username)` is currently in lockout.
143    ///
144    /// Read-only; does **not** record an attempt. Call before doing
145    /// the actual password verification. If `Allowed`, do the verify;
146    /// if `LockedOut`, reject without touching the password backend.
147    ///
148    /// The check looks at both the per-IP and per-(IP, username)
149    /// counters and returns the first matching lockout. IPv6 addresses
150    /// are normalized to their /64 prefix.
151    pub fn check(&self, ip: IpAddr, username: &str) -> AuthCheck {
152        let ip = normalize_ip(ip);
153        let now = Instant::now();
154
155        if let Some(rec) = self.ip_failures.get(&ip)
156            && let Some(until) = rec.lockout_until
157                && now < until {
158                    let remaining = until.duration_since(now).as_secs();
159                    return AuthCheck::LockedOut {
160                        remaining_secs: remaining,
161                    };
162                }
163
164        let key = (ip, username.to_string());
165        if let Some(rec) = self.account_failures.get(&key)
166            && let Some(until) = rec.lockout_until
167                && now < until {
168                    let remaining = until.duration_since(now).as_secs();
169                    return AuthCheck::LockedOut {
170                        remaining_secs: remaining,
171                    };
172                }
173
174        AuthCheck::Allowed
175    }
176
177    /// Record a failed auth attempt. Call when the password verify
178    /// returns "wrong credentials" — including the case where the
179    /// account doesn't exist (constant-time policy).
180    ///
181    /// Increments both the per-IP and per-(IP, username) counters.
182    /// May tip one or both over their threshold and arm a lockout.
183    pub fn record_failure(&self, ip: IpAddr, username: &str) {
184        let ip = normalize_ip(ip);
185        let now = Instant::now();
186
187        tracing::warn!(
188            event = "auth_failure",
189            ip = %ip,
190            username = username,
191        );
192
193        // per-(IP, username) tracking
194        let key = (ip, username.to_string());
195        let mut entry = self
196            .account_failures
197            .entry(key)
198            .or_insert_with(|| FailureRecord {
199                failures: Vec::new(),
200                lockout_until: None,
201                consecutive_lockouts: 0,
202            });
203
204        let window_start = now - Duration::from_secs(self.config.account_window_secs);
205        entry.failures.retain(|t| *t > window_start);
206        entry.failures.push(now);
207
208        if entry.failures.len() as u32 >= self.config.max_failures_account {
209            let duration = lockout_duration(
210                self.config.base_lockout_secs,
211                entry.consecutive_lockouts,
212                self.config.backoff_multiplier,
213                self.config.max_lockout_secs,
214            );
215            entry.lockout_until = Some(now + Duration::from_secs(duration));
216            entry.consecutive_lockouts += 1;
217            entry.failures.clear();
218
219            tracing::warn!(
220                event = "auth_lockout",
221                ip = %ip,
222                username = username,
223                scope = "account",
224                duration_secs = duration,
225            );
226        }
227
228        // per-IP tracking
229        let mut entry = self.ip_failures.entry(ip).or_insert_with(|| FailureRecord {
230            failures: Vec::new(),
231            lockout_until: None,
232            consecutive_lockouts: 0,
233        });
234
235        let window_start = now - Duration::from_secs(self.config.ip_window_secs);
236        entry.failures.retain(|t| *t > window_start);
237        entry.failures.push(now);
238
239        if entry.failures.len() as u32 >= self.config.max_failures_ip {
240            let duration = lockout_duration(
241                self.config.ip_base_lockout_secs,
242                entry.consecutive_lockouts,
243                self.config.backoff_multiplier,
244                self.config.max_lockout_secs,
245            );
246            entry.lockout_until = Some(now + Duration::from_secs(duration));
247            entry.consecutive_lockouts += 1;
248            entry.failures.clear();
249
250            tracing::warn!(
251                event = "auth_lockout",
252                ip = %ip,
253                scope = "ip",
254                duration_secs = duration,
255            );
256        }
257    }
258
259    /// Record a successful auth. Clears the per-(IP, username)
260    /// counter (so a legitimate user who fat-fingered then succeeded
261    /// doesn't accumulate against future attempts).
262    ///
263    /// Does **not** clear the per-IP counter, because a successful
264    /// auth from one user doesn't prove the IP isn't being abused
265    /// against another. Use cleanup_stale + time decay for that.
266    pub fn record_success(&self, ip: IpAddr, username: &str) {
267        let ip = normalize_ip(ip);
268        let key = (ip, username.to_string());
269        self.account_failures.remove(&key);
270    }
271
272    /// Drop records whose lockouts have already expired before
273    /// `before`. Call periodically (every few minutes) from a
274    /// background task to keep the maps bounded under sustained
275    /// attack volume.
276    ///
277    /// Records with active lockouts or recent in-window failures
278    /// are preserved.
279    pub fn cleanup_stale(&self, before: Instant) {
280        self.account_failures.retain(|_, rec| {
281            if let Some(until) = rec.lockout_until
282                && until < before {
283                    return false;
284                }
285            !rec.failures.is_empty() || rec.lockout_until.is_some()
286        });
287        self.ip_failures.retain(|_, rec| {
288            if let Some(until) = rec.lockout_until
289                && until < before {
290                    return false;
291                }
292            !rec.failures.is_empty() || rec.lockout_until.is_some()
293        });
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn lockout_duration_base() {
303        assert_eq!(lockout_duration(1800, 0, 2.0, 86400), 1800);
304    }
305
306    #[test]
307    fn lockout_duration_exponential() {
308        assert_eq!(lockout_duration(1800, 2, 2.0, 86400), 7200);
309    }
310
311    #[test]
312    fn lockout_duration_capped() {
313        assert_eq!(lockout_duration(1800, 10, 2.0, 86400), 86400);
314    }
315
316    #[test]
317    fn allowed_below_threshold() {
318        let guard = AuthGuard::new(AuthGuardConfig {
319            max_failures_account: 5,
320            ..Default::default()
321        });
322        let ip: IpAddr = "127.0.0.1".parse().unwrap();
323        for _ in 0..4 {
324            guard.record_failure(ip, "alice");
325        }
326        assert!(matches!(guard.check(ip, "alice"), AuthCheck::Allowed));
327    }
328
329    #[test]
330    fn locked_at_threshold() {
331        let guard = AuthGuard::new(AuthGuardConfig {
332            max_failures_account: 5,
333            ..Default::default()
334        });
335        let ip: IpAddr = "127.0.0.1".parse().unwrap();
336        for _ in 0..5 {
337            guard.record_failure(ip, "alice");
338        }
339        assert!(matches!(
340            guard.check(ip, "alice"),
341            AuthCheck::LockedOut { .. }
342        ));
343    }
344
345    #[test]
346    fn success_resets_account() {
347        let guard = AuthGuard::new(AuthGuardConfig {
348            max_failures_account: 5,
349            ..Default::default()
350        });
351        let ip: IpAddr = "127.0.0.1".parse().unwrap();
352        for _ in 0..4 {
353            guard.record_failure(ip, "alice");
354        }
355        guard.record_success(ip, "alice");
356        // should be back to 0 failures
357        guard.record_failure(ip, "alice");
358        assert!(matches!(guard.check(ip, "alice"), AuthCheck::Allowed));
359    }
360
361    #[test]
362    fn ipv6_normalized_to_64() {
363        let ip1: IpAddr = "2001:db8::1".parse().unwrap();
364        let ip2: IpAddr = "2001:db8::ffff".parse().unwrap();
365        assert_eq!(normalize_ip(ip1), normalize_ip(ip2));
366    }
367
368    #[test]
369    fn ipv4_unchanged() {
370        let ip: IpAddr = "192.168.1.1".parse().unwrap();
371        assert_eq!(normalize_ip(ip), ip);
372    }
373
374    #[test]
375    fn ipv6_different_subnets_not_merged() {
376        let ip1: IpAddr = "2001:db8:aaaa:bbbb::1".parse().unwrap();
377        let ip2: IpAddr = "2001:db8:cccc:dddd::1".parse().unwrap();
378        assert_ne!(normalize_ip(ip1), normalize_ip(ip2));
379    }
380
381    #[test]
382    fn ip_lockout_at_threshold() {
383        let guard = AuthGuard::new(AuthGuardConfig {
384            max_failures_ip: 3,
385            max_failures_account: 100, // high so account lock doesn't trigger
386            ..Default::default()
387        });
388        let ip: IpAddr = "10.0.0.1".parse().unwrap();
389        for _ in 0..3 {
390            guard.record_failure(ip, "user1");
391        }
392        assert!(matches!(
393            guard.check(ip, "any_user"),
394            AuthCheck::LockedOut { .. }
395        ));
396    }
397
398    #[test]
399    fn lockout_expires_after_duration() {
400        // use a very short lockout so we can simulate expiry via Instant arithmetic
401        // since Instant doesn't let us go forward, we test via cleanup_stale + re-check pattern
402        // instead we directly verify the lockout_until field is in the future
403        let guard = AuthGuard::new(AuthGuardConfig {
404            max_failures_account: 2,
405            base_lockout_secs: 1,
406            max_lockout_secs: 1,
407            backoff_multiplier: 1.0,
408            ..Default::default()
409        });
410        let ip: IpAddr = "127.0.0.1".parse().unwrap();
411
412        // trigger lockout
413        guard.record_failure(ip, "bob");
414        guard.record_failure(ip, "bob");
415        assert!(matches!(
416            guard.check(ip, "bob"),
417            AuthCheck::LockedOut { remaining_secs }
418            if remaining_secs <= 1
419        ));
420
421        // wait just over 1 second for the lockout to expire
422        std::thread::sleep(std::time::Duration::from_millis(1100));
423        assert!(matches!(guard.check(ip, "bob"), AuthCheck::Allowed));
424    }
425
426    #[test]
427    fn cleanup_stale_removes_expired_lockouts() {
428        let guard = AuthGuard::new(AuthGuardConfig {
429            max_failures_account: 2,
430            base_lockout_secs: 1,
431            max_lockout_secs: 1,
432            backoff_multiplier: 1.0,
433            max_failures_ip: 2,
434            ip_base_lockout_secs: 1,
435            ..Default::default()
436        });
437        let ip: IpAddr = "127.0.0.1".parse().unwrap();
438
439        // trigger both account and ip lockout
440        guard.record_failure(ip, "carol");
441        guard.record_failure(ip, "carol");
442        assert!(!guard.account_failures.is_empty());
443        assert!(!guard.ip_failures.is_empty());
444
445        // cleanup with a time far in the future should remove everything
446        let future = Instant::now() + std::time::Duration::from_secs(3600);
447        guard.cleanup_stale(future);
448        assert!(guard.account_failures.is_empty());
449        assert!(guard.ip_failures.is_empty());
450    }
451
452    #[test]
453    fn cleanup_stale_preserves_active_records() {
454        let guard = AuthGuard::new(AuthGuardConfig {
455            max_failures_account: 10,
456            max_failures_ip: 10,
457            ..Default::default()
458        });
459        let ip: IpAddr = "127.0.0.1".parse().unwrap();
460
461        // record one failure (no lockout yet, but failures vec is non-empty)
462        guard.record_failure(ip, "dave");
463        assert_eq!(guard.account_failures.len(), 1);
464        assert_eq!(guard.ip_failures.len(), 1);
465
466        // cleanup with current time should keep records (they have recent failures)
467        guard.cleanup_stale(Instant::now());
468        assert_eq!(guard.account_failures.len(), 1);
469        assert_eq!(guard.ip_failures.len(), 1);
470    }
471
472    #[test]
473    fn normal_login_not_blocked() {
474        let guard = AuthGuard::new(AuthGuardConfig::default());
475        let ip: IpAddr = "192.168.1.100".parse().unwrap();
476        // fresh guard, no failures recorded
477        assert!(matches!(guard.check(ip, "admin"), AuthCheck::Allowed));
478    }
479
480    #[test]
481    fn exponential_backoff_increases_lockout() {
482        let guard = AuthGuard::new(AuthGuardConfig {
483            max_failures_account: 1,
484            base_lockout_secs: 10,
485            backoff_multiplier: 2.0,
486            max_lockout_secs: 86400,
487            account_window_secs: 1, // short window so failures don't pile up across lockouts
488            ..Default::default()
489        });
490        let ip: IpAddr = "127.0.0.1".parse().unwrap();
491
492        // first lockout: base = 10s
493        guard.record_failure(ip, "eve");
494        if let AuthCheck::LockedOut { remaining_secs } = guard.check(ip, "eve") {
495            assert!(remaining_secs <= 10);
496        } else {
497            panic!("expected lockout after first failure");
498        }
499
500        // wait for lockout to expire, then trigger second lockout
501        std::thread::sleep(std::time::Duration::from_millis(100));
502        // manually clear lockout to simulate time passing
503        if let Some(mut rec) = guard.account_failures.get_mut(&(ip, "eve".to_string())) {
504            rec.lockout_until = None;
505        }
506
507        // second lockout: base * 2^1 = 20s
508        guard.record_failure(ip, "eve");
509        if let AuthCheck::LockedOut { remaining_secs } = guard.check(ip, "eve") {
510            assert!(
511                remaining_secs > 10,
512                "second lockout should be longer than first, got {remaining_secs}"
513            );
514        } else {
515            panic!("expected lockout after second round of failures");
516        }
517    }
518
519    #[test]
520    fn ipv6_lockout_applies_to_same_subnet() {
521        let guard = AuthGuard::new(AuthGuardConfig {
522            max_failures_account: 2,
523            ..Default::default()
524        });
525        // two different hosts in the same /64
526        let ip1: IpAddr = "2001:db8:1:2::aaaa".parse().unwrap();
527        let ip2: IpAddr = "2001:db8:1:2::bbbb".parse().unwrap();
528
529        // failures from ip1
530        guard.record_failure(ip1, "frank");
531        guard.record_failure(ip1, "frank");
532
533        // check from ip2 (same /64) should be locked
534        assert!(matches!(
535            guard.check(ip2, "frank"),
536            AuthCheck::LockedOut { .. }
537        ));
538    }
539
540    // ===== additional edge cases =====
541
542    #[test]
543    fn ipv6_different_subnets_not_blocked_together() {
544        let guard = AuthGuard::new(AuthGuardConfig {
545            max_failures_account: 2,
546            ..Default::default()
547        });
548        let ip1: IpAddr = "2001:db8:1:2::aaaa".parse().unwrap();
549        let ip2: IpAddr = "2001:db8:3:4::bbbb".parse().unwrap(); // different /64
550        guard.record_failure(ip1, "alice");
551        guard.record_failure(ip1, "alice");
552        // ip2 is a different /64 and should NOT be locked
553        assert!(matches!(guard.check(ip2, "alice"), AuthCheck::Allowed));
554    }
555
556    #[test]
557    fn different_usernames_track_independently() {
558        let guard = AuthGuard::new(AuthGuardConfig {
559            max_failures_account: 2,
560            max_failures_ip: 100, // very high so IP-level doesn't trigger
561            ..Default::default()
562        });
563        let ip: IpAddr = "192.0.2.1".parse().unwrap();
564        // alice gets 2 failures (at threshold) — should be locked
565        guard.record_failure(ip, "alice");
566        guard.record_failure(ip, "alice");
567        assert!(matches!(guard.check(ip, "alice"), AuthCheck::LockedOut { .. }));
568        // bob (same IP, different user) should still be allowed
569        assert!(matches!(guard.check(ip, "bob"), AuthCheck::Allowed));
570    }
571
572    #[test]
573    fn record_failure_during_lockout_does_not_panic() {
574        // Once locked out, record_failure can still be called (the
575        // attacker keeps probing); the function should not panic.
576        let guard = AuthGuard::new(AuthGuardConfig {
577            max_failures_account: 2,
578            ..Default::default()
579        });
580        let ip: IpAddr = "192.0.2.10".parse().unwrap();
581        guard.record_failure(ip, "alice");
582        guard.record_failure(ip, "alice"); // triggers lockout
583        // Now keep recording while locked out — must not panic.
584        for _ in 0..10 {
585            guard.record_failure(ip, "alice");
586        }
587        // Still locked out (we never cleared)
588        assert!(matches!(guard.check(ip, "alice"), AuthCheck::LockedOut { .. }));
589    }
590
591    #[test]
592    fn record_success_does_not_clear_ip_counter() {
593        // Documented contract: record_success clears the per-account
594        // counter but NOT the per-IP counter. Verify.
595        let guard = AuthGuard::new(AuthGuardConfig {
596            max_failures_account: 100,
597            max_failures_ip: 3,
598            ..Default::default()
599        });
600        let ip: IpAddr = "192.0.2.20".parse().unwrap();
601        // Record 3 IP-level failures with different usernames.
602        guard.record_failure(ip, "user1");
603        guard.record_failure(ip, "user2");
604        guard.record_failure(ip, "user3"); // triggers IP-level lockout
605        // user1 succeeds (but it's already too late for the IP)
606        guard.record_success(ip, "user1");
607        // IP is still locked out for any user
608        assert!(matches!(guard.check(ip, "anyone"), AuthCheck::LockedOut { .. }));
609    }
610
611    #[test]
612    fn cleanup_stale_handles_empty_maps() {
613        // cleanup_stale on a fresh guard with no entries should not panic.
614        let guard = AuthGuard::new(AuthGuardConfig::default());
615        guard.cleanup_stale(Instant::now());
616        guard.cleanup_stale(Instant::now() + Duration::from_secs(3600));
617    }
618
619    #[test]
620    fn zero_max_failures_locks_immediately() {
621        // Degenerate config: 1 failure = lockout. (Setting 0 would
622        // never lockout because `len >= 0` is always true after the
623        // first push, but also tests show >= 1 means "one failure
624        // triggers it".)
625        let guard = AuthGuard::new(AuthGuardConfig {
626            max_failures_account: 1,
627            ..Default::default()
628        });
629        let ip: IpAddr = "192.0.2.30".parse().unwrap();
630        guard.record_failure(ip, "alice");
631        assert!(matches!(guard.check(ip, "alice"), AuthCheck::LockedOut { .. }));
632    }
633
634    #[test]
635    fn high_max_lockout_secs_caps_at_max() {
636        // Test that lockout_duration is capped at max_lockout_secs
637        // even when backoff would otherwise overflow.
638        let d = lockout_duration(1800, 100, 2.0, 86400);
639        assert_eq!(d, 86400);
640    }
641
642    #[test]
643    fn backoff_multiplier_below_one_does_not_explode() {
644        // 0.5 backoff multiplier — lockout duration should monotonically
645        // decrease (not increase) with repeat offenses. Not a useful
646        // config but should not panic.
647        let d0 = lockout_duration(1800, 0, 0.5, 86400);
648        let d1 = lockout_duration(1800, 1, 0.5, 86400);
649        let d2 = lockout_duration(1800, 2, 0.5, 86400);
650        assert_eq!(d0, 1800);
651        assert!(d1 <= d0);
652        assert!(d2 <= d1);
653    }
654
655    #[test]
656    fn concurrent_record_failures_dont_panic() {
657        use std::sync::Arc;
658        use std::thread;
659
660        let guard = Arc::new(AuthGuard::new(AuthGuardConfig::default()));
661        let ip: IpAddr = "192.0.2.40".parse().unwrap();
662        let mut handles = vec![];
663        for _ in 0..8 {
664            let g = guard.clone();
665            handles.push(thread::spawn(move || {
666                for _ in 0..50 {
667                    g.record_failure(ip, "alice");
668                }
669            }));
670        }
671        for h in handles {
672            h.join().unwrap();
673        }
674        // After 400 concurrent failures, account should be locked
675        // (we never panicked or deadlocked — that's the assertion).
676        assert!(matches!(guard.check(ip, "alice"), AuthCheck::LockedOut { .. }));
677    }
678
679    #[test]
680    fn ipv4_loopback_treated_separately_from_ipv6_loopback() {
681        // ::1 and 127.0.0.1 are different IP addresses.
682        let guard = AuthGuard::new(AuthGuardConfig {
683            max_failures_account: 2,
684            ..Default::default()
685        });
686        let v4: IpAddr = "127.0.0.1".parse().unwrap();
687        let v6: IpAddr = "::1".parse().unwrap();
688        guard.record_failure(v4, "alice");
689        guard.record_failure(v4, "alice");
690        assert!(matches!(guard.check(v4, "alice"), AuthCheck::LockedOut { .. }));
691        // v6 ::1 is independent — should be allowed
692        assert!(matches!(guard.check(v6, "alice"), AuthCheck::Allowed));
693    }
694}