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