use std::sync::Arc;
use std::time::Duration;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{Extensions, HeaderMap};
use axum::response::Response;
use tokio::sync::Notify;
use crate::account_lockout::{Reservation, ReservedSlot};
use crate::cache::BoxedCache;
use crate::rate_limit::RateLimitLayer;
use crate::rate_limit_cache::CacheRateLimitLayer;
pub const DEFAULT_IP_LIMIT: u32 = 20;
pub const DEFAULT_IP_WINDOW_SECS: u64 = 60;
pub const DEFAULT_GLOBAL_LIMIT: u32 = 600;
pub const DEFAULT_GLOBAL_WINDOW_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LoginLimits {
pub ip_limit: u32,
pub ip_window: Duration,
pub global_limit: u32,
pub global_window: Duration,
}
impl Default for LoginLimits {
fn default() -> Self {
Self {
ip_limit: DEFAULT_IP_LIMIT,
ip_window: Duration::from_secs(DEFAULT_IP_WINDOW_SECS),
global_limit: DEFAULT_GLOBAL_LIMIT,
global_window: Duration::from_secs(DEFAULT_GLOBAL_WINDOW_SECS),
}
}
}
impl LoginLimits {
#[cfg(feature = "config")]
#[must_use]
pub fn from_settings(s: &crate::config::AuthSettings) -> Self {
let d = Self::default();
Self {
ip_limit: s.login_ip_limit.unwrap_or(d.ip_limit),
ip_window: s
.login_ip_window_secs
.map_or(d.ip_window, Duration::from_secs),
global_limit: s.login_global_limit.unwrap_or(d.global_limit),
global_window: s
.login_global_window_secs
.map_or(d.global_window, Duration::from_secs),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LoginScope {
Admin,
Operator,
Tenant(String),
TenantBasic(String),
TenantApiKey(String),
}
impl LoginScope {
fn key(&self) -> String {
match self {
Self::Admin => "admin".to_owned(),
Self::Operator => "operator".to_owned(),
Self::Tenant(slug) => format!("tenant:{slug}"),
Self::TenantBasic(slug) => format!("tenant-basic:{slug}"),
Self::TenantApiKey(slug) => format!("tenant-apikey:{slug}"),
}
}
fn locks_accounts(&self) -> bool {
!matches!(self, Self::TenantApiKey(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginRefused {
Throttled { retry_after_secs: u64 },
Busy,
}
impl LoginRefused {
#[must_use]
pub fn into_response(self) -> Response {
use axum::response::IntoResponse as _;
match self {
Self::Throttled { retry_after_secs } => {
crate::api_errors::ApiError::rate_limited_response(
"too many login attempts",
retry_after_secs,
)
}
Self::Busy => {
let mut resp =
crate::api_errors::ApiError::service_unavailable("login is busy, try again")
.into_response();
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, 1u64.into());
resp
}
}
}
}
impl axum::response::IntoResponse for LoginRefused {
fn into_response(self) -> Response {
LoginRefused::into_response(self)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientIp(Option<String>);
impl ClientIp {
#[must_use]
pub fn from_parts(extensions: &Extensions, headers: &HeaderMap) -> Self {
Self(crate::rate_limit::client_ip(extensions, headers).map(crate::rate_limit::ip_bucket))
}
}
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
Ok(Self::from_parts(&parts.extensions, &parts.headers))
}
}
struct Buckets {
limits: LoginLimits,
ip: Limit,
global: Limit,
in_flight: InFlight,
}
const IN_FLIGHT_POLL: Duration = Duration::from_millis(50);
#[derive(Default)]
struct InFlight(std::sync::Mutex<std::collections::HashMap<String, (usize, Arc<Notify>)>>);
impl InFlight {
fn map(
&self,
) -> std::sync::MutexGuard<'_, std::collections::HashMap<String, (usize, Arc<Notify>)>> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn enter(&self, gate: &str) {
self.map().entry(gate.to_owned()).or_default().0 += 1;
}
fn leave(&self, gate: &str) {
let mut map = self.map();
if let Some((n, notify)) = map.get_mut(gate) {
*n -= 1;
notify.notify_waiters();
if *n == 0 {
map.remove(gate);
}
}
}
fn busy(&self, gate: &str) -> Option<Arc<Notify>> {
self.map().get(gate).map(|(_, notify)| Arc::clone(notify))
}
}
struct Gates {
buckets: Arc<Buckets>,
held: Vec<String>,
}
impl Gates {
fn enter(&mut self, gate: String) {
self.buckets.in_flight.enter(&gate);
self.held.push(gate);
}
}
impl Drop for Gates {
fn drop(&mut self) {
for gate in &self.held {
self.buckets.in_flight.leave(gate);
}
}
}
enum Blocked {
Refused(LoginRefused),
Full { gate: String, retry_after_secs: u64 },
}
impl Buckets {
async fn wait(
&self,
blocked: Blocked,
deadline: tokio::time::Instant,
) -> Result<(), LoginRefused> {
let (gate, retry_after_secs) = match blocked {
Blocked::Refused(r) => return Err(r),
Blocked::Full {
gate,
retry_after_secs,
} => (gate, retry_after_secs),
};
let Some(notify) = self.in_flight.busy(&gate) else {
return Err(LoginRefused::Throttled { retry_after_secs });
};
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if self.in_flight.busy(&gate).is_none() {
return Ok(());
}
let left = deadline.saturating_duration_since(tokio::time::Instant::now());
if left.is_zero() {
return Err(LoginRefused::Busy);
}
let _ = tokio::time::timeout(left.min(IN_FLIGHT_POLL), notified).await;
Ok(())
}
}
fn ip_gate(ip: &str) -> String {
format!("ip:{ip}")
}
fn global_gate(scope_key: &str) -> String {
format!("global:{scope_key}")
}
enum Limit {
Memory(RateLimitLayer),
Cache {
shared: CacheRateLimitLayer,
fallback: RateLimitLayer,
},
}
#[derive(Debug, Clone, Copy)]
enum Spent {
Nothing,
Memory,
Window(u64),
}
impl Limit {
async fn take(&self, key: &str) -> Result<Spent, u64> {
match self {
Self::Memory(l) => l.take(key).await.map(|_| Spent::Memory),
Self::Cache { shared, fallback } => match shared.try_take(key).await {
Ok(Ok((_, window))) => Ok(Spent::Window(window)),
Ok(Err((secs, window))) => {
shared.give_back_at(key, window).await;
Err(secs)
}
Err(e) => {
tracing::warn!(target: "rustango::rate_limit", error = %e, "login limit cache incr failed");
fallback.take(key).await.map(|_| Spent::Memory)
}
},
}
}
async fn peek(&self, key: &str) -> Result<(), u64> {
match self {
Self::Memory(l) => l.peek(key).await,
Self::Cache { shared, fallback } => match shared.try_peek(key).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(target: "rustango::rate_limit", error = %e, "login limit cache read failed");
fallback.peek(key).await
}
},
}
}
async fn give_back(&self, key: &str, spent: Spent) {
match (self, spent) {
(Self::Memory(l) | Self::Cache { fallback: l, .. }, Spent::Memory) => {
l.give_back(key).await;
}
(Self::Cache { shared, .. }, Spent::Window(w)) => shared.give_back_at(key, w).await,
_ => {}
}
}
fn cache(&self) -> Option<&BoxedCache> {
match self {
Self::Memory(_) => None,
Self::Cache { shared, .. } => Some(shared.cache()),
}
}
}
pub struct LoginThrottle(Arc<Buckets>);
impl LoginThrottle {
#[must_use]
pub fn new(limits: LoginLimits) -> Self {
Self(Arc::new(Buckets {
limits,
ip: Limit::Memory(memory_ip(limits)),
global: Limit::Memory(memory_global(limits)),
in_flight: InFlight::default(),
}))
}
#[must_use]
pub fn with_cache(limits: LoginLimits, cache: BoxedCache) -> Self {
let ip = CacheRateLimitLayer::new(cache.clone(), limits.ip_limit.max(1), limits.ip_window);
let global =
CacheRateLimitLayer::new(cache, limits.global_limit.max(1), limits.global_window);
Self(Arc::new(Buckets {
limits,
ip: Limit::Cache {
shared: ip.key_prefix("login-ip"),
fallback: memory_ip(limits),
},
global: Limit::Cache {
shared: global.key_prefix("login-global"),
fallback: memory_global(limits),
},
in_flight: InFlight::default(),
}))
}
#[must_use]
pub fn is_process_local(&self) -> bool {
self.0.ip.cache().is_none_or(|c| c.is_process_local())
}
fn store_warning(&self) -> Option<&'static str> {
if self.0.ip.cache().is_some_and(|c| c.stores_nothing()) {
return Some(NULL_STORE_WARNING);
}
self.is_process_local().then_some(PROCESS_LOCAL_WARNING)
}
fn warn_once_on_weak_store(&self) {
static WARNED: std::sync::Once = std::sync::Once::new();
if let Some(msg) = self.store_warning() {
WARNED.call_once(|| tracing::warn!(target: "rustango::rate_limit", "{msg}"));
}
}
#[must_use]
pub fn limits(&self) -> LoginLimits {
self.0.limits
}
pub async fn begin(
&self,
scope: &LoginScope,
ip: &ClientIp,
username: &str,
) -> Result<LoginAttempt, LoginRefused> {
self.warn_once_on_weak_store();
let deadline = tokio::time::Instant::now() + crate::passwords::hash_wait();
loop {
match self.try_begin(scope, ip, username).await {
Ok(held) => return Ok(LoginAttempt(Some(held))),
Err(blocked) => self.0.wait(blocked, deadline).await?,
}
}
}
async fn try_begin(
&self,
scope: &LoginScope,
ip: &ClientIp,
username: &str,
) -> Result<Held, Blocked> {
let b = &self.0;
let mut held = Held {
buckets: Arc::clone(b),
scope_key: scope.key(),
ip: ip.0.clone(),
spent: (Spent::Nothing, Spent::Nothing),
locks: Vec::with_capacity(2),
gates: Gates {
buckets: Arc::clone(b),
held: Vec::with_capacity(4),
},
};
if scope.locks_accounts() {
held.reserve_lock(account_key(&held.scope_key, username))
.await?;
}
if let Some(ip) = &ip.0 {
match b.ip.take(ip).await {
Ok(spent) => {
held.spent.0 = spent;
held.gates.enter(ip_gate(ip));
}
Err(retry_after_secs) => {
held.release().await;
let gate = ip_gate(ip);
return Err(Blocked::Full {
gate,
retry_after_secs,
});
}
}
}
match b.global.take(&held.scope_key).await {
Ok(spent) => {
held.spent.1 = spent;
held.gates.enter(global_gate(&held.scope_key));
}
Err(retry_after_secs) => {
let gate = global_gate(&held.scope_key);
held.release().await;
return Err(Blocked::Full {
gate,
retry_after_secs,
});
}
}
Ok(held)
}
pub async fn check(
&self,
scope: &LoginScope,
ip: &ClientIp,
username: &str,
) -> Result<(), LoginRefused> {
let scope_key = scope.key();
if scope.locks_accounts() {
let lockout = crate::account_lockout::shared();
if lockout.is_locked(&account_key(&scope_key, username)).await {
return Err(LoginRefused::Throttled {
retry_after_secs: lockout.lock_duration().as_secs().max(1),
});
}
}
let b = &self.0;
let spent = |gate: &str, r: Result<(), u64>| match r {
Err(retry_after_secs) if b.in_flight.busy(gate).is_none() => {
Err(LoginRefused::Throttled { retry_after_secs })
}
_ => Ok(()),
};
if let Some(ip) = &ip.0 {
spent(&ip_gate(ip), b.ip.peek(ip).await)?;
}
spent(&global_gate(&scope_key), b.global.peek(&scope_key).await)
}
pub async fn verify_current_password(
&self,
scope: &LoginScope,
ip: &ClientIp,
username: &str,
verify: impl std::future::Future<Output = Result<bool, LoginRefused>>,
) -> Result<bool, LoginRefused> {
let attempt = self.begin(scope, ip, username).await?;
let ok = match verify.await {
Ok(ok) => ok,
Err(refused) => {
attempt.prompted().await;
return Err(refused);
}
};
if ok {
attempt.succeeded().await;
} else {
attempt.failed().await;
}
Ok(ok)
}
}
fn memory_ip(limits: LoginLimits) -> RateLimitLayer {
RateLimitLayer::per_ip(limits.ip_limit.max(1), limits.ip_window)
}
fn memory_global(limits: LoginLimits) -> RateLimitLayer {
RateLimitLayer::global(limits.global_limit.max(1), limits.global_window)
}
#[must_use = "report the outcome, or failures never lock the account"]
pub struct LoginAttempt(Option<Held>);
struct Held {
buckets: Arc<Buckets>,
scope_key: String,
ip: Option<String>,
spent: (Spent, Spent),
locks: Vec<(String, ReservedSlot)>,
gates: Gates,
}
impl Drop for LoginAttempt {
fn drop(&mut self) {
let Some(held) = self.0.take() else {
return;
};
if let Ok(rt) = tokio::runtime::Handle::try_current() {
rt.spawn(held.release());
}
}
}
impl LoginAttempt {
fn held(&mut self) -> Held {
self.0.take().expect("a LoginAttempt is reported once")
}
pub async fn resolve(&mut self, stored_username: &str) -> Result<(), LoginRefused> {
let Some(held) = self.0.as_mut() else {
return Ok(());
};
if held.locks.is_empty() {
return Ok(());
}
let key = account_key(&held.scope_key, stored_username);
if held.locks.iter().any(|(k, _)| *k == key) {
return Ok(());
}
let buckets = Arc::clone(&held.buckets);
let deadline = tokio::time::Instant::now() + crate::passwords::hash_wait();
loop {
match held.reserve_lock(key.clone()).await {
Ok(()) => return Ok(()),
Err(blocked) => buckets.wait(blocked, deadline).await?,
}
}
}
pub async fn failed(mut self) {
let mut held = self.held();
let lockout = crate::account_lockout::shared();
for (key, slot) in held.locks.drain(..) {
lockout.reserved_failed(&key, slot).await;
}
}
pub async fn succeeded(mut self) {
let mut held = self.held();
let lockout = crate::account_lockout::shared();
for (key, slot) in held.locks.drain(..) {
lockout.reserved_succeeded(&key, slot).await;
}
held.give_back().await;
}
pub async fn prompted(mut self) {
self.held().release().await;
}
}
impl Held {
async fn reserve_lock(&mut self, key: String) -> Result<(), Blocked> {
let lockout = crate::account_lockout::shared();
crate::account_lockout::warn_once_on_weak_store(lockout);
match lockout.reserve(&key).await {
Reservation::Counted(slot) => {
self.gates.enter(key.clone());
self.locks.push((key, slot));
Ok(())
}
Reservation::Locked => Err(Blocked::Refused(LoginRefused::Throttled {
retry_after_secs: lockout.lock_duration().as_secs().max(1),
})),
_ => Err(Blocked::Full {
gate: key,
retry_after_secs: 1,
}),
}
}
async fn release(mut self) {
let lockout = crate::account_lockout::shared();
for (key, slot) in self.locks.drain(..) {
lockout.release(&key, slot).await;
}
self.give_back().await;
}
async fn give_back(&self) {
let (ip_spent, global_spent) = self.spent;
if let Some(ip) = &self.ip {
self.buckets.ip.give_back(ip, ip_spent).await;
}
self.buckets
.global
.give_back(&self.scope_key, global_spent)
.await;
}
}
fn account_key(scope_key: &str, username: &str) -> String {
use sha2::{Digest as _, Sha256};
use std::fmt::Write as _;
let digest = Sha256::digest(username.trim().to_lowercase().as_bytes());
let mut key = format!("login:{scope_key}:");
for b in digest {
let _ = write!(key, "{b:02x}");
}
key
}
pub(crate) const PROCESS_LOCAL_NOTE: &str =
"login per-IP and global limits: unless your app installs \
`login_throttle::configure_shared(LoginThrottle::with_cache(limits, cache))` with a Redis or \
database cache, each replica counts its own, so N replicas allow N times each limit";
const PROCESS_LOCAL_WARNING: &str =
"login per-IP and global limits are counted in process memory, so each replica allows its own \
attempts; install `login_throttle::configure_shared(LoginThrottle::with_cache(limits, cache))` \
with a Redis or database cache";
const NULL_STORE_WARNING: &str =
"login per-IP and global limits use a cache that stores nothing (`NullCache`), so they never \
count; use a Redis, database or in-memory cache";
static SHARED: crate::boot_slot::BootSlot<LoginThrottle> = crate::boot_slot::BootSlot::new();
#[must_use]
pub fn shared() -> &'static LoginThrottle {
SHARED.get(|| LoginThrottle::new(LoginLimits::default()))
}
pub fn configure_shared(throttle: LoginThrottle) -> bool {
SHARED.set_explicit(throttle)
}
#[cfg(all(feature = "config", feature = "manage"))]
pub(crate) fn configure_from_settings(throttle: LoginThrottle) -> bool {
SHARED.set_from_settings(throttle)
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> ClientIp {
ClientIp(Some(s.to_owned()))
}
fn tenant(s: &str) -> LoginScope {
LoginScope::Tenant(s.to_owned())
}
#[test]
fn account_key_normalizes_and_scopes() {
assert_eq!(
account_key("admin", " Alice "),
account_key("admin", "alice")
);
assert_ne!(account_key("admin", "alice"), account_key("op", "alice"));
assert!(!account_key("admin", "alice").contains("alice"));
}
#[test]
fn each_scope_has_its_own_key() {
let keys = [
LoginScope::Admin.key(),
LoginScope::Operator.key(),
tenant("a").key(),
LoginScope::TenantBasic("a".into()).key(),
LoginScope::TenantApiKey("a".into()).key(),
];
for (i, a) in keys.iter().enumerate() {
for b in &keys[i + 1..] {
assert_ne!(a, b);
}
}
}
#[tokio::test]
async fn per_ip_limit_spares_other_ips() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 2,
..LoginLimits::default()
});
let s = tenant("t-ip");
for n in 0..2 {
let a = t.begin(&s, &ip("10.9.0.1"), &format!("u{n}")).await;
a.unwrap().failed().await;
}
let r = t.begin(&s, &ip("10.9.0.1"), "u9").await;
assert!(matches!(r, Err(LoginRefused::Throttled { .. })));
assert!(t.begin(&s, &ip("10.9.0.2"), "u9").await.is_ok());
}
#[tokio::test]
async fn busy_current_password_check_gives_tokens_back() {
let t = LoginThrottle::new(LoginLimits {
global_limit: 1,
..LoginLimits::default()
});
let s = tenant("t-busy");
for _ in 0..2 {
let r = t
.verify_current_password(&s, &ClientIp::default(), "u", async {
Err(LoginRefused::Busy)
})
.await;
assert_eq!(r, Err(LoginRefused::Busy));
}
}
#[tokio::test]
async fn global_ceiling_applies_without_an_ip() {
let t = LoginThrottle::new(LoginLimits {
global_limit: 1,
..LoginLimits::default()
});
let s = tenant("t-g");
let a = t.begin(&s, &ClientIp::default(), "a").await.unwrap();
a.failed().await;
let r = t.begin(&s, &ClientIp::default(), "b").await;
assert!(matches!(r, Err(LoginRefused::Throttled { .. })));
}
#[tokio::test]
async fn an_ip_refusal_leaves_the_global_budget() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 1,
global_limit: 3,
..LoginLimits::default()
});
let s = tenant("t-order");
t.begin(&s, &ip("10.9.1.1"), "a")
.await
.unwrap()
.failed()
.await;
for _ in 0..10 {
assert!(t.begin(&s, &ip("10.9.1.1"), "a").await.is_err());
}
for n in 2..4 {
let a = t.begin(&s, &ip(&format!("10.9.1.{n}")), "b").await;
assert!(a.is_ok(), "global budget was drained by refused requests");
}
}
#[tokio::test]
async fn global_limits_are_per_scope() {
let t = LoginThrottle::new(LoginLimits {
global_limit: 1,
..LoginLimits::default()
});
let none = ClientIp::default();
t.begin(&tenant("t-a"), &none, "x")
.await
.unwrap()
.failed()
.await;
assert!(t.begin(&tenant("t-a"), &none, "y").await.is_err());
assert!(t.begin(&tenant("t-b"), &none, "y").await.is_ok());
assert!(t.begin(&LoginScope::Operator, &none, "y").await.is_ok());
}
#[tokio::test]
async fn successes_do_not_use_up_the_limits() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 2,
global_limit: 2,
..LoginLimits::default()
});
let s = tenant("t-ok");
for n in 0..10 {
let a = t.begin(&s, &ip("10.9.2.1"), &format!("ok{n}")).await;
a.unwrap().succeeded().await;
}
}
#[tokio::test]
async fn per_request_scopes_count_only_failures() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 2,
..LoginLimits::default()
});
let s = LoginScope::TenantApiKey("t-key".into());
for _ in 0..5 {
t.begin(&s, &ip("10.9.3.1"), "")
.await
.unwrap()
.succeeded()
.await;
}
for _ in 0..2 {
t.begin(&s, &ip("10.9.3.1"), "")
.await
.unwrap()
.failed()
.await;
}
assert!(t.begin(&s, &ip("10.9.3.1"), "").await.is_err());
}
async fn burst(
t: &LoginThrottle,
s: &LoginScope,
ip_of: impl Fn(usize) -> ClientIp,
user: &str,
n: usize,
fail: impl Fn(usize) -> bool,
) -> usize {
let runs = (0..n).map(|i| {
let (ip, fail) = (ip_of(i), fail(i));
async move {
let Ok(a) = t.begin(s, &ip, user).await else {
return 0;
};
tokio::time::sleep(Duration::from_millis(if fail { 1 } else { 20 })).await;
if fail {
a.failed().await;
} else {
a.succeeded().await;
}
1
}
});
futures_util::future::join_all(runs).await.iter().sum()
}
#[tokio::test]
async fn per_request_burst_is_capped_by_the_ip_limit() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 3,
..LoginLimits::default()
});
let s = LoginScope::TenantApiKey("t-burst".into());
let n = burst(&t, &s, |_| ip("10.9.3.9"), "", 20, |_| true).await;
assert_eq!(n, 3, "in-flight attempts must hold their token");
}
#[tokio::test]
async fn basic_burst_is_capped_by_the_account_lock() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 1000,
..LoginLimits::default()
});
let s = LoginScope::TenantBasic("t-burst-lock".into());
let at = |n: usize| ip(&format!("10.9.10.{n}"));
let n = burst(&t, &s, at, "victim", 50, |_| true).await;
assert_eq!(
n,
crate::account_lockout::DEFAULT_MAX_ATTEMPTS as usize,
"the lock check must reserve, not peek"
);
let r = t.begin(&s, &ip("10.9.11.1"), "victim").await;
assert!(matches!(r, Err(LoginRefused::Throttled { .. })));
}
#[tokio::test]
async fn parallel_successes_are_never_refused() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 3,
..LoginLimits::default()
});
let s = LoginScope::TenantBasic("t-par-ok".into());
let n = burst(&t, &s, |_| ip("10.9.15.1"), "svc", 20, |_| false).await;
assert_eq!(n, 20);
let s = LoginScope::TenantApiKey("t-par-key".into());
let none = |_| ClientIp::default();
assert_eq!(
burst(&t, &s, |_| ip("10.9.15.2"), "", 25, |_| false).await,
25
);
assert_eq!(burst(&t, &s, none, "", 5, |_| false).await, 5);
}
#[tokio::test]
async fn one_failure_in_a_good_burst_does_not_lock() {
let t = LoginThrottle::new(LoginLimits::default());
let s = LoginScope::TenantBasic("t-one-bad".into());
let n = burst(&t, &s, |_| ip("10.9.16.1"), "svc2", 5, |i| i == 0).await;
assert_eq!(n, 5);
let a = t.begin(&s, &ip("10.9.16.1"), "svc2").await;
assert!(a.is_ok(), "one failure locked the account");
}
#[tokio::test]
async fn cache_backed_burst_is_capped_and_successes_wait() {
let cache: BoxedCache = Arc::new(crate::cache::InMemoryCache::new());
let t = on_cache(
&cache,
LoginLimits {
ip_limit: 3,
..LoginLimits::default()
},
);
let s = LoginScope::TenantApiKey("t-cache-burst".into());
assert_eq!(
burst(&t, &s, |_| ip("10.9.17.1"), "", 20, |_| false).await,
20
);
assert_eq!(
burst(&t, &s, |_| ip("10.9.17.1"), "", 20, |_| true).await,
3
);
}
#[tokio::test]
async fn an_unreported_attempt_is_given_back() {
let t = LoginThrottle::new(LoginLimits {
ip_limit: 1,
..LoginLimits::default()
});
let s = tenant("t-drop");
for _ in 0..10 {
drop(t.begin(&s, &ip("10.9.13.1"), "dropper").await.unwrap());
for _ in 0..20 {
tokio::task::yield_now().await;
}
}
}
#[test]
fn an_attempt_dropped_at_runtime_shutdown_does_not_recurse() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let t = LoginThrottle::new(LoginLimits::default());
let a = t.begin(&tenant("t-shutdown"), &ip("10.9.14.1"), "u").await;
let a = a.unwrap();
tokio::spawn(async move {
let _held = a;
std::future::pending::<()>().await;
});
tokio::task::yield_now().await;
});
drop(rt);
}
#[tokio::test]
async fn successes_free_the_account_reservation() {
let t = LoginThrottle::new(LoginLimits::default());
let s = tenant("t-lock-ok");
for _ in 0..20 {
t.begin(&s, &ip("10.9.12.1"), "regular")
.await
.unwrap()
.succeeded()
.await;
}
}
fn on_cache(cache: &BoxedCache, limits: LoginLimits) -> LoginThrottle {
LoginThrottle::with_cache(limits, cache.clone())
}
#[tokio::test]
async fn replicas_on_one_cache_share_the_limits() {
let cache: BoxedCache = Arc::new(crate::cache::InMemoryCache::new());
let limits = LoginLimits {
ip_limit: 2,
global_limit: 3,
..LoginLimits::default()
};
let (a, b) = (on_cache(&cache, limits), on_cache(&cache, limits));
let s = tenant("t-shared");
let one = ip("10.9.4.1");
a.begin(&s, &one, "u").await.unwrap().failed().await;
b.begin(&s, &one, "v").await.unwrap().failed().await;
assert!(a.begin(&s, &one, "w").await.is_err(), "per-IP not shared");
b.begin(&s, &ip("10.9.4.2"), "x")
.await
.unwrap()
.failed()
.await;
let r = a.begin(&s, &ip("10.9.4.3"), "y").await;
assert!(r.is_err(), "global not shared");
}
#[tokio::test]
async fn cache_backed_successes_do_not_use_up_the_limits() {
let cache: BoxedCache = Arc::new(crate::cache::InMemoryCache::new());
let t = on_cache(
&cache,
LoginLimits {
ip_limit: 2,
global_limit: 2,
..LoginLimits::default()
},
);
for n in 0..10 {
let a = t
.begin(&tenant("t-ok2"), &ip("10.9.5.1"), &format!("ok{n}"))
.await;
a.unwrap().succeeded().await;
}
}
struct Down;
#[async_trait::async_trait]
impl crate::cache::Cache for Down {
async fn get(&self, _: &str) -> Result<Option<String>, crate::cache::CacheError> {
Err(crate::cache::CacheError::Connection("down".into()))
}
async fn set(
&self,
_: &str,
_: &str,
_: Option<Duration>,
) -> Result<(), crate::cache::CacheError> {
Err(crate::cache::CacheError::Connection("down".into()))
}
async fn delete(&self, _: &str) -> Result<(), crate::cache::CacheError> {
Err(crate::cache::CacheError::Connection("down".into()))
}
async fn exists(&self, _: &str) -> Result<bool, crate::cache::CacheError> {
Err(crate::cache::CacheError::Connection("down".into()))
}
async fn clear(&self) -> Result<(), crate::cache::CacheError> {
Err(crate::cache::CacheError::Connection("down".into()))
}
}
fn down(ip_limit: u32, global_limit: u32) -> LoginThrottle {
let cache: BoxedCache = Arc::new(Down);
on_cache(
&cache,
LoginLimits {
ip_limit,
global_limit,
..LoginLimits::default()
},
)
}
#[tokio::test]
async fn a_cache_outage_keeps_the_per_ip_limit() {
let t = down(1, 10);
let s = tenant("t-down");
t.begin(&s, &ip("10.9.6.1"), "a")
.await
.unwrap()
.failed()
.await;
assert!(t.begin(&s, &ip("10.9.6.1"), "a").await.is_err());
assert!(t.begin(&s, &ip("10.9.6.2"), "b").await.is_ok());
}
#[tokio::test]
async fn a_cache_outage_keeps_the_global_limit() {
let t = down(10, 1);
let s = tenant("t-down-g");
t.begin(&s, &ip("10.9.7.1"), "a")
.await
.unwrap()
.failed()
.await;
assert!(t.begin(&s, &ip("10.9.7.2"), "b").await.is_err());
}
#[tokio::test]
async fn a_cache_outage_refunds_the_fallback() {
let t = down(1, 1);
let s = tenant("t-down-ok");
for n in 0..5 {
let a = t.begin(&s, &ip("10.9.8.1"), &format!("ok{n}")).await;
a.unwrap().succeeded().await;
}
}
#[test]
fn weak_stores_are_reported() {
let limits = LoginLimits::default();
let memory = LoginThrottle::new(limits);
assert!(memory.is_process_local());
assert_eq!(memory.store_warning(), Some(PROCESS_LOCAL_WARNING));
let null: BoxedCache = Arc::new(crate::cache::NullCache);
let null = LoginThrottle::with_cache(limits, null);
assert_eq!(null.store_warning(), Some(NULL_STORE_WARNING));
assert!(LoginThrottle::with_cache(limits, Arc::new(Down))
.store_warning()
.is_none());
}
#[test]
fn refusals_carry_retry_after() {
let r = LoginRefused::Throttled {
retry_after_secs: 9,
}
.into_response();
assert_eq!(r.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
assert_eq!(r.headers()[axum::http::header::RETRY_AFTER], "9");
let r = LoginRefused::Busy.into_response();
assert_eq!(r.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
assert!(r.headers().contains_key(axum::http::header::RETRY_AFTER));
}
}