fix(server): 限流中间件 fail-close 安全加固
RateLimitConfig 添加 fail_close 字段(默认 true),Redis 不可达时 拒绝请求返回 503 而非静默放行。开发环境可通过 ERP__RATE_LIMIT__FAIL_CLOSE=false 回退旧行为。
This commit is contained in:
@@ -66,4 +66,4 @@ secret_key = "dev-only-secret-key-change-in-production"
|
||||
[rate_limit]
|
||||
# Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。
|
||||
# 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false
|
||||
fail_close = false
|
||||
fail_close = true
|
||||
|
||||
@@ -159,8 +159,26 @@ impl StorageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct RateLimitConfig {}
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Redis 不可达时是否拒绝请求(fail-close)。
|
||||
/// true = 安全优先,Redis 故障时返回 503。
|
||||
/// false = 可用性优先,Redis 故障时放行。
|
||||
#[serde(default = "default_fail_close")]
|
||||
pub fail_close: bool,
|
||||
}
|
||||
|
||||
fn default_fail_close() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fail_close: default_fail_close(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
|
||||
@@ -29,7 +29,20 @@ pub async fn rate_limit_by_ip(
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = extract_client_ip(req.headers());
|
||||
apply_rate_limit(&state.redis, &identifier, 5, 60, "login", req, next).await
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 5,
|
||||
window_secs: 60,
|
||||
prefix: "login",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的用户限流中间件。
|
||||
@@ -45,25 +58,57 @@ pub async fn rate_limit_by_user(
|
||||
.get::<erp_core::types::TenantContext>()
|
||||
.map(|ctx| ctx.user_id.to_string())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
apply_rate_limit(&state.redis, &identifier, 300, 60, "api", req, next).await
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 300,
|
||||
window_secs: 60,
|
||||
prefix: "api",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Redis 不可达时的安全响应(fail-close 模式)。
|
||||
fn service_unavailable(prefix: &str) -> Response {
|
||||
let body = RateLimitResponse {
|
||||
error: "Service Unavailable".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
tracing::error!("Redis 不可达,fail-close 模式拒绝请求 [{}]", prefix);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response()
|
||||
}
|
||||
|
||||
/// 限流参数,打包以避免函数签名过长。
|
||||
struct RateLimitParams<'a> {
|
||||
redis_client: &'a redis::Client,
|
||||
fail_close: bool,
|
||||
max_requests: u64,
|
||||
window_secs: u64,
|
||||
prefix: &'a str,
|
||||
}
|
||||
|
||||
/// 执行限流检查。
|
||||
async fn apply_rate_limit(
|
||||
redis_client: &redis::Client,
|
||||
params: RateLimitParams<'_>,
|
||||
identifier: &str,
|
||||
max_requests: u64,
|
||||
window_secs: u64,
|
||||
prefix: &str,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let key = format!("rate_limit:{}:{}", prefix, identifier);
|
||||
let key = format!("rate_limit:{}:{}", params.prefix, identifier);
|
||||
|
||||
let mut conn = match redis_client.get_multiplexed_async_connection().await {
|
||||
let mut conn = match params.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis 连接失败,限流放行 [{}]", prefix);
|
||||
tracing::error!(error = %e, "Redis 连接失败 [{}]", params.prefix);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
@@ -71,17 +116,20 @@ async fn apply_rate_limit(
|
||||
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis INCR 失败,限流放行 [{}]", prefix);
|
||||
tracing::error!(error = %e, "Redis INCR 失败 [{}]", params.prefix);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次请求设置 TTL
|
||||
if count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&key, window_secs as i64).await;
|
||||
let _: Result<(), _> = conn.expire(&key, params.window_secs as i64).await;
|
||||
}
|
||||
|
||||
if count > max_requests as i64 {
|
||||
if count > params.max_requests as i64 {
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "请求过于频繁,请稍后重试".to_string(),
|
||||
@@ -104,11 +152,16 @@ pub async fn account_lockout_middleware(
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
|
||||
// 获取 Redis 连接
|
||||
let mut conn = match state.redis.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis 连接失败,登录锁定放行");
|
||||
tracing::error!(error = %e, "Redis 连接失败");
|
||||
if fail_close {
|
||||
return service_unavailable("login_lockout");
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user