security: add IP rate limiting + account lockout on auth endpoints
- Rate limit login (5/min), register (3/hr), refresh (10/min) per IP - Account lockout after 5 consecutive failed logins (15 min cooldown) - Fix stored XSS in admin panel (escapeHtml on all user-controlled data) - All limits configurable via OXICLOUD_RATE_LIMIT_* / OXICLOUD_LOCKOUT_* env vars - Zero new dependencies (uses existing moka crate for in-memory caches) - Includes unit tests for lockout service
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
//! Account lockout service — blocks login for an account after N consecutive
|
||||
//! failed attempts.
|
||||
//!
|
||||
//! Uses a `moka` TTL cache so that:
|
||||
//! * Failed-attempt counters automatically expire after the lockout window.
|
||||
//! * No database writes are needed — this is **in-memory** and therefore
|
||||
//! per-instance. If OxiCloud is deployed behind a load balancer with
|
||||
//! multiple replicas, a sticky-session or shared Redis store would be
|
||||
//! needed for cross-instance coordination (out of scope for v1).
|
||||
//!
|
||||
//! Typical flow:
|
||||
//! 1. **Before password verification** → call [`LoginLockoutService::check`].
|
||||
//! If the account is locked, return `403` immediately without touching
|
||||
//! Argon2 (saves CPU).
|
||||
//! 2. **After failed verification** → call [`LoginLockoutService::record_failure`].
|
||||
//! 3. **After successful login** → call [`LoginLockoutService::record_success`]
|
||||
//! to reset the counter.
|
||||
|
||||
use moka::sync::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Tracks consecutive failures for a single username.
|
||||
#[derive(Clone, Debug)]
|
||||
struct FailureRecord {
|
||||
/// Number of consecutive failed attempts.
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// In-memory account lockout tracker.
|
||||
#[derive(Clone)]
|
||||
pub struct LoginLockoutService {
|
||||
/// Maps `username -> FailureRecord`. TTL = lockout window.
|
||||
cache: Cache<String, FailureRecord>,
|
||||
/// Maximum consecutive failures before the account is temporarily locked.
|
||||
max_failures: u32,
|
||||
/// How long the lockout lasts (seconds).
|
||||
lockout_secs: u64,
|
||||
}
|
||||
|
||||
impl LoginLockoutService {
|
||||
/// Create a new lockout service.
|
||||
///
|
||||
/// * `max_failures` — e.g. `5` (lock after 5 bad passwords)
|
||||
/// * `lockout_secs` — e.g. `900` (15-minute lockout)
|
||||
/// * `max_accounts` — upper bound on tracked accounts (evicts LRU)
|
||||
pub fn new(max_failures: u32, lockout_secs: u64, max_accounts: u64) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.time_to_live(Duration::from_secs(lockout_secs))
|
||||
.max_capacity(max_accounts)
|
||||
.build();
|
||||
Self {
|
||||
cache,
|
||||
max_failures,
|
||||
lockout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the account is currently locked.
|
||||
///
|
||||
/// Returns `Ok(())` if the user may attempt login, or
|
||||
/// `Err(remaining_secs)` with the *approximate* remaining lockout time.
|
||||
pub fn check(&self, username: &str) -> Result<(), u64> {
|
||||
if let Some(rec) = self.cache.get(&username.to_lowercase()) {
|
||||
if rec.count >= self.max_failures {
|
||||
// The entry exists and is over the threshold. Because moka
|
||||
// evicts at TTL we know the lockout window has not yet elapsed.
|
||||
return Err(self.lockout_secs);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a failed login attempt. Returns the new failure count.
|
||||
pub fn record_failure(&self, username: &str) -> u32 {
|
||||
let key = username.to_lowercase();
|
||||
let new_count = self
|
||||
.cache
|
||||
.get(&key)
|
||||
.map(|r| r.count + 1)
|
||||
.unwrap_or(1);
|
||||
self.cache.insert(key.clone(), FailureRecord { count: new_count });
|
||||
|
||||
if new_count >= self.max_failures {
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
attempts = new_count,
|
||||
lockout_secs = self.lockout_secs,
|
||||
"Account temporarily locked after {} consecutive failed login attempts",
|
||||
new_count,
|
||||
);
|
||||
}
|
||||
new_count
|
||||
}
|
||||
|
||||
/// Record a successful login — resets the failure counter.
|
||||
pub fn record_success(&self, username: &str) {
|
||||
self.cache.invalidate(&username.to_lowercase());
|
||||
}
|
||||
|
||||
/// Maximum failures before lockout (used to inform callers / error messages).
|
||||
pub fn max_failures(&self) -> u32 {
|
||||
self.max_failures
|
||||
}
|
||||
|
||||
/// Lockout duration in seconds.
|
||||
pub fn lockout_secs(&self) -> u64 {
|
||||
self.lockout_secs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allows_login_under_threshold() {
|
||||
let svc = LoginLockoutService::new(3, 60, 100);
|
||||
assert!(svc.check("alice").is_ok());
|
||||
svc.record_failure("alice");
|
||||
svc.record_failure("alice");
|
||||
// 2 failures — still under threshold
|
||||
assert!(svc.check("alice").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locks_after_threshold() {
|
||||
let svc = LoginLockoutService::new(3, 60, 100);
|
||||
svc.record_failure("bob");
|
||||
svc.record_failure("bob");
|
||||
svc.record_failure("bob");
|
||||
assert!(svc.check("bob").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resets_on_success() {
|
||||
let svc = LoginLockoutService::new(3, 60, 100);
|
||||
svc.record_failure("carol");
|
||||
svc.record_failure("carol");
|
||||
svc.record_success("carol");
|
||||
// Counter reset — should be allowed again
|
||||
assert!(svc.check("carol").is_ok());
|
||||
svc.record_failure("carol"); // starts over at 1
|
||||
assert!(svc.check("carol").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive() {
|
||||
let svc = LoginLockoutService::new(2, 60, 100);
|
||||
svc.record_failure("Dave");
|
||||
svc.record_failure("dave");
|
||||
assert!(svc.check("DAVE").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod chunked_upload_service;
|
||||
pub mod compression_service;
|
||||
pub mod dedup_service;
|
||||
pub mod login_lockout_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod image_transcode_service;
|
||||
|
||||
Reference in New Issue
Block a user