Skip to main content

tuwunel_service/login_ratelimit/
mod.rs

1//! Per-account throttles on password authentication.
2//!
3//! Every password attempt reserves a token from the account's failed-attempt
4//! bucket before the password is checked, and only a wrong password keeps it,
5//! so concurrent guesses cannot share one token. Only a sign-in by a verified
6//! password debits the account bucket. Both are keyed on the account rather
7//! than the client address, mirroring Synapse's `rc_login.failed_attempts` and
8//! `rc_login.account`; the operator documentation for the
9//! `[global.rate_limiting.login]` options sets out their costs.
10
11use std::{
12	collections::BTreeMap,
13	ops::Bound::{Excluded, Included, Unbounded},
14	sync::{Arc, Mutex},
15	time::{Duration, Instant},
16};
17
18use http::StatusCode;
19use ruma::{
20	UserId,
21	api::error::{ErrorKind, LimitExceededErrorData, RetryAfter},
22};
23use tuwunel_core::{Error, Result, Server, implement, warn};
24
25/// Per-account login throttles.
26///
27/// The tables live here rather than on [`crate::users::Service`] so that the
28/// interior mutability they need stays out of a type whose `&self` methods are
29/// linted as taking nothing mutable.
30pub struct Service {
31	server: Arc<Server>,
32	account: Ratelimiter,
33	failed: Ratelimiter,
34}
35
36/// A failed-attempt token reserved for one password attempt.
37///
38/// It carries the account key, built once per request, and whether a token was
39/// actually taken. Dropping it keeps the token, as a wrong password does;
40/// anything else hands it to [`Service::refund_login_attempt`] or
41/// [`Service::record_login`]. A held bucket that fully refills and is pruned
42/// while the password is verified, then re-created by another guess, takes
43/// this reservation's refund in its place.
44#[derive(Debug)]
45#[must_use]
46pub struct Reservation {
47	key: String,
48	hold: Hold,
49}
50
51type Ratelimiter = Mutex<Table>;
52
53/// One axis's token buckets, and where the next prune of them resumes.
54///
55/// Each account maps to its last-refill instant and remaining tokens, ordered
56/// by account so that pruning can resume in place. It holds at most
57/// [`RATELIMIT_MAP_CAP`] accounts, each only until its bucket refills to the
58/// burst and a prune or refund drops it.
59#[derive(Default)]
60struct Table {
61	buckets: Buckets,
62
63	/// The last account a prune inspected; the next one starts after it.
64	cursor: String,
65}
66
67type Buckets = BTreeMap<String, (Instant, f64)>;
68
69impl crate::Service for Service {
70	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
71		Ok(Arc::new(Self {
72			server: args.server.clone(),
73			account: Ratelimiter::default(),
74			failed: Ratelimiter::default(),
75		}))
76	}
77
78	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
79}
80
81/// Cap on the number of accounts each bucket table holds.
82///
83/// A full table never evicts a bucket that is still limiting; [`Axis`] decides
84/// what happens to an account it cannot admit.
85const RATELIMIT_MAP_CAP: usize = 1 << 16;
86
87/// How many entries one admission inspects when the table is full.
88const PRUNE_SAMPLE: usize = 64;
89
90/// The longest `retry_after` a refusal will state, whatever the rate.
91const MAX_RETRY_AFTER: Duration = Duration::from_hours(24);
92
93/// How often each table's full-table warning may repeat while it stays full.
94const FULL_WARNING_INTERVAL: Duration = Duration::from_mins(1);
95
96static FAILED_FULL_WARNING: Mutex<Option<Instant>> = Mutex::new(None);
97
98static ACCOUNT_FULL_WARNING: Mutex<Option<Instant>> = Mutex::new(None);
99
100#[cfg(test)]
101mod tests;
102
103/// One bucket's configuration: refill rate per second and burst depth.
104#[derive(Clone, Copy)]
105struct Limit {
106	rate: f64,
107	burst: u32,
108}
109
110impl Limit {
111	/// A zero burst, or a rate that is zero, negative or not a number, turns
112	/// the bucket off rather than making it one that never refills.
113	fn enabled(self) -> bool { self.burst > 0 && self.rate > 0.0 && self.rate.is_finite() }
114
115	fn burst(self) -> f64 { f64::from(self.burst) }
116}
117
118/// Whether a reservation actually took a token.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120enum Hold {
121	/// A token was taken and is owed back unless a wrong password keeps it.
122	Held,
123
124	/// No token was taken: the limit is off, or a full table could not track
125	/// the account.
126	Untracked,
127}
128
129/// Which bucket table a debit addresses, deciding its full-table policy.
130///
131/// A bucket that is still limiting is never evicted to make room, since that
132/// would hand the account being guessed a fresh burst.
133#[derive(Clone, Copy, Debug)]
134enum Axis {
135	/// Wrong passwords; an account the full table cannot admit goes untracked.
136	Failed,
137
138	/// Verified sign-ins; an account the full table cannot admit is refused.
139	Account,
140}
141
142impl Axis {
143	fn when_full(self) -> Result<Hold> {
144		match self {
145			| Self::Failed => Ok(Hold::Untracked),
146			| Self::Account => Err(limit_exceeded(None)),
147		}
148	}
149
150	fn last_full_warning(self) -> &'static Mutex<Option<Instant>> {
151		match self {
152			| Self::Failed => &FAILED_FULL_WARNING,
153			| Self::Account => &ACCOUNT_FULL_WARNING,
154		}
155	}
156}
157
158/// Reserve one failed-attempt token for a password about to be checked.
159///
160/// The debit is atomic, so concurrent guesses cannot all pass on one remaining
161/// token, and an account with none left is refused with `M_LIMIT_EXCEEDED`.
162/// Only a wrong password keeps the reservation: every other outcome hands it
163/// back through [`record_login`] or [`refund_login_attempt`].
164///
165/// [`record_login`]: Service::record_login
166/// [`refund_login_attempt`]: Service::refund_login_attempt
167#[implement(Service)]
168pub fn reserve_login_attempt(&self, user_id: &UserId) -> Result<Reservation> {
169	reserve_at(
170		&self.failed,
171		account_key(user_id),
172		self.failed_limit(),
173		Instant::now(),
174		RATELIMIT_MAP_CAP,
175	)
176}
177
178/// Record a sign-in by a verified password.
179///
180/// The reservation is handed back first, because the password was right
181/// whatever the account bucket decides. The account bucket is then debited,
182/// refusing with `M_LIMIT_EXCEEDED` an account that has signed in too often.
183#[implement(Service)]
184pub fn record_login(&self, reservation: Reservation) -> Result {
185	let Reservation { key, hold } = reservation;
186	let now = Instant::now();
187
188	refund_at(&self.failed, &key, hold, self.failed_limit(), now)?;
189
190	debit_at(&self.account, &key, self.account_limit(), Axis::Account, now, RATELIMIT_MAP_CAP)?;
191
192	Ok(())
193}
194
195/// Hand back a reservation whose attempt found no wrong password.
196///
197/// That covers a password re-entered for UIAA, which opens no session, and
198/// every refusal made without checking a password at all.
199#[implement(Service)]
200pub fn refund_login_attempt(&self, reservation: Reservation) -> Result {
201	let Reservation { key, hold } = reservation;
202
203	refund_at(&self.failed, &key, hold, self.failed_limit(), Instant::now())
204}
205
206#[implement(Service)]
207fn failed_limit(&self) -> Limit {
208	let failed = &self.server.config.rate_limiting.login.failed;
209
210	Limit {
211		rate: failed.per_second,
212		burst: failed.burst_count,
213	}
214}
215
216#[implement(Service)]
217fn account_limit(&self) -> Limit {
218	let account = &self.server.config.rate_limiting.login.account;
219
220	Limit {
221		rate: account.per_second,
222		burst: account.burst_count,
223	}
224}
225
226/// One bucket per account regardless of the case it was typed in, matching
227/// the lowercase fallback the password check itself applies.
228fn account_key(user_id: &UserId) -> String { user_id.as_str().to_lowercase() }
229
230fn reserve_at(
231	table: &Ratelimiter,
232	key: String,
233	limit: Limit,
234	now: Instant,
235	cap: usize,
236) -> Result<Reservation> {
237	let hold = debit_at(table, &key, limit, Axis::Failed, now, cap)?;
238
239	Ok(Reservation { key, hold })
240}
241
242/// Take one token from an account's bucket, refusing when none is left.
243///
244/// An account the table does not hold starts with a full bucket. When the
245/// table holds `cap` accounts and nothing can be pruned, the axis decides
246/// whether such an account is refused or let through untracked.
247fn debit_at(
248	table: &Ratelimiter,
249	key: &str,
250	limit: Limit,
251	axis: Axis,
252	now: Instant,
253	cap: usize,
254) -> Result<Hold> {
255	if !limit.enabled() {
256		return Ok(Hold::Untracked);
257	}
258
259	let Limit { rate, .. } = limit;
260	let burst = limit.burst();
261	let mut table = table.lock()?;
262	let Table { buckets, cursor } = &mut *table;
263
264	debug_assert!(cap > 0, "rate-limit table cap must be positive");
265
266	let Some(bucket) = buckets.get_mut(key) else {
267		if buckets.len() >= cap {
268			prune_sample(buckets, cursor, rate, burst, now);
269		}
270
271		if buckets.len() >= cap {
272			drop(table);
273			warn_table_full(axis, now);
274			return axis.when_full();
275		}
276
277		buckets.insert(key.to_owned(), (now, burst - 1.0));
278		return Ok(Hold::Held);
279	};
280
281	let (last_time, tokens) = bucket;
282	let refilled = refill(*last_time, *tokens, rate, burst, now);
283
284	if refilled < 1.0 {
285		return Err(limit_exceeded(retry_after(rate, refilled)));
286	}
287
288	*last_time = now;
289	*tokens = refilled - 1.0;
290
291	Ok(Hold::Held)
292}
293
294/// Give a held reservation's token back, never past the burst.
295///
296/// A bucket refunded back to full carries no information and is removed, so
297/// ordinary sign-ins do not fill the table. An untracked reservation took
298/// nothing and gets nothing back.
299fn refund_at(table: &Ratelimiter, key: &str, hold: Hold, limit: Limit, now: Instant) -> Result {
300	if hold == Hold::Untracked || !limit.enabled() {
301		return Ok(());
302	}
303
304	let burst = limit.burst();
305	let mut table = table.lock()?;
306	let Table { buckets, .. } = &mut *table;
307
308	let Some((last_time, tokens)) = buckets.get_mut(key) else {
309		return Ok(());
310	};
311
312	let level = burst.min(refill(*last_time, *tokens, limit.rate, burst, now) + 1.0);
313
314	if level >= burst {
315		buckets.remove(key);
316	} else {
317		*last_time = now;
318		*tokens = level;
319	}
320
321	Ok(())
322}
323
324fn refill(last: Instant, tokens: f64, rate: f64, burst: f64, now: Instant) -> f64 {
325	now.saturating_duration_since(last)
326		.as_secs_f64()
327		.mul_add(rate, tokens)
328		.min(burst)
329}
330
331/// Remove, from the next bounded sample of the table, the buckets that have
332/// refilled completely.
333///
334/// Each sample starts after the last account the previous one inspected and
335/// wraps around at the end, so successive samples reach every bucket even
336/// while the leading ones stay restricted. A bucket that is still restricted
337/// is never removed.
338fn prune_sample(buckets: &mut Buckets, cursor: &mut String, rate: f64, burst: f64, now: Instant) {
339	let after = (Excluded(cursor.as_str()), Unbounded);
340	let wrapped = (Unbounded, Included(cursor.as_str()));
341	let sample = buckets
342		.range::<str, _>(after)
343		.chain(buckets.range::<str, _>(wrapped))
344		.take(PRUNE_SAMPLE);
345
346	if let Some((key, _)) = sample.clone().last() {
347		cursor.clone_from(key);
348	}
349
350	let refilled: Vec<String> = sample
351		.filter(|(_, (last, tokens))| refill(*last, *tokens, rate, burst, now) >= burst)
352		.map(|(key, _)| key.clone())
353		.collect();
354
355	for key in refilled {
356		buckets.remove(&key);
357	}
358}
359
360/// Warn that the table for `axis` cannot admit another account.
361///
362/// At most once per [`FULL_WARNING_INTERVAL`] for each table, so a spray that
363/// keeps one table full neither floods the log nor hides the other's warning.
364fn warn_table_full(axis: Axis, now: Instant) {
365	let Ok(mut last) = axis.last_full_warning().lock() else {
366		return;
367	};
368
369	if last.is_some_and(|last| now.saturating_duration_since(last) < FULL_WARNING_INTERVAL) {
370		return;
371	}
372
373	*last = Some(now);
374
375	match axis {
376		| Axis::Failed => warn!(
377			table = ?axis,
378			cap = RATELIMIT_MAP_CAP,
379			"Login rate-limit table is full of accounts still being limited; wrong passwords for \
380			 accounts not already in it go untracked. Likely a spray of distinct user names."
381		),
382		| Axis::Account => warn!(
383			table = ?axis,
384			cap = RATELIMIT_MAP_CAP,
385			"Login rate-limit table is full of accounts still being limited; sign-ins for \
386			 accounts not already in it are refused."
387		),
388	}
389}
390
391/// Seconds until one token is back, rounded up and capped. `None` when it
392/// cannot be stated, which the error then simply omits.
393fn retry_after(rate: f64, tokens: f64) -> Option<Duration> {
394	let secs = ((1.0 - tokens) / rate).ceil();
395
396	// Capped before converting: a tiny rate gives a figure too large for a
397	// `Duration`, and the cap is the honest answer to it, not an omission.
398	Duration::try_from_secs_f64(secs.min(MAX_RETRY_AFTER.as_secs_f64())).ok()
399}
400
401fn limit_exceeded(retry_after: Option<Duration>) -> Error {
402	Error::Request(
403		ErrorKind::LimitExceeded(LimitExceededErrorData {
404			retry_after: retry_after.map(RetryAfter::Delay),
405		}),
406		"Too many login attempts for this account.".into(),
407		StatusCode::TOO_MANY_REQUESTS,
408	)
409}