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}