kellnr_http/
login_throttle.rs1use std::hash::{BuildHasher, RandomState};
9use std::time::{Duration, Instant};
10
11use moka::future::Cache;
12
13const 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 hasher: RandomState,
32}
33
34impl LoginThrottle {
35 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 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 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 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}