kipuka/auth/
failure_tracker.rs1use std::collections::HashMap;
34use std::time::{Duration, Instant};
35
36use parking_lot::Mutex;
37
38#[derive(Debug, Clone, Copy)]
40pub struct LockoutPolicy {
41 pub max_failures: u32,
44 pub failure_window: Duration,
47 pub lockout_duration: Duration,
49}
50
51impl LockoutPolicy {
52 pub fn is_enabled(&self) -> bool {
54 self.max_failures > 0
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LockoutStatus {
61 Allowed,
63 LockedOut {
65 retry_after: Duration,
67 },
68}
69
70impl LockoutStatus {
71 pub fn is_locked(&self) -> bool {
73 matches!(self, LockoutStatus::LockedOut { .. })
74 }
75}
76
77struct Record {
79 failures: u32,
81 window_start: Instant,
83 locked_until: Option<Instant>,
85}
86
87impl Record {
88 fn is_prunable(&self, now: Instant, window: Duration) -> bool {
90 let lock_expired = self.locked_until.is_none_or(|until| now >= until);
91 let window_stale = now.duration_since(self.window_start) >= window;
92 lock_expired && window_stale
93 }
94}
95
96pub struct FailureTracker {
98 policy: LockoutPolicy,
99 records: Mutex<HashMap<String, Record>>,
100 last_prune: Mutex<Instant>,
101}
102
103impl FailureTracker {
104 pub fn new(policy: LockoutPolicy) -> Self {
106 Self {
107 policy,
108 records: Mutex::new(HashMap::new()),
109 last_prune: Mutex::new(Instant::now() - Duration::from_secs(1)),
110 }
111 }
112
113 pub fn policy(&self) -> &LockoutPolicy {
115 &self.policy
116 }
117
118 pub fn check(&self, identity: &str) -> LockoutStatus {
121 if !self.policy.is_enabled() {
122 return LockoutStatus::Allowed;
123 }
124 let now = Instant::now();
125 let mut records = self.records.lock();
126 if records.len() >= 10_000 {
127 let mut last = self.last_prune.lock();
128 if now.duration_since(*last) >= Duration::from_secs(1).min(self.policy.failure_window) {
129 self.prune(&mut records, now);
130 *last = now;
131 }
132 }
133 if identity.len() > 4096 {
134 return LockoutStatus::LockedOut {
135 retry_after: Duration::from_secs(1),
136 };
137 }
138 Self::status_at(records.get(identity), now).unwrap_or_else(|| {
139 if let Some(rec) = records.get(identity)
141 && rec.is_prunable(now, self.policy.failure_window)
142 {
143 records.remove(identity);
144 }
145 LockoutStatus::Allowed
146 })
147 }
148
149 pub fn record_failure(&self, identity: &str) -> LockoutStatus {
154 if !self.policy.is_enabled() {
155 return LockoutStatus::Allowed;
156 }
157 let now = Instant::now();
158 let mut records = self.records.lock();
159 let mut last = self.last_prune.lock();
160 if now.duration_since(*last) >= Duration::from_secs(1).min(self.policy.failure_window) {
161 self.prune(&mut records, now);
162 *last = now;
163 }
164 if identity.len() > 4096 {
165 return LockoutStatus::LockedOut {
166 retry_after: Duration::from_secs(1),
167 };
168 }
169 if records.len() >= 10_000 && !records.contains_key(identity) {
170 return LockoutStatus::Allowed;
174 }
175
176 let rec = records.entry(identity.to_string()).or_insert(Record {
177 failures: 0,
178 window_start: now,
179 locked_until: None,
180 });
181
182 if let Some(until) = rec.locked_until
184 && now < until
185 {
186 return LockoutStatus::LockedOut {
187 retry_after: until.duration_since(now),
188 };
189 }
190
191 let window_elapsed = now.duration_since(rec.window_start) >= self.policy.failure_window;
200 let lockout_expired = rec.locked_until.is_some_and(|until| now >= until);
201 if window_elapsed || lockout_expired {
202 rec.failures = 0;
203 rec.window_start = now;
204 rec.locked_until = None;
205 }
206
207 rec.failures += 1;
208
209 if rec.failures >= self.policy.max_failures {
210 let until = now + self.policy.lockout_duration;
211 rec.locked_until = Some(until);
212 LockoutStatus::LockedOut {
213 retry_after: self.policy.lockout_duration,
214 }
215 } else {
216 LockoutStatus::Allowed
217 }
218 }
219
220 pub fn record_success(&self, identity: &str) {
225 if !self.policy.is_enabled() {
226 return;
227 }
228 let now = Instant::now();
229 let mut records = self.records.lock();
230 if let Some(rec) = records.get(identity) {
231 let locked = rec.locked_until.is_some_and(|until| now < until);
232 if !locked {
233 records.remove(identity);
234 }
235 }
236 }
237
238 fn status_at(rec: Option<&Record>, now: Instant) -> Option<LockoutStatus> {
241 let rec = rec?;
242 match rec.locked_until {
243 Some(until) if now < until => Some(LockoutStatus::LockedOut {
244 retry_after: until.duration_since(now),
245 }),
246 _ => None,
247 }
248 }
249
250 fn prune(&self, records: &mut HashMap<String, Record>, now: Instant) {
252 records.retain(|_, rec| !rec.is_prunable(now, self.policy.failure_window));
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn policy(max: u32) -> LockoutPolicy {
261 LockoutPolicy {
262 max_failures: max,
263 failure_window: Duration::from_secs(60),
264 lockout_duration: Duration::from_secs(300),
265 }
266 }
267
268 #[test]
269 fn disabled_policy_never_locks() {
270 let t = FailureTracker::new(policy(0));
271 for _ in 0..100 {
272 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
273 }
274 assert_eq!(t.check("bob"), LockoutStatus::Allowed);
275 }
276
277 #[test]
278 fn locks_after_threshold() {
279 let t = FailureTracker::new(policy(3));
280 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
281 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
282 assert!(t.record_failure("bob").is_locked());
284 assert!(t.check("bob").is_locked());
286 }
287
288 #[test]
289 fn other_identities_unaffected() {
290 let t = FailureTracker::new(policy(2));
291 assert!(t.record_failure("bob").is_locked() || t.record_failure("bob").is_locked());
292 assert_eq!(t.check("alice"), LockoutStatus::Allowed);
294 assert_eq!(t.record_failure("alice"), LockoutStatus::Allowed);
295 }
296
297 #[test]
298 fn success_clears_failures_before_lockout() {
299 let t = FailureTracker::new(policy(3));
300 t.record_failure("bob");
301 t.record_failure("bob");
302 t.record_success("bob");
304 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
305 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
306 assert!(!t.check("bob").is_locked());
307 }
308
309 #[test]
310 fn success_does_not_lift_active_lockout() {
311 let t = FailureTracker::new(policy(2));
312 t.record_failure("bob");
313 assert!(t.record_failure("bob").is_locked());
314 t.record_success("bob");
316 assert!(t.check("bob").is_locked());
317 }
318
319 #[test]
320 fn window_reset_allows_slow_failures() {
321 let pol = LockoutPolicy {
323 max_failures: 2,
324 failure_window: Duration::from_millis(1),
325 lockout_duration: Duration::from_secs(300),
326 };
327 let t = FailureTracker::new(pol);
328 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
329 std::thread::sleep(Duration::from_millis(3));
330 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
332 }
333
334 #[test]
335 fn lockout_expires() {
336 let pol = LockoutPolicy {
337 max_failures: 1,
338 failure_window: Duration::from_secs(60),
339 lockout_duration: Duration::from_millis(5),
340 };
341 let t = FailureTracker::new(pol);
342 assert!(t.record_failure("bob").is_locked());
343 assert!(t.check("bob").is_locked());
344 std::thread::sleep(Duration::from_millis(8));
345 assert_eq!(t.check("bob"), LockoutStatus::Allowed);
347 }
348
349 #[test]
350 fn expired_lockout_starts_fresh_window() {
351 let pol = LockoutPolicy {
355 max_failures: 2,
356 failure_window: Duration::from_secs(60),
357 lockout_duration: Duration::from_millis(5),
358 };
359 let t = FailureTracker::new(pol);
360 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
361 assert!(t.record_failure("bob").is_locked());
362 std::thread::sleep(Duration::from_millis(8));
363 assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
365 }
366
367 #[test]
368 fn prunes_stale_records() {
369 let pol = LockoutPolicy {
370 max_failures: 5,
371 failure_window: Duration::from_millis(1),
372 lockout_duration: Duration::from_secs(300),
373 };
374 let t = FailureTracker::new(pol);
375 t.record_failure("ephemeral");
376 std::thread::sleep(Duration::from_millis(3));
377 t.record_failure("other");
379 assert!(!t.records.lock().contains_key("ephemeral"));
380 }
381}
382
383#[cfg(test)]
384mod capacity_tests {
385 use super::*;
386
387 #[test]
388 fn saturation_preserves_access_and_existing_targeted_lockouts() {
389 let tracker = FailureTracker::new(LockoutPolicy {
390 max_failures: 2,
391 failure_window: Duration::from_secs(300),
392 lockout_duration: Duration::from_secs(900),
393 });
394 tracker.record_failure("target");
395 for i in 1..10_000 {
396 tracker.record_failure(&format!("invented-{i}"));
397 }
398 assert_eq!(tracker.check("valid-user"), LockoutStatus::Allowed);
399 assert_eq!(tracker.record_failure("overflow"), LockoutStatus::Allowed);
400 assert_eq!(tracker.records.lock().len(), 10_000);
401 assert!(tracker.record_failure("target").is_locked());
402 assert!(tracker.check("target").is_locked());
403 tracker.record_success("target");
404 assert!(tracker.check("target").is_locked());
405 tracker.record_success("invented-1");
407 tracker.record_failure("overflow");
408 assert!(tracker.record_failure("overflow").is_locked());
409 assert_eq!(tracker.records.lock().len(), 10_000);
410 }
411}