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:
Dionisio
2026-03-03 01:44:39 +01:00
parent d2c08d31ba
commit 1df52fd702
11 changed files with 558 additions and 17 deletions
+38 -3
View File
@@ -19,9 +19,6 @@ use crate::interfaces::middleware::auth::CurrentUserId;
pub fn auth_routes() -> Router<Arc<AppState>> {
// Routes that do NOT require authentication
let public_routes = Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/refresh", post(refresh_token))
.route("/status", get(get_system_status))
// OIDC endpoints (all public)
.route("/oidc/providers", get(oidc_providers))
@@ -40,6 +37,20 @@ pub fn auth_routes() -> Router<Arc<AppState>> {
public_routes.merge(protected_routes)
}
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
/// rate limiting middleware independently.
pub fn login_route() -> Router<Arc<AppState>> {
Router::new().route("/login", post(login))
}
pub fn register_route() -> Router<Arc<AppState>> {
Router::new().route("/register", post(register))
}
pub fn refresh_route() -> Router<Arc<AppState>> {
Router::new().route("/refresh", post(refresh_token))
}
async fn register(
State(state): State<Arc<AppState>>,
Json(dto): Json<RegisterDto>,
@@ -123,6 +134,25 @@ async fn login(
}
};
// ── Account lockout check ──────────────────────────────────────────
// Reject immediately if the account has too many consecutive failures.
// This runs BEFORE Argon2 to save CPU under brute-force attacks.
if let Err(lockout_secs) = auth_service.login_lockout.check(&dto.username) {
tracing::warn!(
username = %dto.username,
lockout_secs = lockout_secs,
"Login rejected — account temporarily locked"
);
return Err(AppError::new(
StatusCode::TOO_MANY_REQUESTS,
&format!(
"Account temporarily locked due to too many failed attempts. Try again in {} seconds.",
lockout_secs
),
"AccountLocked",
));
}
// Check if password login is disabled (OIDC-only mode)
if auth_service
.auth_application_service
@@ -140,6 +170,9 @@ async fn login(
.await
{
Ok(auth_response) => {
// ── Successful login — reset lockout counter ──
auth_service.login_lockout.record_success(&dto.username);
tracing::info!("Login successful for user: {}", dto.username);
// Log the response structure for debugging
tracing::debug!("Auth response: {:?}", &auth_response);
@@ -171,6 +204,8 @@ async fn login(
Ok(response)
}
Err(err) => {
// ── Record failed attempt for lockout tracking ──
auth_service.login_lockout.record_failure(&dto.username);
tracing::error!("Login failed for user {}: {}", dto.username, err);
Err(err.into())
}