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 crate::rate_limit::RateLimitLayer;
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 per_request(&self) -> bool {
matches!(self, Self::TenantBasic(_) | Self::TenantApiKey(_))
}
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: RateLimitLayer,
global: RateLimitLayer,
}
pub struct LoginThrottle(Arc<Buckets>);
impl LoginThrottle {
#[must_use]
pub fn new(limits: LoginLimits) -> Self {
Self(Arc::new(Buckets {
limits,
ip: RateLimitLayer::per_ip(limits.ip_limit.max(1), limits.ip_window),
global: RateLimitLayer::global(limits.global_limit.max(1), limits.global_window),
}))
}
#[must_use]
pub fn limits(&self) -> LoginLimits {
self.0.limits
}
pub async fn begin(
&self,
scope: &LoginScope,
ip: &ClientIp,
username: &str,
) -> Result<LoginAttempt, LoginRefused> {
let throttled = |retry_after_secs| LoginRefused::Throttled { retry_after_secs };
let scope_key = scope.key();
let per_request = scope.per_request();
let mut keys = Vec::with_capacity(2);
if scope.locks_accounts() {
let key = account_key(&scope_key, username);
check_lock(&key).await?;
keys.push(key);
}
let b = &self.0;
if let Some(ip) = &ip.0 {
if per_request {
b.ip.peek(ip).await.map_err(throttled)?;
} else {
b.ip.take(ip).await.map_err(throttled)?;
}
}
let global = if per_request {
b.global.peek(&scope_key).await
} else {
b.global.take(&scope_key).await.map(|_| ())
};
if let Err(secs) = global {
if let (false, Some(ip)) = (per_request, &ip.0) {
b.ip.give_back(ip).await;
}
return Err(throttled(secs));
}
Ok(LoginAttempt {
buckets: Arc::clone(b),
scope_key,
ip: ip.0.clone(),
per_request,
keys,
})
}
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)
}
}
async fn check_lock(key: &str) -> Result<(), LoginRefused> {
let lockout = crate::account_lockout::shared();
crate::account_lockout::warn_once_on_weak_store(lockout);
if lockout.is_locked(key).await {
return Err(LoginRefused::Throttled {
retry_after_secs: lockout.lock_duration().as_secs().max(1),
});
}
Ok(())
}
#[must_use = "report the outcome, or failures never lock the account"]
pub struct LoginAttempt {
buckets: Arc<Buckets>,
scope_key: String,
ip: Option<String>,
per_request: bool,
keys: Vec<String>,
}
impl LoginAttempt {
pub async fn resolve(&mut self, stored_username: &str) -> Result<(), LoginRefused> {
if self.keys.is_empty() {
return Ok(());
}
let key = account_key(&self.scope_key, stored_username);
if !self.keys.contains(&key) {
check_lock(&key).await?;
self.keys.push(key);
}
Ok(())
}
pub async fn failed(self) {
let lockout = crate::account_lockout::shared();
for key in &self.keys {
let _ = lockout.record_failure(key).await;
}
if self.per_request {
if let Some(ip) = &self.ip {
let _ = self.buckets.ip.take(ip).await;
}
let _ = self.buckets.global.take(&self.scope_key).await;
}
}
pub async fn succeeded(self) {
let lockout = crate::account_lockout::shared();
for key in &self.keys {
if lockout.attempt_count(key).await > 0 {
lockout.clear(key).await;
}
}
self.give_back().await;
}
pub async fn prompted(self) {
self.give_back().await;
}
async fn give_back(&self) {
if !self.per_request {
if let Some(ip) = &self.ip {
self.buckets.ip.give_back(ip).await;
}
self.buckets.global.give_back(&self.scope_key).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 are counted per process: with N replicas each limit is N times \
higher; set `[auth] login_*` per replica or rate-limit logins at the proxy";
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());
let mut held = Vec::new();
for _ in 0..5 {
held.push(t.begin(&s, &ip("10.9.3.1"), "").await);
}
for a in held {
a.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());
}
#[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));
}
}