Skip to main content

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;