use std::collections::HashMap;
use std::fmt;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
const DEFAULT_PER_IDENTITY: u32 = 5;
const DEFAULT_PER_ADDRESS: u32 = 50;
const DEFAULT_WINDOW: Duration = Duration::from_secs(15 * 60);
const MINIMUM_RETRY_AFTER: Duration = Duration::from_secs(1);
const SWEEP_AT: usize = 8192;
const IDENTITY_DOMAIN: u8 = 1;
const ADDRESS_DOMAIN: u8 = 2;
const UNATTRIBUTED: &[u8] = b"arcature/login-throttle/unattributed";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ThrottleDecision {
Allowed,
TooManyAttempts {
retry_after: Duration,
},
}
impl ThrottleDecision {
#[must_use]
pub fn is_allowed(self) -> bool {
matches!(self, Self::Allowed)
}
#[must_use]
pub fn retry_after(self) -> Option<Duration> {
match self {
Self::Allowed => None,
Self::TooManyAttempts { retry_after } => Some(retry_after),
}
}
}
#[derive(Debug, Clone, Copy)]
struct Quota {
capacity: f64,
refill_per_sec: f64,
}
impl Quota {
fn new(limit: u32, window: Duration) -> Self {
let limit = f64::from(limit.max(1));
let window = window.max(Duration::from_millis(1));
Self {
capacity: limit,
refill_per_sec: limit / window.as_secs_f64(),
}
}
}
#[derive(Debug, Clone, Copy)]
struct Bucket {
tokens: f64,
updated: Instant,
}
impl Bucket {
fn tokens_at(self, quota: Quota, now: Instant) -> f64 {
let elapsed = now.saturating_duration_since(self.updated).as_secs_f64();
(self.tokens + elapsed * quota.refill_per_sec).min(quota.capacity)
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct LoginThrottle {
identity: Quota,
address: Quota,
window: Duration,
identity_limit: u32,
address_limit: u32,
buckets: Arc<Mutex<HashMap<[u8; 32], Bucket>>>,
}
impl Default for LoginThrottle {
fn default() -> Self {
Self::new()
}
}
impl LoginThrottle {
#[must_use]
pub fn new() -> Self {
Self {
identity: Quota::new(DEFAULT_PER_IDENTITY, DEFAULT_WINDOW),
address: Quota::new(DEFAULT_PER_ADDRESS, DEFAULT_WINDOW),
window: DEFAULT_WINDOW,
identity_limit: DEFAULT_PER_IDENTITY,
address_limit: DEFAULT_PER_ADDRESS,
buckets: Arc::new(Mutex::new(HashMap::new())),
}
}
#[must_use]
pub fn per_identity(mut self, limit: u32) -> Self {
self.identity_limit = limit;
self.identity = Quota::new(limit, self.window);
self
}
#[must_use]
pub fn per_address(mut self, limit: u32) -> Self {
self.address_limit = limit;
self.address = Quota::new(limit, self.window);
self
}
#[must_use]
pub fn window(mut self, window: Duration) -> Self {
self.window = window;
self.identity = Quota::new(self.identity_limit, window);
self.address = Quota::new(self.address_limit, window);
self
}
#[must_use]
pub fn check(&self, email: &str, client: Option<IpAddr>) -> ThrottleDecision {
self.check_at(email, client, Instant::now())
}
pub fn record_failure(&self, email: &str, client: Option<IpAddr>) {
self.record_failure_at(email, client, Instant::now());
}
pub fn record_success(&self, email: &str, client: Option<IpAddr>) {
let key = identity_key(email, client);
if let Ok(mut buckets) = self.buckets.lock() {
buckets.remove(&key);
}
}
#[must_use]
pub fn tracked(&self) -> usize {
self.buckets.lock().map_or(0, |buckets| buckets.len())
}
fn check_at(&self, email: &str, client: Option<IpAddr>, now: Instant) -> ThrottleDecision {
let pairs = [
(identity_key(email, client), self.identity),
(address_key(client), self.address),
];
let buckets = match self.buckets.lock() {
Ok(buckets) => buckets,
Err(_) => {
return ThrottleDecision::TooManyAttempts {
retry_after: MINIMUM_RETRY_AFTER,
};
}
};
let mut wait: Option<Duration> = None;
for (key, quota) in pairs {
let tokens = buckets
.get(&key)
.map_or(quota.capacity, |bucket| bucket.tokens_at(quota, now));
if tokens < 1.0 {
let seconds = (1.0 - tokens) / quota.refill_per_sec;
let this = Duration::from_secs_f64(seconds).max(MINIMUM_RETRY_AFTER);
wait = Some(wait.map_or(this, |longest: Duration| longest.max(this)));
}
}
match wait {
Some(retry_after) => ThrottleDecision::TooManyAttempts { retry_after },
None => ThrottleDecision::Allowed,
}
}
fn record_failure_at(&self, email: &str, client: Option<IpAddr>, now: Instant) {
let pairs = [
(identity_key(email, client), self.identity),
(address_key(client), self.address),
];
let Ok(mut buckets) = self.buckets.lock() else {
return;
};
if buckets.len() >= SWEEP_AT {
buckets
.retain(|_, bucket| bucket.tokens_at(self.identity, now) < self.identity.capacity);
}
for (key, quota) in pairs {
let bucket = buckets.entry(key).or_insert(Bucket {
tokens: quota.capacity,
updated: now,
});
let tokens = bucket.tokens_at(quota, now);
bucket.tokens = (tokens - 1.0).max(0.0);
bucket.updated = now;
}
}
}
impl fmt::Debug for LoginThrottle {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LoginThrottle")
.field("per_identity", &self.identity_limit)
.field("per_address", &self.address_limit)
.field("window", &self.window)
.field("tracked", &self.tracked())
.finish_non_exhaustive()
}
}
fn identity_key(email: &str, client: Option<IpAddr>) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update([IDENTITY_DOMAIN]);
let normalised = normalise(email);
hasher.update((normalised.len() as u64).to_be_bytes());
hasher.update(normalised.as_bytes());
write_client(&mut hasher, client);
hasher.finalize().into()
}
fn address_key(client: Option<IpAddr>) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update([ADDRESS_DOMAIN]);
write_client(&mut hasher, client);
hasher.finalize().into()
}
fn write_client(hasher: &mut Sha256, client: Option<IpAddr>) {
match client {
Some(address) => match address.to_canonical() {
IpAddr::V4(v4) => {
hasher.update([4]);
hasher.update(v4.octets());
}
IpAddr::V6(v6) => {
hasher.update([6]);
hasher.update(v6.octets());
}
},
None => {
hasher.update([0]);
hasher.update(UNATTRIBUTED);
}
}
}
fn normalise(email: &str) -> String {
email.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_PER_IDENTITY, LoginThrottle};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::time::{Duration, Instant};
fn client(last: u8) -> Option<IpAddr> {
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, last)))
}
#[test]
fn a_fresh_address_is_allowed() {
let throttle = LoginThrottle::new();
assert!(throttle.check("you@example.com", client(1)).is_allowed());
assert_eq!(throttle.tracked(), 0, "a check must not create a bucket");
}
#[test]
fn the_default_allowance_is_spent_exactly_at_the_limit() {
let throttle = LoginThrottle::new();
for attempt in 0..DEFAULT_PER_IDENTITY {
assert!(
throttle.check("you@example.com", client(1)).is_allowed(),
"refused after only {attempt} failures"
);
throttle.record_failure("you@example.com", client(1));
}
assert!(!throttle.check("you@example.com", client(1)).is_allowed());
}
#[test]
fn one_client_cannot_lock_another_client_out_of_an_account() {
let throttle = LoginThrottle::new().per_identity(2);
throttle.record_failure("you@example.com", client(1));
throttle.record_failure("you@example.com", client(1));
assert!(!throttle.check("you@example.com", client(1)).is_allowed());
assert!(
throttle.check("you@example.com", client(2)).is_allowed(),
"an attacker locked the account holder out of their own account"
);
}
#[test]
fn a_spray_across_many_accounts_is_caught_by_the_client_bucket() {
let throttle = LoginThrottle::new().per_address(4);
for account in 0..4 {
let email = format!("user-{account}@example.com");
assert!(throttle.check(&email, client(1)).is_allowed());
throttle.record_failure(&email, client(1));
}
assert!(
!throttle
.check("never-tried@example.com", client(1))
.is_allowed(),
"the client kept going after spending its whole allowance"
);
assert!(
throttle
.check("never-tried@example.com", client(2))
.is_allowed(),
"a different client was caught by the first one's failures"
);
}
#[test]
fn an_address_with_no_account_is_throttled_identically() {
let throttle = LoginThrottle::new().per_identity(3);
let now = Instant::now();
for _ in 0..3 {
throttle.record_failure_at("real@example.com", client(1), now);
throttle.record_failure_at("nobody@example.com", client(2), now);
}
assert_eq!(
throttle.check_at("real@example.com", client(1), now),
throttle.check_at("nobody@example.com", client(2), now),
"the two addresses were refused differently"
);
}
#[test]
fn success_clears_the_account_but_not_the_client() {
let throttle = LoginThrottle::new().per_identity(2).per_address(3);
throttle.record_failure("you@example.com", client(1));
throttle.record_failure("you@example.com", client(1));
assert!(!throttle.check("you@example.com", client(1)).is_allowed());
throttle.record_success("you@example.com", client(1));
assert!(
throttle.check("you@example.com", client(1)).is_allowed(),
"a successful sign-in did not clear the account's failures"
);
throttle.record_failure("someone@example.com", client(1));
assert!(
!throttle.check("anyone@example.com", client(1)).is_allowed(),
"the client's allowance was refunded by a successful sign-in"
);
}
#[test]
fn a_refusal_always_reports_a_wait_of_at_least_a_second() {
let throttle = LoginThrottle::new().per_identity(1);
throttle.record_failure("you@example.com", client(1));
let wait = throttle
.check("you@example.com", client(1))
.retry_after()
.expect("refused");
assert!(wait >= Duration::from_secs(1), "{wait:?}");
}
#[test]
fn the_allowance_comes_back_over_the_window() {
let throttle = LoginThrottle::new()
.per_identity(4)
.window(Duration::from_secs(40));
let start = Instant::now();
for _ in 0..4 {
throttle.record_failure_at("you@example.com", client(1), start);
}
assert!(
!throttle
.check_at("you@example.com", client(1), start)
.is_allowed()
);
assert!(
!throttle
.check_at("you@example.com", client(1), start + Duration::from_secs(9))
.is_allowed()
);
assert!(
throttle
.check_at(
"you@example.com",
client(1),
start + Duration::from_secs(11)
)
.is_allowed(),
"the bucket did not refill"
);
}
#[test]
fn the_allowance_does_not_accumulate_past_the_limit() {
let throttle = LoginThrottle::new()
.per_identity(2)
.window(Duration::from_secs(10));
let start = Instant::now();
throttle.record_failure_at("you@example.com", client(1), start);
let later = start + Duration::from_secs(60 * 60);
throttle.record_failure_at("you@example.com", client(1), later);
throttle.record_failure_at("you@example.com", client(1), later);
assert!(
!throttle
.check_at("you@example.com", client(1), later)
.is_allowed(),
"an hour of quiet bought more than the configured limit"
);
}
#[test]
fn a_burst_of_failures_does_not_extend_the_lockout() {
let throttle = LoginThrottle::new()
.per_identity(2)
.window(Duration::from_secs(20));
let start = Instant::now();
for _ in 0..20 {
throttle.record_failure_at("you@example.com", client(1), start);
}
assert!(
throttle
.check_at(
"you@example.com",
client(1),
start + Duration::from_secs(11)
)
.is_allowed(),
"the bucket was driven below empty"
);
}
#[test]
fn the_address_is_normalised_before_it_is_counted() {
let throttle = LoginThrottle::new().per_identity(1);
throttle.record_failure(" You@Example.COM ", client(1));
assert!(
!throttle.check("you@example.com", client(1)).is_allowed(),
"a change of case bought a fresh allowance"
);
}
#[test]
fn a_v4_mapped_address_shares_the_v4_bucket() {
let throttle = LoginThrottle::new().per_identity(1);
let mapped = Some(IpAddr::V6(Ipv4Addr::new(203, 0, 113, 1).to_ipv6_mapped()));
throttle.record_failure("you@example.com", mapped);
assert!(!throttle.check("you@example.com", client(1)).is_allowed());
}
#[test]
fn a_v6_client_is_counted_separately_from_a_v4_one() {
let throttle = LoginThrottle::new().per_identity(1);
let v6 = Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)));
throttle.record_failure("you@example.com", v6);
assert!(throttle.check("you@example.com", client(1)).is_allowed());
}
#[test]
fn attempts_with_no_client_address_still_count() {
let throttle = LoginThrottle::new().per_address(2);
throttle.record_failure("one@example.com", None);
throttle.record_failure("two@example.com", None);
assert!(!throttle.check("three@example.com", None).is_allowed());
assert!(
throttle.check("three@example.com", client(1)).is_allowed(),
"an identified client was caught by unattributed failures"
);
}
#[test]
fn a_clone_shares_the_counters() {
let throttle = LoginThrottle::new().per_identity(1);
let clone = throttle.clone();
clone.record_failure("you@example.com", client(1));
assert!(!throttle.check("you@example.com", client(1)).is_allowed());
}
#[test]
fn a_failure_creates_one_bucket_per_dimension() {
let throttle = LoginThrottle::new();
throttle.record_failure("you@example.com", client(1));
assert_eq!(throttle.tracked(), 2);
throttle.record_failure("you@example.com", client(1));
assert_eq!(throttle.tracked(), 2);
throttle.record_failure("other@example.com", client(1));
assert_eq!(throttle.tracked(), 3);
}
#[test]
fn debug_does_not_dump_the_bucket_table() {
let throttle = LoginThrottle::new();
throttle.record_failure("you@example.com", client(1));
let rendered = format!("{throttle:?}");
assert!(rendered.contains("tracked: 2"), "{rendered}");
assert!(!rendered.contains("Bucket"), "{rendered}");
}
}