tablo_core/auth/throttle.rs
1//! [`LoginThrottle`]: counts sign-in attempts per login and client address, and refuses one past
2//! its limit.
3
4mod shared;
5
6use std::{
7 collections::HashMap,
8 hash::{BuildHasher, RandomState},
9 net::IpAddr,
10 sync::{
11 Mutex,
12 atomic::{AtomicBool, Ordering},
13 },
14 time::{Duration, Instant},
15};
16
17use topcoat::context::Cx;
18
19pub use self::shared::AuthThrottle;
20
21/// The most counts a throttle keeps at once; past it, an expired count is dropped first, else the
22/// oldest under its limit, and the oldest refusing one only when every count refuses: a flood of
23/// fresh logins cannot release a refused one.
24const MAX_TRACKED: usize = 10_000;
25
26/// How many sign-in attempts one login may make from one client address in a window, as
27/// [`Auth::throttle`](crate::Auth::throttle) sets it.
28///
29/// Each attempt that reaches the authenticator counts against the login it names, trimmed and
30/// lowercased, and the address [`client_ip`](topcoat::router::request::client_ip) answers, from
31/// the first attempt of the window; a successful sign-in clears that count. Past the limit, the
32/// login page refuses the attempt without asking the authenticator, with the same 403 and message
33/// as a wrong password, until the window ends, and logs a warning.
34///
35/// Keying on the address keeps a guesser from locking the account's owner out from elsewhere.
36/// Behind a reverse proxy every request carries the proxy's address unless the router trusts it
37/// through [`TrustedProxies`](topcoat::router::TrustedProxies); until then the count is per login
38/// alone, and a guesser can lock the owner out.
39///
40/// [`Auth::account_throttle`](crate::Auth::account_throttle) adds an opt-in, looser count per login
41/// across addresses. The first sign-in that carries `X-Forwarded-For` or `Forwarded` and whose
42/// client address is still the connection's logs a warning: either the router trusts no proxy, or
43/// the request bypassed the trusted ones.
44///
45/// It does not limit one client across logins, nor a login
46/// spelled several ways that the authenticator's lookup treats as one, as MySQL's default
47/// collation does with accents: a per-IP limit at the proxy covers those. Counts live in the
48/// process and per panel, so each replica and each panel counts its own attempts, and a restart
49/// clears them; [`in_database`](Self::in_database) keeps them where every replica reads them.
50///
51/// ```rust
52/// use std::time::Duration;
53///
54/// use tablo_core::{Auth, LoginThrottle};
55///
56/// let auth = Auth::password().throttle(LoginThrottle::new(10, Duration::from_secs(300)));
57/// # let _ = auth;
58/// ```
59pub struct LoginThrottle {
60 limit: Option<Limit>,
61 hasher: RandomState,
62 counts: Mutex<HashMap<u64, Count>>,
63 in_database: bool,
64}
65
66#[derive(Debug, Clone, Copy)]
67struct Limit {
68 attempts: u32,
69 window: Duration,
70}
71
72#[derive(Debug, Clone, Copy)]
73struct Count {
74 attempts: u32,
75 since: Instant,
76}
77
78impl LoginThrottle {
79 /// Allows `attempts` sign-in attempts per login in each `window`.
80 ///
81 /// # Panics
82 ///
83 /// When `attempts` is `0` or `window` is zero: neither allows a sign-in.
84 #[must_use]
85 pub fn new(attempts: u32, window: Duration) -> Self {
86 assert!(attempts > 0, "a login throttle allows at least one attempt");
87 assert!(!window.is_zero(), "a login throttle's window is not zero");
88 Self::with(Some(Limit { attempts, window }))
89 }
90
91 /// Counts nothing, for a deployment whose proxy limits sign-ins.
92 #[must_use]
93 pub fn off() -> Self {
94 Self::with(None)
95 }
96
97 /// Keeps the counts in the database, as [`AuthThrottle`] rows, rather than in the process:
98 /// every replica then counts against the same limit, and a restart forgets nothing. Each
99 /// attempt costs a serializable transaction, and mounting requires the model, which
100 /// [`models!`](crate::models!) registers.
101 ///
102 /// ```rust
103 /// use std::time::Duration;
104 ///
105 /// use tablo_core::{Auth, LoginThrottle};
106 ///
107 /// let auth =
108 /// Auth::password().throttle(LoginThrottle::new(10, Duration::from_secs(300)).in_database());
109 /// # let _ = auth;
110 /// ```
111 #[must_use]
112 pub fn in_database(mut self) -> Self {
113 self.in_database = true;
114 self
115 }
116
117 fn with(limit: Option<Limit>) -> Self {
118 Self {
119 limit,
120 hasher: RandomState::new(),
121 counts: Mutex::new(HashMap::new()),
122 in_database: false,
123 }
124 }
125
126 /// Whether the throttle counts in the database, which then needs [`AuthThrottle`].
127 pub(crate) fn counts_in_database(&self) -> bool {
128 self.limit.is_some() && self.in_database
129 }
130
131 /// Counts an attempt for `login` from `client`, or answers `false` when the pair is past its
132 /// limit. `scope` names what the throttle counts, so two throttles keep apart in the database.
133 pub(crate) async fn attempt(
134 &self,
135 cx: &Cx,
136 scope: &str,
137 login: &str,
138 client: Option<IpAddr>,
139 ) -> topcoat::Result<bool> {
140 match self.limit {
141 Some(limit) if self.in_database => {
142 shared::attempt(cx, limit, &shared::Slot::of(cx, scope, login, client)).await
143 }
144 _ => Ok(self.attempt_at(login, client, Instant::now())),
145 }
146 }
147
148 /// The window's length in whole seconds, rounded up: how long a refused client waits at
149 /// most. Zero for a throttle that counts nothing.
150 pub(crate) fn window_secs(&self) -> u64 {
151 self.limit.map_or(0, |limit| {
152 limit.window.as_secs() + u64::from(limit.window.subsec_nanos() > 0)
153 })
154 }
155
156 fn attempt_at(&self, login: &str, client: Option<IpAddr>, now: Instant) -> bool {
157 let Some(limit) = self.limit else {
158 return true;
159 };
160 let key = self.key(login, client);
161 let mut counts = self
162 .counts
163 .lock()
164 .unwrap_or_else(|poison| poison.into_inner());
165 if !counts.contains_key(&key) && counts.len() >= MAX_TRACKED {
166 counts.retain(|_, count| now.duration_since(count.since) < limit.window);
167 if counts.len() >= MAX_TRACKED
168 && let Some(oldest) = counts
169 .iter()
170 .min_by_key(|(_, count)| (count.attempts >= limit.attempts, count.since))
171 .map(|(key, _)| *key)
172 {
173 counts.remove(&oldest);
174 }
175 }
176 let count = counts.entry(key).or_insert(Count {
177 attempts: 0,
178 since: now,
179 });
180 if now.duration_since(count.since) >= limit.window {
181 *count = Count {
182 attempts: 0,
183 since: now,
184 };
185 }
186 if count.attempts >= limit.attempts {
187 return false;
188 }
189 count.attempts += 1;
190 true
191 }
192
193 /// Clears the count of `login` from `client` after it signs in there.
194 pub(crate) async fn clear(
195 &self,
196 cx: &Cx,
197 scope: &str,
198 login: &str,
199 client: Option<IpAddr>,
200 ) -> topcoat::Result<()> {
201 if self.counts_in_database() {
202 return shared::clear(cx, &shared::Slot::of(cx, scope, login, client)).await;
203 }
204 self.clear_here(login, client);
205 Ok(())
206 }
207
208 /// Clears the count of `login` from `client` in the process.
209 fn clear_here(&self, login: &str, client: Option<IpAddr>) {
210 if self.limit.is_some() {
211 let key = self.key(login, client);
212 self.counts
213 .lock()
214 .unwrap_or_else(|poison| poison.into_inner())
215 .remove(&key);
216 }
217 }
218
219 /// The count's key: a hash of the login and the address, so the map holds no login in the
220 /// clear.
221 fn key(&self, login: &str, client: Option<IpAddr>) -> u64 {
222 self.hasher.hash_one((login.trim().to_lowercase(), client))
223 }
224}
225
226/// Logs one warning per process when a sign-in carries a forwarding header the router did not
227/// read: the throttle then keys on the proxy's address, which every client shares.
228pub(crate) fn warn_unforwarded(cx: &topcoat::context::Cx) {
229 static WARNED: AtomicBool = AtomicBool::new(false);
230 if WARNED.load(Ordering::Relaxed) {
231 return;
232 }
233 let headers = topcoat::router::request::headers(cx);
234 let forwarded = headers.contains_key("x-forwarded-for") || headers.contains_key("forwarded");
235 let peer = topcoat::router::request::remote_addr(cx).map(|addr| addr.ip());
236 if forwarded
237 && peer.is_some()
238 && topcoat::router::request::client_ip(cx) == peer
239 && !WARNED.swap(true, Ordering::Relaxed)
240 {
241 tracing::warn!(
242 peer = ?peer,
243 "a sign-in carries a forwarding header the router ignores; register \
244 `TrustedProxies` so sign-in throttles count per client, not per proxy"
245 );
246 }
247}
248
249/// Five attempts per login and address a minute.
250impl Default for LoginThrottle {
251 fn default() -> Self {
252 Self::new(5, Duration::from_secs(60))
253 }
254}
255
256impl std::fmt::Debug for LoginThrottle {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 match self.limit {
259 Some(Limit { attempts, window }) => f
260 .debug_struct("LoginThrottle")
261 .field("attempts", &attempts)
262 .field("window", &window)
263 .field("in_database", &self.in_database)
264 .finish(),
265 None => f.write_str("LoginThrottle::off"),
266 }
267 }
268}
269
270#[cfg(test)]
271mod tests;