1use lazy_static::lazy_static;
29use once_cell::sync::OnceCell;
30use std::collections::HashMap;
31use std::sync::RwLock;
32
33#[derive(Clone, Debug)]
35pub struct Limit {
36 pub max_attempts: u32,
37 pub window_secs: i64,
38}
39
40impl Limit {
41 pub const fn new(max_attempts: u32, window_secs: i64) -> Self {
42 Self {
43 max_attempts,
44 window_secs,
45 }
46 }
47}
48
49pub const DEFAULT_LOGIN_LIMIT: Limit = Limit::new(10, 900);
51pub const DEFAULT_MFA_LIMIT: Limit = Limit::new(5, 900);
54
55const MAX_TRACKED_KEYS: usize = 10_000;
58
59#[derive(Clone, Debug)]
62pub struct RateLimitConfig {
63 pub login: Option<Limit>,
65 pub mfa: Option<Limit>,
68}
69
70impl Default for RateLimitConfig {
71 fn default() -> Self {
72 Self {
73 login: Some(DEFAULT_LOGIN_LIMIT),
74 mfa: Some(DEFAULT_MFA_LIMIT),
75 }
76 }
77}
78
79static CONFIG: OnceCell<RateLimitConfig> = OnceCell::new();
80
81pub fn configure(config: RateLimitConfig) {
84 if CONFIG.set(config).is_err() {
85 tracing::warn!("adminx rate limits already configured; ignoring reconfigure");
86 }
87}
88
89fn config() -> &'static RateLimitConfig {
90 CONFIG.get_or_init(RateLimitConfig::default)
91}
92
93pub fn login_limit() -> Option<&'static Limit> {
95 config().login.as_ref()
96}
97
98pub fn mfa_limit() -> Option<&'static Limit> {
100 config().mfa.as_ref()
101}
102
103#[derive(Clone, Debug)]
104struct Window {
105 count: u32,
106 expires_at: i64,
108}
109
110lazy_static! {
111 static ref FAILURES: RwLock<HashMap<String, Window>> = RwLock::new(HashMap::new());
112}
113
114fn now_secs() -> i64 {
115 std::time::SystemTime::now()
116 .duration_since(std::time::UNIX_EPOCH)
117 .map(|d| d.as_secs() as i64)
118 .unwrap_or(0)
119}
120
121fn is_limited_at(key: &str, limit: &Limit, now: i64) -> bool {
124 match FAILURES.read().unwrap_or_else(|e| e.into_inner()).get(key) {
125 Some(w) if w.expires_at > now => w.count >= limit.max_attempts,
126 _ => false,
127 }
128}
129
130fn record_failure_at(key: &str, limit: &Limit, now: i64) -> u32 {
134 let mut map = FAILURES.write().unwrap_or_else(|e| e.into_inner());
135
136 if map.len() >= MAX_TRACKED_KEYS {
137 map.retain(|_, w| w.expires_at > now);
138 }
139
140 let entry = map.entry(key.to_string()).or_insert(Window {
141 count: 0,
142 expires_at: now + limit.window_secs,
143 });
144
145 if entry.expires_at <= now {
147 entry.count = 0;
148 entry.expires_at = now + limit.window_secs;
149 }
150 entry.count += 1;
151 entry.count
152}
153
154pub fn is_limited(key: &str, limit: &Limit) -> bool {
156 is_limited_at(key, limit, now_secs())
157}
158
159pub fn record_failure(key: &str, limit: &Limit) {
161 record_failure_at(key, limit, now_secs());
162}
163
164pub fn reset(key: &str) {
167 FAILURES.write().unwrap_or_else(|e| e.into_inner()).remove(key);
168}
169
170#[doc(hidden)]
173pub fn clear_all() {
174 FAILURES.write().unwrap_or_else(|e| e.into_inner()).clear();
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use std::sync::{Mutex, MutexGuard};
181
182 const T0: i64 = 1_000_000;
185
186 lazy_static! {
187 static ref TEST_LOCK: Mutex<()> = Mutex::new(());
188 }
189
190 fn isolated() -> MutexGuard<'static, ()> {
194 let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
197 clear_all();
198 guard
199 }
200
201 #[test]
202 fn throttles_only_after_the_limit_is_reached() {
203 let _g = isolated();
204 let limit = Limit::new(3, 60);
205 let key = "throttles-only-after";
206
207 assert!(!is_limited_at(key, &limit, T0), "clean key is not limited");
208 assert_eq!(record_failure_at(key, &limit, T0), 1);
209 assert_eq!(record_failure_at(key, &limit, T0), 2);
210 assert!(!is_limited_at(key, &limit, T0), "under the limit, still allowed");
211
212 assert_eq!(record_failure_at(key, &limit, T0), 3);
213 assert!(is_limited_at(key, &limit, T0), "at the limit, throttled");
214 }
215
216 #[test]
217 fn window_lapses_and_the_count_starts_over() {
218 let _g = isolated();
219 let limit = Limit::new(2, 60);
220 let key = "window-lapses";
221
222 record_failure_at(key, &limit, T0);
223 record_failure_at(key, &limit, T0);
224 assert!(is_limited_at(key, &limit, T0));
225
226 assert!(is_limited_at(key, &limit, T0 + 59));
228 assert!(!is_limited_at(key, &limit, T0 + 61));
230 assert_eq!(
231 record_failure_at(key, &limit, T0 + 61),
232 1,
233 "a lapsed window restarts at one rather than accumulating"
234 );
235 }
236
237 #[test]
238 fn window_is_anchored_at_the_first_failure() {
239 let _g = isolated();
240 let limit = Limit::new(2, 60);
241 let key = "anchored";
242
243 record_failure_at(key, &limit, T0);
244 record_failure_at(key, &limit, T0 + 50);
247 assert!(is_limited_at(key, &limit, T0 + 50));
248 assert!(
249 !is_limited_at(key, &limit, T0 + 61),
250 "expiry stays anchored to the first failure"
251 );
252 }
253
254 #[test]
255 fn success_clears_the_count() {
256 let _g = isolated();
257 let limit = Limit::new(2, 60);
258 let key = "success-clears";
259
260 record_failure_at(key, &limit, T0);
261 record_failure_at(key, &limit, T0);
262 assert!(is_limited_at(key, &limit, T0));
263
264 reset(key);
265 assert!(!is_limited_at(key, &limit, T0), "reset lifts the throttle");
266 }
267
268 #[test]
269 fn keys_are_tracked_independently() {
270 let _g = isolated();
271 let limit = Limit::new(1, 60);
272 record_failure_at("alice", &limit, T0);
273 assert!(is_limited_at("alice", &limit, T0));
274 assert!(
275 !is_limited_at("bob", &limit, T0),
276 "one account's failures must not throttle another"
277 );
278 }
279
280 #[test]
281 fn expired_entries_are_pruned_under_pressure() {
282 let _g = isolated();
283 let limit = Limit::new(1, 60);
284
285 for i in 0..MAX_TRACKED_KEYS {
287 record_failure_at(&format!("key-{i}"), &limit, T0);
288 }
289 assert_eq!(FAILURES.read().unwrap_or_else(|e| e.into_inner()).len(), MAX_TRACKED_KEYS);
290
291 record_failure_at("newcomer", &limit, T0 + 61);
293 let len = FAILURES.read().unwrap_or_else(|e| e.into_inner()).len();
294 assert!(
295 len < MAX_TRACKED_KEYS,
296 "expired entries should be pruned, still holding {len}"
297 );
298 assert!(is_limited_at("newcomer", &limit, T0 + 61));
299 }
300}