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::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;