Skip to main content

kellnr_http/
login_throttle.rs

1//! Per-username throttling of password authentication attempts.
2//!
3//! Kellnr often runs behind a reverse proxy, where every request shares the
4//! proxy's IP address. Throttling by IP would lock out all users at once, so
5//! attempts are counted per username instead. Unknown usernames are counted
6//! the same way, so the throttle does not reveal which users exist.
7
8use std::hash::{BuildHasher, RandomState};
9use std::time::{Duration, Instant};
10
11use moka::future::Cache;
12
13/// Upper bound of usernames tracked at once. Bounds memory when an attacker
14/// sprays random usernames; the least valuable entries are evicted first.
15/// Usernames are tracked by their hash, so long usernames do not take more
16/// memory.
17const MAX_TRACKED_USERS: u64 = 100_000;
18
19#[derive(Clone, Copy)]
20struct AttemptWindow {
21    attempts: u32,
22    started: Instant,
23}
24
25pub struct LoginThrottle {
26    max_attempts: u32,
27    window: Duration,
28    attempts: Option<Cache<u64, AttemptWindow>>,
29    /// Randomly seeded, so attackers cannot craft usernames that share the
30    /// hash of a victim.
31    hasher: RandomState,
32}
33
34impl LoginThrottle {
35    /// Allows `max_attempts` password checks per user within `window`. A
36    /// successful check resets the user's count. Zero disables throttling.
37    pub fn new(max_attempts: u32, window: Duration) -> Self {
38        let attempts = (max_attempts > 0 && !window.is_zero()).then(|| {
39            Cache::builder()
40                .max_capacity(MAX_TRACKED_USERS)
41                .time_to_live(window)
42                .build()
43        });
44        Self {
45            max_attempts,
46            window,
47            attempts,
48            hasher: RandomState::new(),
49        }
50    }
51
52    fn key(&self, user: &str) -> u64 {
53        self.hasher.hash_one(user)
54    }
55
56    /// Registers a password check for `user` before it runs. Returns `false`
57    /// if the user exhausted the allowed attempts in the current window, in
58    /// which case the password must not be checked at all.
59    ///
60    /// Counting before the check (instead of after a failure) keeps parallel
61    /// requests from exceeding the limit.
62    pub async fn try_attempt(&self, user: &str) -> bool {
63        let Some(attempts) = &self.attempts else {
64            return true;
65        };
66
67        let window = self.window;
68        // Saturate one above the limit so the count cannot overflow.
69        let ceiling = self.max_attempts.saturating_add(1);
70        let entry = attempts
71            .entry(self.key(user))
72            .and_upsert_with(|existing| {
73                let mut current = existing
74                    .map(moka::Entry::into_value)
75                    .filter(|w| w.started.elapsed() < window)
76                    .unwrap_or(AttemptWindow {
77                        attempts: 0,
78                        started: Instant::now(),
79                    });
80                current.attempts = current.attempts.saturating_add(1).min(ceiling);
81                std::future::ready(current)
82            })
83            .await;
84
85        entry.into_value().attempts <= self.max_attempts
86    }
87
88    /// Clears the attempts of `user` after a successful password check.
89    pub async fn succeeded(&self, user: &str) {
90        if let Some(attempts) = &self.attempts {
91            attempts.invalidate(&self.key(user)).await;
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    const WINDOW: Duration = Duration::from_mins(5);
101
102    async fn exhaust(throttle: &LoginThrottle, user: &str, n: u32) {
103        for _ in 0..n {
104            assert!(throttle.try_attempt(user).await);
105        }
106    }
107
108    #[tokio::test]
109    async fn allows_attempts_up_to_the_limit() {
110        let throttle = LoginThrottle::new(3, WINDOW);
111
112        exhaust(&throttle, "user", 3).await;
113
114        assert!(!throttle.try_attempt("user").await);
115        assert!(!throttle.try_attempt("user").await);
116    }
117
118    #[tokio::test]
119    async fn locking_one_user_does_not_affect_others() {
120        let throttle = LoginThrottle::new(2, WINDOW);
121
122        exhaust(&throttle, "victim", 2).await;
123        assert!(!throttle.try_attempt("victim").await);
124
125        assert!(throttle.try_attempt("other").await);
126    }
127
128    #[tokio::test]
129    async fn success_resets_only_that_user() {
130        let throttle = LoginThrottle::new(2, WINDOW);
131
132        exhaust(&throttle, "user", 2).await;
133        exhaust(&throttle, "other", 2).await;
134        throttle.succeeded("user").await;
135
136        assert!(throttle.try_attempt("user").await);
137        assert!(!throttle.try_attempt("other").await);
138    }
139
140    #[tokio::test]
141    async fn unknown_usernames_are_throttled() {
142        let throttle = LoginThrottle::new(1, WINDOW);
143
144        assert!(throttle.try_attempt("does-not-exist").await);
145        assert!(!throttle.try_attempt("does-not-exist").await);
146    }
147
148    #[tokio::test]
149    async fn lock_expires_after_window() {
150        let window = Duration::from_millis(100);
151        let throttle = LoginThrottle::new(1, window);
152
153        assert!(throttle.try_attempt("user").await);
154        assert!(!throttle.try_attempt("user").await);
155
156        tokio::time::sleep(window + Duration::from_millis(50)).await;
157
158        assert!(throttle.try_attempt("user").await);
159    }
160
161    #[tokio::test]
162    async fn zero_attempts_disables_throttling() {
163        let throttle = LoginThrottle::new(0, WINDOW);
164
165        exhaust(&throttle, "user", 100).await;
166    }
167
168    #[tokio::test]
169    async fn zero_window_disables_throttling() {
170        let throttle = LoginThrottle::new(1, Duration::ZERO);
171
172        exhaust(&throttle, "user", 100).await;
173    }
174
175    #[tokio::test]
176    async fn parallel_attempts_cannot_exceed_the_limit() {
177        let throttle = std::sync::Arc::new(LoginThrottle::new(5, WINDOW));
178
179        let handles: Vec<_> = (0..50)
180            .map(|_| {
181                let throttle = throttle.clone();
182                tokio::spawn(async move { throttle.try_attempt("user").await })
183            })
184            .collect();
185
186        let mut allowed = 0;
187        for handle in handles {
188            if handle.await.unwrap() {
189                allowed += 1;
190            }
191        }
192        assert_eq!(allowed, 5);
193    }
194}