tablo_core/auth/throttle.rs
1//! [`LoginThrottle`]: counts sign-in attempts per login and client address, and refuses one past
2//! its limit.
3
4use std::{
5 collections::HashMap,
6 hash::{BuildHasher, RandomState},
7 net::IpAddr,
8 sync::Mutex,
9 time::{Duration, Instant},
10};
11
12/// The most counts a throttle keeps at once; past it, an expired count is dropped first, else the
13/// oldest.
14const MAX_TRACKED: usize = 10_000;
15
16/// How many sign-in attempts one login may make from one client address in a window, as
17/// [`Auth::throttle`](crate::Auth::throttle) sets it.
18///
19/// Each attempt that reaches the authenticator counts against the login it names, trimmed and
20/// lowercased, and the address [`client_ip`](topcoat::router::request::client_ip) answers, from
21/// the first attempt of the window; a successful sign-in clears that count. Past the limit, the
22/// login page refuses the attempt without asking the authenticator, with the same 403 and message
23/// as a wrong password, until the window ends, and logs a warning.
24///
25/// Keying on the address keeps a guesser from locking the account's owner out from elsewhere.
26/// Behind a reverse proxy every request carries the proxy's address unless the router trusts it
27/// through [`TrustedProxies`](topcoat::router::TrustedProxies); until then the count is per login
28/// alone, and a guesser can lock the owner out.
29///
30/// It does not limit one client across logins, nor one login across many addresses, nor a login
31/// spelled several ways that the authenticator's lookup treats as one, as MySQL's default
32/// collation does with accents: a per-IP limit at the proxy covers those. Counts live in the
33/// process and per panel, so each replica and each panel counts its own attempts, and a restart
34/// clears them.
35///
36/// ```rust
37/// use std::time::Duration;
38///
39/// use tablo_core::{Auth, LoginThrottle};
40///
41/// let auth = Auth::password().throttle(LoginThrottle::new(10, Duration::from_secs(300)));
42/// # let _ = auth;
43/// ```
44pub struct LoginThrottle {
45 limit: Option<Limit>,
46 hasher: RandomState,
47 counts: Mutex<HashMap<u64, Count>>,
48}
49
50#[derive(Debug, Clone, Copy)]
51struct Limit {
52 attempts: u32,
53 window: Duration,
54}
55
56#[derive(Debug, Clone, Copy)]
57struct Count {
58 attempts: u32,
59 since: Instant,
60}
61
62impl LoginThrottle {
63 /// Allows `attempts` sign-in attempts per login in each `window`.
64 ///
65 /// # Panics
66 ///
67 /// When `attempts` is `0` or `window` is zero: neither allows a sign-in.
68 #[must_use]
69 pub fn new(attempts: u32, window: Duration) -> Self {
70 assert!(attempts > 0, "a login throttle allows at least one attempt");
71 assert!(!window.is_zero(), "a login throttle's window is not zero");
72 Self::with(Some(Limit { attempts, window }))
73 }
74
75 /// Counts nothing, for a deployment whose proxy limits sign-ins.
76 #[must_use]
77 pub fn off() -> Self {
78 Self::with(None)
79 }
80
81 fn with(limit: Option<Limit>) -> Self {
82 Self {
83 limit,
84 hasher: RandomState::new(),
85 counts: Mutex::new(HashMap::new()),
86 }
87 }
88
89 /// Counts an attempt for `login` from `client`, or answers `false` when the pair is past its
90 /// limit.
91 pub(crate) fn attempt(&self, login: &str, client: Option<IpAddr>) -> bool {
92 self.attempt_at(login, client, Instant::now())
93 }
94
95 fn attempt_at(&self, login: &str, client: Option<IpAddr>, now: Instant) -> bool {
96 let Some(limit) = self.limit else {
97 return true;
98 };
99 let key = self.key(login, client);
100 let mut counts = self
101 .counts
102 .lock()
103 .unwrap_or_else(|poison| poison.into_inner());
104 if !counts.contains_key(&key) && counts.len() >= MAX_TRACKED {
105 counts.retain(|_, count| now.duration_since(count.since) < limit.window);
106 if counts.len() >= MAX_TRACKED
107 && let Some(oldest) = counts
108 .iter()
109 .min_by_key(|(_, count)| count.since)
110 .map(|(key, _)| *key)
111 {
112 counts.remove(&oldest);
113 }
114 }
115 let count = counts.entry(key).or_insert(Count {
116 attempts: 0,
117 since: now,
118 });
119 if now.duration_since(count.since) >= limit.window {
120 *count = Count {
121 attempts: 0,
122 since: now,
123 };
124 }
125 if count.attempts >= limit.attempts {
126 return false;
127 }
128 count.attempts += 1;
129 true
130 }
131
132 /// Clears the count of `login` from `client` after it signs in there.
133 pub(crate) fn clear(&self, login: &str, client: Option<IpAddr>) {
134 if self.limit.is_some() {
135 let key = self.key(login, client);
136 self.counts
137 .lock()
138 .unwrap_or_else(|poison| poison.into_inner())
139 .remove(&key);
140 }
141 }
142
143 /// The count's key: a hash of the login and the address, so the map holds no login in the
144 /// clear.
145 fn key(&self, login: &str, client: Option<IpAddr>) -> u64 {
146 self.hasher.hash_one((login.trim().to_lowercase(), client))
147 }
148}
149
150/// Five attempts per login and address a minute.
151impl Default for LoginThrottle {
152 fn default() -> Self {
153 Self::new(5, Duration::from_secs(60))
154 }
155}
156
157impl std::fmt::Debug for LoginThrottle {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self.limit {
160 Some(Limit { attempts, window }) => f
161 .debug_struct("LoginThrottle")
162 .field("attempts", &attempts)
163 .field("window", &window)
164 .finish(),
165 None => f.write_str("LoginThrottle::off"),
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests;