Skip to main content

umbral_auth/
throttle.rs

1//! Login / register / email-action rate-limiting — credential-stuffing & brute-force defense.
2//!
3//! Today's login and register handlers have NO throttle, so a script can
4//! pound `<prefix>/login` with a leaked credential list or mass-create
5//! accounts at `<prefix>/register` unimpeded. This module adds a
6//! **secure-by-default** sliding-window limiter that both handlers consult
7//! at entry, returning **429 Too Many Requests** before any DB work when a
8//! caller exceeds the budget.
9//!
10//! ## The window
11//!
12//! A [`Throttle`] keeps, per key, the timestamps of recent attempts. On each
13//! [`Throttle::check`] it prunes timestamps older than `window`, and if the
14//! surviving count is already `>= max` it returns `false` (deny). Otherwise
15//! it records `now` and returns `true` (allow). [`Throttle::clear`] drops a
16//! key's history entirely — the login handler calls it on a SUCCESSFUL login
17//! so a legitimate user who fat-fingered their password once isn't locked
18//! out by the failures that preceded the success.
19//!
20//! The sliding-window mechanics are NOT implemented here: [`Throttle`] is a
21//! thin wrapper over the core [`umbral::ratelimit::RateLimiter`] primitive,
22//! which owns the single per-key timestamp store in the tree. This module
23//! contributes the auth-specific policy on top: the secure-default budgets,
24//! the IP+username keying, the `enabled` master switch, and the ambient
25//! install. (Consolidated from a former hand-rolled copy — gaps2 #90.)
26//!
27//! Keys are caller-chosen strings:
28//! - **login** keys on `ip + "\0" + username` so one attacker IP can't lock
29//!   out every account, and one targeted account can't be brute-forced from
30//!   one IP. (5 attempts / 5 min by default.)
31//! - **register** keys on `ip` alone — it defends mass account creation, and
32//!   there's no username yet. (10 / hour by default.)
33//! - **email_action** keys on `ip + "\0" + email` — covers verify-email,
34//!   resend-verification, and password-forgot. A common email can't be
35//!   bombed from one IP. (5 / hour by default.)
36//!
37//! ## Injectable clock
38//!
39//! [`Throttle::check`] / [`Throttle::clear`] use `Instant::now()`. The
40//! private core ([`Throttle::check_at`] / [`Throttle::clear_at`]) takes the
41//! `now: Instant` explicitly so tests can advance time deterministically
42//! without `sleep`.
43//!
44//! ## Ambient install
45//!
46//! The active config + stores live in a process-wide `OnceLock`
47//! ([`AUTH_THROTTLE`]), installed once from
48//! [`crate::AuthPlugin::on_ready`] — the same ambient pattern as
49//! `PASSWORD_POLICY`. The route handlers are free functions with no handle
50//! to the `AuthPlugin`, so they reach the limiter via the free helpers
51//! [`login_throttle_check`], [`login_throttle_clear`], and
52//! [`register_throttle_check`], each of which falls back to the secure
53//! default config when nothing has been installed yet (so the helpers are
54//! enforced even before `on_ready` runs and in unit tests).
55//!
56//! ## Scope: in-memory, single-instance
57//!
58//! The store is a process-local `HashMap`. In a multi-instance deployment
59//! each replica counts independently, so the effective budget is
60//! `max * replicas`. That's still a meaningful brake on a single attacker
61//! pinned to one replica by a sticky LB, but a multi-instance app that wants
62//! a hard global limit should front it with a shared limiter (a future
63//! Redis-backed `Throttle`). Logged as a known limitation in the auth docs.
64
65use std::sync::OnceLock;
66use std::time::{Duration, Instant};
67
68use umbral::ratelimit::{Rate, RateLimiter};
69
70/// A sliding-window counter keyed by an arbitrary string.
71///
72/// `max` attempts are permitted within any trailing `window`. This is a thin
73/// wrapper over the core [`RateLimiter`] primitive — it holds one and adapts
74/// its rich [`umbral::ratelimit::RateDecision`] down to the `bool` (allow /
75/// deny) the auth handlers need, plus the clock-injectable `*_at` variants the
76/// tests drive. All the per-key timestamp bookkeeping lives in `RateLimiter`;
77/// this type adds no sliding-window logic of its own.
78#[derive(Debug)]
79pub struct Throttle {
80    inner: RateLimiter,
81}
82
83impl Throttle {
84    /// Build a limiter allowing `max` attempts per trailing `window`.
85    ///
86    /// `max == 0` is treated as "deny everything" (a hard lock); any other
87    /// `max` permits up to `max` attempts in the trailing `window`. (Core
88    /// `RateLimiter` denies when `count < num` is false, so `num == 0` denies
89    /// the very first attempt — the same hard-lock semantics.)
90    pub fn new(max: usize, window: Duration) -> Self {
91        Self {
92            inner: RateLimiter::new(Rate::new(max as u32, window)),
93        }
94    }
95
96    /// Record an attempt for `key` and report whether it's allowed.
97    ///
98    /// Uses the real wall clock. Prunes anything older than `window`,
99    /// denies (`false`) when `>= max` remain in-window, otherwise records
100    /// `now` and allows (`true`).
101    pub fn check(&self, key: &str) -> bool {
102        self.inner.check(key).allowed
103    }
104
105    /// Forget every recorded attempt for `key`. Called on a successful
106    /// login so prior failures don't count against a now-authenticated user.
107    pub fn clear(&self, key: &str) {
108        self.inner.clear(key);
109    }
110
111    /// Clock-injectable core of [`check`](Self::check). A `now` of the
112    /// caller's choosing lets a test advance time without sleeping.
113    pub fn check_at(&self, key: &str, now: Instant) -> bool {
114        self.inner.check_at(key, now).allowed
115    }
116
117    /// Clock-injectable core of [`clear`](Self::clear). No `now` needed —
118    /// clearing is unconditional — but named `_at` for symmetry with
119    /// [`check_at`](Self::check_at).
120    pub fn clear_at(&self, key: &str) {
121        self.inner.clear(key);
122    }
123}
124
125// =========================================================================
126// Config + ambient install
127// =========================================================================
128
129/// The throttle configuration installed at boot.
130///
131/// Secure defaults (see [`ThrottleConfig::default`]): login 5 attempts /
132/// 5 min keyed per IP+username, register 10 / hour keyed per IP, email
133/// actions (verify-email, resend-verification, password-forgot) 5 / hour
134/// keyed per IP+email. `enabled` defaults to `true` — throttling is ON
135/// unless an app opts out via [`crate::AuthPlugin::disable_throttle`].
136#[derive(Debug, Clone, Copy)]
137pub struct ThrottleConfig {
138    /// Max login attempts per IP+username inside `login_window`.
139    pub login_max: usize,
140    /// Sliding window for login attempts.
141    pub login_window: Duration,
142    /// Max register attempts per IP inside `register_window`.
143    pub register_max: usize,
144    /// Sliding window for register attempts.
145    pub register_window: Duration,
146    /// Max email-action attempts per IP+email inside `email_action_window`.
147    /// Covers verify-email, resend-verification, and password-forgot.
148    pub email_action_max: usize,
149    /// Sliding window for email-action attempts.
150    pub email_action_window: Duration,
151    /// Master switch. `false` makes every `*_check` allow unconditionally.
152    pub enabled: bool,
153}
154
155impl Default for ThrottleConfig {
156    fn default() -> Self {
157        Self {
158            login_max: 5,
159            login_window: Duration::from_secs(5 * 60),
160            register_max: 10,
161            register_window: Duration::from_secs(60 * 60),
162            email_action_max: 5,
163            email_action_window: Duration::from_secs(60 * 60),
164            enabled: true,
165        }
166    }
167}
168
169/// The live limiter: the config plus the three backing stores.
170#[derive(Debug)]
171pub struct AuthThrottle {
172    config: ThrottleConfig,
173    login: Throttle,
174    register: Throttle,
175    email_action: Throttle,
176}
177
178impl AuthThrottle {
179    /// Build the live limiter from a config, sizing each store's `max` /
180    /// `window` from the matching config fields.
181    pub fn from_config(config: ThrottleConfig) -> Self {
182        Self {
183            login: Throttle::new(config.login_max, config.login_window),
184            register: Throttle::new(config.register_max, config.register_window),
185            email_action: Throttle::new(config.email_action_max, config.email_action_window),
186            config,
187        }
188    }
189}
190
191/// Process-wide installed limiter. Set once from `AuthPlugin::on_ready`,
192/// mirroring `password_validation::PASSWORD_POLICY`.
193static AUTH_THROTTLE: OnceLock<AuthThrottle> = OnceLock::new();
194
195/// Install the limiter at boot. Idempotent — first install wins, matching
196/// the ambient-pool / password-policy contract.
197pub(crate) fn install(throttle: AuthThrottle) {
198    let _ = AUTH_THROTTLE.set(throttle);
199}
200
201/// Resolve the active limiter, building the secure default on first use when
202/// nothing has been installed yet. This keeps the free helpers enforced even
203/// before `on_ready` runs and in unit tests that call them without a boot.
204///
205/// The lazy default lives in a separate `OnceLock` so it doesn't seed
206/// `AUTH_THROTTLE` — an explicit `on_ready` install must still win if it
207/// happens after the first fallback read.
208fn active() -> &'static AuthThrottle {
209    if let Some(t) = AUTH_THROTTLE.get() {
210        return t;
211    }
212    static FALLBACK: OnceLock<AuthThrottle> = OnceLock::new();
213    FALLBACK.get_or_init(|| AuthThrottle::from_config(ThrottleConfig::default()))
214}
215
216/// Build the login key from IP + username. `\0` is the separator because it
217/// can't appear in an IP or a username, so two distinct (ip, username) pairs
218/// never collide into one key.
219fn login_key(ip: &str, username: &str) -> String {
220    format!("{ip}\0{username}")
221}
222
223/// Record + check a login attempt for `(ip, username)`. Returns `true` if
224/// the attempt is allowed, `false` if the IP+username has exhausted its
225/// budget (the handler then returns 429). A disabled config always allows.
226pub fn login_throttle_check(ip: &str, username: &str) -> bool {
227    let t = active();
228    if !t.config.enabled {
229        return true;
230    }
231    t.login.check(&login_key(ip, username))
232}
233
234/// Forgive the login counter for `(ip, username)` — called after a
235/// SUCCESSFUL login so a legit user's earlier typos don't lock them out.
236pub fn login_throttle_clear(ip: &str, username: &str) {
237    active().login.clear(&login_key(ip, username));
238}
239
240/// Record + check a register attempt for `ip`. Returns `true` if allowed,
241/// `false` once the IP has exhausted its register budget. A disabled config
242/// always allows.
243pub fn register_throttle_check(ip: &str) -> bool {
244    let t = active();
245    if !t.config.enabled {
246        return true;
247    }
248    t.register.check(ip)
249}
250
251/// Build the email-action key from IP + email. `\0` separator prevents a
252/// longer IP colliding with a shorter IP + email prefix.
253fn email_action_key(ip: &str, email: &str) -> String {
254    format!("{ip}\0{email}")
255}
256
257/// Record + check an email-action attempt for `(ip, email)`. Returns `true`
258/// if allowed, `false` once the IP+email pair has exhausted its budget (the
259/// handler returns 429). Covers verify-email, resend-verification, and
260/// password-forgot. A disabled config always allows.
261pub fn email_action_throttle_check(ip: &str, email: &str) -> bool {
262    let t = active();
263    if !t.config.enabled {
264        return true;
265    }
266    t.email_action.check(&email_action_key(ip, email))
267}
268
269// =========================================================================
270// Unit tests — deterministic via the injected clock.
271// =========================================================================
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn third_attempt_in_window_is_denied() {
279        let t = Throttle::new(2, Duration::from_secs(60));
280        let now = Instant::now();
281        assert!(t.check_at("k", now));
282        assert!(t.check_at("k", now));
283        // Third in-window → over budget.
284        assert!(!t.check_at("k", now));
285    }
286
287    #[test]
288    fn different_keys_are_independent() {
289        let t = Throttle::new(1, Duration::from_secs(60));
290        let now = Instant::now();
291        assert!(t.check_at("a", now));
292        // "a" is now exhausted, but "b" has its own budget.
293        assert!(!t.check_at("a", now));
294        assert!(t.check_at("b", now));
295    }
296
297    #[test]
298    fn window_elapse_re_allows() {
299        let t = Throttle::new(1, Duration::from_secs(60));
300        let now = Instant::now();
301        assert!(t.check_at("k", now));
302        assert!(!t.check_at("k", now));
303        // Advance past the window: the old hit ages out.
304        let later = now + Duration::from_secs(61);
305        assert!(t.check_at("k", later));
306    }
307
308    #[test]
309    fn clear_resets_a_key() {
310        let t = Throttle::new(1, Duration::from_secs(60));
311        let now = Instant::now();
312        assert!(t.check_at("k", now));
313        assert!(!t.check_at("k", now));
314        t.clear_at("k");
315        assert!(t.check_at("k", now));
316    }
317
318    #[test]
319    fn max_zero_denies_everything() {
320        let t = Throttle::new(0, Duration::from_secs(60));
321        assert!(!t.check_at("k", Instant::now()));
322    }
323
324    #[test]
325    fn disabled_config_gate_short_circuits() {
326        // The `enabled = false` gate the free helpers apply (see
327        // `login_throttle_check` / `register_throttle_check`) short-circuits
328        // BEFORE the store, so even a max-1 limiter never denies when disabled.
329        // We assert the gate predicate the helpers use, exercised here without
330        // touching the process-wide ambient `OnceLock` (which a sibling test
331        // may have installed). The store itself, by contrast, WOULD deny:
332        let cfg = ThrottleConfig {
333            login_max: 1,
334            enabled: false,
335            ..ThrottleConfig::default()
336        };
337        let store = AuthThrottle::from_config(cfg);
338        let now = Instant::now();
339        assert!(store.login.check_at("k", now)); // 1st allowed
340        assert!(!store.login.check_at("k", now)); // 2nd denied by the store
341        // ...but the free-helper gate skips the store entirely when disabled:
342        assert!(!cfg.enabled, "gate is open when enabled == false");
343    }
344}