use rustlavel::prelude::*;
use std::time::Duration;
use crate::models::user::User;
use crate::support::settings::Settings;
pub const MAX_ATTEMPTS: i64 = 5;
pub const LOCK_FOR: Duration = Duration::from_secs(15 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub attempts: i64,
pub lock_for: Duration,
}
impl Default for Limits {
fn default() -> Self {
Limits { attempts: MAX_ATTEMPTS, lock_for: LOCK_FOR }
}
}
impl Limits {
pub async fn current(req: &Request) -> Limits {
let Some(settings) = req.state::<Settings>() else { return Limits::default() };
let attempts = settings.int("auth.lockout.attempts", MAX_ATTEMPTS).await.max(0);
let minutes = settings
.int("auth.lockout.minutes", LOCK_FOR.as_secs() as i64 / 60)
.await
.clamp(1, 7 * 24 * 60);
Limits { attempts, lock_for: Duration::from_secs(minutes as u64 * 60) }
}
pub fn locks_at(&self, failures: i64) -> bool {
self.attempts > 0 && failures >= self.attempts
}
}
pub const MAX_PER_ADDRESS: u64 = 20;
pub const ADDRESS_WINDOW: Duration = Duration::from_secs(15 * 60);
pub async fn address_is_blocked(req: &Request) -> bool {
let Some(cache) = req.state::<CacheStore>() else { return false };
let key = format!("login-failures:{}", req.ip().unwrap_or_else(|| "unknown".into()));
matches!(
cache.driver_handle().get(&key).await,
Ok(Some(Json::Number(count))) if count as u64 >= MAX_PER_ADDRESS
)
}
pub async fn record_address_failure(req: &Request) {
let Some(cache) = req.state::<CacheStore>() else { return };
let key = format!("login-failures:{}", req.ip().unwrap_or_else(|| "unknown".into()));
let _ = cache.driver_handle().increment_within(&key, 1, ADDRESS_WINDOW).await;
}
pub async fn record_failure(
db: &Database,
user: &mut User,
req: &Request,
now: &str,
) -> Result<bool> {
let limits = Limits::current(req).await;
user.failed_attempts += 1;
if limits.locks_at(user.failed_attempts) {
user.locked_until = Some(crate::support::tokens::in_future(now, limits.lock_for));
user.update(db).await?;
return Ok(true);
}
user.update(db).await?;
Ok(false)
}
pub async fn record_success(db: &Database, user: &mut User, req: &Request, now: &str) -> Result<()> {
user.failed_attempts = 0;
user.locked_until = None;
user.last_login_at = Some(now.to_string());
user.last_login_ip = req.ip();
user.update(db).await?;
if let Some(cache) = req.state::<CacheStore>() {
let key = format!("login-failures:{}", req.ip().unwrap_or_else(|| "unknown".into()));
let _ = cache.driver_handle().forget(&key).await;
}
Ok(())
}
pub fn remaining(locked_until: &str, now: &str) -> String {
let minutes = crate::support::tokens::minutes_between(now, locked_until);
match minutes {
0 => "less than a minute".to_string(),
1 => "1 minute".to_string(),
n => format!("{n} minutes"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::support::tokens;
fn configured(attempts: i64, minutes: u64) -> Limits {
Limits { attempts, lock_for: Duration::from_secs(minutes * 60) }
}
#[test]
fn the_threshold_is_the_configured_count() {
let limits = configured(3, 15);
assert!(!limits.locks_at(2));
assert!(limits.locks_at(3));
assert!(limits.locks_at(4), "a count past the threshold is still locked");
assert_ne!(configured(10, 15).attempts, MAX_ATTEMPTS);
assert!(!configured(10, 15).locks_at(MAX_ATTEMPTS));
assert!(configured(10, 15).locks_at(10));
}
#[test]
fn no_limit_never_locks() {
let limits = configured(0, 15);
for failures in [1, 5, 20, 5_000] {
assert!(!limits.locks_at(failures), "{failures} failures locked an account with no limit");
}
}
#[test]
fn the_fallback_is_the_compiled_in_pair() {
let limits = Limits::default();
assert_eq!(limits.attempts, MAX_ATTEMPTS);
assert_eq!(limits.lock_for, LOCK_FOR);
assert!(limits.locks_at(MAX_ATTEMPTS));
}
#[test]
fn a_locked_account_reports_the_configured_duration() {
let now = "2026-09-03 09:00:00";
for (minutes, expected) in [(5, "5 minutes"), (15, "15 minutes"), (60, "60 minutes")] {
let limits = configured(5, minutes);
let until = tokens::in_future(now, limits.lock_for);
assert_eq!(remaining(&until, now), expected);
}
}
#[test]
fn a_lock_that_has_nearly_run_out_says_so() {
let now = "2026-09-03 09:00:00";
assert_eq!(remaining("2026-09-03 09:00:30", now), "less than a minute");
assert_eq!(remaining("2026-09-03 09:01:00", now), "1 minute");
assert_eq!(remaining("2026-09-03 08:00:00", now), "less than a minute");
}
}