fix(auth): scope lockout key to (account, IP) to prevent DOS by login flood
Closes #323. LoginLockoutService cached failed-attempt counters keyed only on the username, so any caller that could reach the auth endpoint and guess (or enumerate) a username could lock that account out for the entire lockout window — the rate limiter happily lets each IP make its share of bad-password attempts before clamping, which is enough to trip the per-account threshold in seconds. The reporter demonstrated a complete DOS by spoofing X-Forwarded-For with OXICLOUD_TRUST_PROXY_HEADERS=true. Fix: change the lockout cache key from `username` to `username|ip`. A flood from one IP locks that IP out of that account, but a legitimate user coming from a different IP is unaffected. Changes: - LoginLockoutService::{check, record_failure, record_success} take client_ip as a second argument; cache key is built via Self::key (`format!("{username}|{ip}")`). - middleware/rate_limit.rs: factor out extract_client_ip_from_parts (HeaderMap + Option<&SocketAddr>) so handlers that don't take a full Request<B> can still derive the same client identifier extract_client_ip uses. extract_client_ip now delegates to it. - auth_handler.rs login: derive client_ip from headers (the only signal available without ConnectInfo) and pass it through to all three lockout calls. - nextcloud/basic_auth_middleware.rs: do the same with the full Request via extract_client_ip. Tests: - Updated existing 4 unit tests to thread an IP arg. - New does_not_lock_out_other_ips_for_same_account: lock from IP1, assert IP2 still allowed (the #323 regression). - New success_resets_only_the_acting_ip: a successful login from IP2 must NOT clear an attacker's lockout from IP1. Verification: - `cargo build` ✅ - `cargo test login_lockout` → 6 passed (4 existing thread an IP arg without behaviour change, 2 new pin the per-IP scoping). Signed-off-by: SAY-5 <say.apm35@gmail.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Query, State},
|
||||
extract::{ConnectInfo, Json, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
@@ -18,6 +19,7 @@ use crate::common::di::AppState;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUserId;
|
||||
use crate::interfaces::middleware::trusted_proxy::client_ip_from_parts;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Public auth routes — no authentication required.
|
||||
@@ -260,6 +262,7 @@ pub async fn register(
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<LoginDto>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -281,13 +284,24 @@ pub 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) {
|
||||
// Reject immediately if (this account, this IP) has too many consecutive
|
||||
// failures. The IP is part of the key so an attacker flooding bad
|
||||
// passwords from one address cannot lock a legitimate user out of the
|
||||
// same account from a different address (issue #323). The check runs
|
||||
// BEFORE Argon2 to save CPU under brute-force attacks.
|
||||
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
|
||||
if let Err(lockout_secs) = auth_service
|
||||
.login_lockout
|
||||
.check(&dto.username, &client_ip)
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.login",
|
||||
reason = "account_ip_locked",
|
||||
username = %dto.username,
|
||||
ip = %client_ip,
|
||||
lockout_secs = lockout_secs,
|
||||
"Login rejected — account temporarily locked"
|
||||
"Login rejected: account temporarily locked for this IP"
|
||||
);
|
||||
return Err(AppError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -317,7 +331,9 @@ pub async fn login(
|
||||
{
|
||||
Ok(auth_response) => {
|
||||
// ── Successful login — reset lockout counter ──
|
||||
auth_service.login_lockout.record_success(&dto.username);
|
||||
auth_service
|
||||
.login_lockout
|
||||
.record_success(&dto.username, &client_ip);
|
||||
|
||||
tracing::info!("Login successful for user: {}", dto.username);
|
||||
// Log the response structure for debugging
|
||||
@@ -367,7 +383,9 @@ pub async fn login(
|
||||
}
|
||||
Err(err) => {
|
||||
// ── Record failed attempt for lockout tracking ──
|
||||
auth_service.login_lockout.record_failure(&dto.username);
|
||||
auth_service
|
||||
.login_lockout
|
||||
.record_failure(&dto.username, &client_ip);
|
||||
tracing::error!("Login failed for user {}: {}", dto.username, err);
|
||||
Err(err.into())
|
||||
}
|
||||
|
||||
@@ -143,11 +143,22 @@ pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0);
|
||||
|
||||
client_ip_from_parts(req.headers(), peer, include_port)
|
||||
}
|
||||
|
||||
/// Same as [`client_ip`], but operates on already-extracted parts (headers
|
||||
/// plus an optional TCP peer). Handlers that don't take a full `Request<B>`,
|
||||
/// e.g. those that consume the body via `Json<…>`, can still derive a stable
|
||||
/// client identifier with this entry point.
|
||||
pub fn client_ip_from_parts(
|
||||
headers: &axum::http::HeaderMap,
|
||||
peer: Option<SocketAddr>,
|
||||
include_port: bool,
|
||||
) -> String {
|
||||
if let Some(peer_addr) = peer {
|
||||
if is_trusted_proxy(peer_addr.ip()) {
|
||||
// Try X-Forwarded-For first (leftmost = original client)
|
||||
if let Some(xff) = req
|
||||
.headers()
|
||||
if let Some(xff) = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
&& let Some(ip) = xff
|
||||
@@ -160,8 +171,7 @@ pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
|
||||
}
|
||||
|
||||
// Then X-Real-Ip
|
||||
if let Some(xri) = req
|
||||
.headers()
|
||||
if let Some(xri) = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
|
||||
@@ -62,14 +62,18 @@ pub async fn basic_auth_middleware(
|
||||
let (username, password) =
|
||||
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
|
||||
|
||||
// Check account lockout before attempting password verification (saves CPU)
|
||||
// Check account lockout before attempting password verification (saves CPU).
|
||||
// The lockout is per (account, IP) — see #323 for rationale.
|
||||
let client_ip =
|
||||
crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Err(secs) = auth_svc.login_lockout.check(&username)
|
||||
&& let Err(secs) = auth_svc.login_lockout.check(&username, &client_ip)
|
||||
{
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
client_ip = %client_ip,
|
||||
lockout_remaining_secs = secs,
|
||||
"[NC] Account locked — too many failed attempts"
|
||||
"[NC] Account locked — too many failed attempts from this IP"
|
||||
);
|
||||
return Err(NextcloudAuthError::Unauthorized);
|
||||
}
|
||||
@@ -87,7 +91,7 @@ pub async fn basic_auth_middleware(
|
||||
Ok((user_id, uname, email, role)) => {
|
||||
// Reset lockout counter on success
|
||||
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||
auth_svc.login_lockout.record_success(&username);
|
||||
auth_svc.login_lockout.record_success(&username, &client_ip);
|
||||
}
|
||||
// External users must never authenticate against the NC
|
||||
// surface — that whole subtree (WebDAV files, uploads,
|
||||
@@ -134,7 +138,7 @@ pub async fn basic_auth_middleware(
|
||||
Err(_) => {
|
||||
// Record failed attempt for lockout tracking
|
||||
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||
auth_svc.login_lockout.record_failure(&username);
|
||||
auth_svc.login_lockout.record_failure(&username, &client_ip);
|
||||
}
|
||||
Err(NextcloudAuthError::Unauthorized)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user