fix(api): bucket admin login throttling by client ip

The AdminLoginSource extension was never injected, so every login request
fell back to the shared "unknown" failure bucket: five wrong passwords from
any unauthenticated visitor locked the real administrator out for fifteen
minutes, renewable indefinitely. Attach a login-route middleware that keys
the bucket on the socket peer IP taken from ConnectInfo; forwarded headers
are client-forgeable and stay excluded, so reverse-proxy deployments retain
the previous shared-bucket behavior. Add a router-level regression test
proving throttling of one source leaves other sources unaffected.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zyycn
2026-07-26 23:18:26 +08:00
co-authored by Claude
parent 237ba96233
commit 495c19b5e4
3 changed files with 398 additions and 3 deletions
+23 -3
View File
@@ -1,11 +1,12 @@
//! 管理员登录、会话与管理请求鉴权接线。
use std::fmt;
use std::{fmt, net::SocketAddr};
use axum::{
Extension, Json, Router,
extract::{FromRequestParts, State},
extract::{ConnectInfo, FromRequestParts, Request, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
};
@@ -207,11 +208,30 @@ where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/auth/login", post(login::<S>))
.route(
"/api/admin/auth/login",
post(login::<S>).layer(middleware::from_fn(attach_login_source)),
)
.route("/api/admin/auth/status", get(session_status::<S>))
.route("/api/admin/auth/logout", post(logout::<S>))
}
/// 登录失败限流以 socket 对端 IP 为计数桶,使不同来源的失败互不影响;
/// 转发头(X-Forwarded-For 等)可由客户端伪造,不参与来源判定。
/// 经反向代理部署时所有请求同源于代理地址,退化为共享单桶。
async fn attach_login_source(mut request: Request, next: Next) -> Response {
if let Some(ConnectInfo(peer)) = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.copied()
{
request
.extensions_mut()
.insert(AdminLoginSource::new(peer.ip().to_string()));
}
next.run(request).await
}
async fn login<S>(
State(state): State<S>,
source: Option<Extension<AdminLoginSource>>,
@@ -172,3 +172,57 @@ async fn audit_failure_should_revoke_new_session_before_returning_it() {
);
assert_eq!(fixture.auth.session_count(), 0);
}
#[tokio::test]
async fn login_route_should_bucket_throttling_by_client_ip() {
use std::net::SocketAddr;
use axum::{body::Body, extract::ConnectInfo, http::Request};
use tower::ServiceExt as _;
#[derive(Clone)]
struct AuthState(gateway_admin::AdminServices);
impl gateway_api::admin::AdminSessionState for AuthState {
fn admin_services(&self) -> &gateway_admin::AdminServices {
&self.0
}
}
let fixture = AdminTestFixture::new().await;
let app = gateway_api::admin::auth::router::<AuthState>()
.with_state(AuthState(fixture.services.clone()));
let failed_login_from = |ip: [u8; 4]| {
let app = app.clone();
async move {
let mut request = Request::builder()
.method("POST")
.uri("/api/admin/auth/login")
.header("content-type", "application/json")
.body(Body::from(
r#"{"username":"admin_1","password":"wrong-password"}"#,
))
.expect("build login request");
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from((ip, 50_000))));
app.oneshot(request).await.expect("login response").status()
}
};
for _ in 0..4 {
assert_eq!(
failed_login_from([10, 0, 0, 1]).await,
axum::http::StatusCode::UNAUTHORIZED
);
}
assert_eq!(
failed_login_from([10, 0, 0, 1]).await,
axum::http::StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(
failed_login_from([10, 0, 0, 2]).await,
axum::http::StatusCode::UNAUTHORIZED
);
}