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