1
//! Bounded sliding-window throttles for authentication attempts.
2
//!
3
//! Pure state plus an injected clock, so tests drive the time axis instead of
4
//! sleeping. Deliberately in-process: a shared backend belongs behind
5
//! [`Bucket`]'s interface, not woven through its callers, and until one exists
6
//! the effective limit is `replicas × limit` — which is why the surfaces using
7
//! this run single-replica.
8

            
9
use std::collections::HashMap;
10
use std::hash::Hash;
11
use std::time::{Duration, Instant};
12

            
13
#[cfg(test)]
14
mod tests;
15

            
16
/// "What time is it now", injected so tests need not sleep.
17
pub trait Clock {
18
    fn now(&self) -> Instant;
19
}
20

            
21
/// Delegates to [`Instant::now`].
22
#[derive(Debug, Default, Clone, Copy)]
23
pub struct SystemClock;
24

            
25
impl Clock for SystemClock {
26
38682
    fn now(&self) -> Instant {
27
38682
        Instant::now()
28
38682
    }
29
}
30

            
31
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32
pub enum Decision {
33
    Allow,
34
    Deny,
35
}
36

            
37
impl Decision {
38
    #[must_use]
39
19712
    pub const fn is_denied(self) -> bool {
40
19712
        matches!(self, Self::Deny)
41
19712
    }
42
}
43

            
44
/// Throttle shape: how many attempts, over what window, across how many keys.
45
#[derive(Debug, Clone, Copy)]
46
pub struct Limits {
47
    /// Attempts one key may make inside `window` before being refused.
48
    pub max_attempts: u32,
49
    /// Width of the sliding window, measured from each attempt.
50
    pub window: Duration,
51
    /// Distinct keys held at once. Bounding this is not an optimization: the
52
    /// key is derived from the peer, so an unbounded map is a memory DoS
53
    /// reachable by anyone who can open connections.
54
    pub capacity: usize,
55
}
56

            
57
struct Entry {
58
    attempts: Vec<Instant>,
59
    last_seen: Instant,
60
}
61

            
62
/// A sliding-window counter per key, holding at most [`Limits::capacity`] keys.
63
pub struct Bucket<K, C = SystemClock> {
64
    entries: HashMap<K, Entry>,
65
    limits: Limits,
66
    clock: C,
67
}
68

            
69
impl<K: Eq + Hash + Clone> Bucket<K, SystemClock> {
70
    #[must_use]
71
114
    pub fn new(limits: Limits) -> Self {
72
114
        Self::with_clock(limits, SystemClock)
73
114
    }
74
}
75

            
76
impl<K: Eq + Hash + Clone, C: Clock> Bucket<K, C> {
77
121
    pub fn with_clock(limits: Limits, clock: C) -> Self {
78
121
        Self {
79
121
            entries: HashMap::new(),
80
121
            limits,
81
121
            clock,
82
121
        }
83
121
    }
84

            
85
    /// Records an attempt for `key` and says whether it may proceed.
86
7859
    pub fn check(&mut self, key: &K) -> Decision {
87
7859
        let now = self.clock.now();
88
7859
        let window = self.limits.window;
89

            
90
7859
        if !self.entries.contains_key(key) {
91
789
            self.make_room(now);
92
7171
        }
93

            
94
7859
        let entry = self.entries.entry(key.clone()).or_insert_with(|| Entry {
95
789
            attempts: Vec::new(),
96
789
            last_seen: now,
97
789
        });
98
7859
        entry.last_seen = now;
99
1548425
        entry.attempts.retain(|t| now.duration_since(*t) < window);
100

            
101
7859
        let seen = u32::try_from(entry.attempts.len()).unwrap_or(u32::MAX);
102
7859
        if seen >= self.limits.max_attempts {
103
1232
            return Decision::Deny;
104
6627
        }
105

            
106
6627
        entry.attempts.push(now);
107
6627
        Decision::Allow
108
7859
    }
109

            
110
    /// Frees a slot for a newcomer: drops keys whose window has fully elapsed,
111
    /// then, if that was not enough, the least-recently-seen key.
112
    ///
113
    /// Evicting rather than refusing is deliberate. Refusing when full would
114
    /// let anyone who can fill the map deny service to every other client — a
115
    /// far cheaper attack than the credential stuffing this exists to stop.
116
789
    fn make_room(&mut self, now: Instant) {
117
789
        if self.entries.len() < self.limits.capacity {
118
691
            return;
119
98
        }
120

            
121
98
        let window = self.limits.window;
122
98
        self.entries
123
388
            .retain(|_, e| now.duration_since(e.last_seen) < window);
124

            
125
98
        if self.entries.len() < self.limits.capacity {
126
1
            return;
127
97
        }
128

            
129
97
        if let Some(stale) = self
130
97
            .entries
131
97
            .iter()
132
97
            .min_by_key(|(_, e)| e.last_seen)
133
97
            .map(|(k, _)| k.clone())
134
97
        {
135
97
            self.entries.remove(&stale);
136
97
        }
137
789
    }
138

            
139
    /// Keys currently tracked. Exposed for metrics and tests.
140
    #[must_use]
141
3
    pub fn tracked(&self) -> usize {
142
3
        self.entries.len()
143
3
    }
144
}