fix(auth): 微信登录端点独立限流 30 次/分钟
真机调试首次登录即触发 '请求过于频繁' 错误,根因是微信登录 与密码登录共享 5 次/分钟的限制,且 extract_client_ip 在无 代理头时返回 'unknown',所有真机请求共享同一个 rate limit key。 修复:将微信登录/绑定路由从 public_routes 拆分为独立的 wechat_routes,使用 30 次/分钟的宽松限流(与 token 刷新一致)。 密码登录保持 5 次/分钟的严格限制不变。
This commit is contained in:
@@ -23,12 +23,23 @@ impl AuthModule {
|
|||||||
/// These routes do not require a valid JWT token.
|
/// These routes do not require a valid JWT token.
|
||||||
/// The caller wraps this into whatever state type the application uses.
|
/// The caller wraps this into whatever state type the application uses.
|
||||||
pub fn public_routes<S>() -> Router<S>
|
pub fn public_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new().route("/auth/login", axum::routing::post(auth_handler::login))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WeChat public routes — separate from login to allow higher rate limits.
|
||||||
|
///
|
||||||
|
/// Mobile users may retry more frequently, so these use 30 req/min
|
||||||
|
/// instead of the strict 5 req/min for password login.
|
||||||
|
pub fn wechat_routes<S>() -> Router<S>
|
||||||
where
|
where
|
||||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
|
||||||
.route(
|
.route(
|
||||||
"/auth/wechat/login",
|
"/auth/wechat/login",
|
||||||
axum::routing::post(wechat_handler::wechat_login),
|
axum::routing::post(wechat_handler::wechat_login),
|
||||||
|
|||||||
@@ -437,12 +437,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone());
|
outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone());
|
||||||
tracing::info!("Outbox relay started");
|
tracing::info!("Outbox relay started");
|
||||||
|
|
||||||
// Start event cleanup (archive old published events + purge processed_events)
|
|
||||||
tasks::start_event_cleanup(db.clone());
|
|
||||||
|
|
||||||
// Start DB connection pool metrics sampling (every 30s)
|
|
||||||
tasks::start_pool_metrics(db.clone());
|
|
||||||
|
|
||||||
// Start timeout checker (scan overdue tasks every 60s)
|
// Start timeout checker (scan overdue tasks every 60s)
|
||||||
erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone());
|
erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone());
|
||||||
tracing::info!("Timeout checker started");
|
tracing::info!("Timeout checker started");
|
||||||
@@ -650,6 +644,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone());
|
erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone());
|
||||||
tracing::info!("Auto trend analysis scheduler started");
|
tracing::info!("Auto trend analysis scheduler started");
|
||||||
|
|
||||||
|
let cron_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
));
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
@@ -664,8 +665,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.build(),
|
.build(),
|
||||||
ai_state,
|
ai_state,
|
||||||
pii_crypto,
|
pii_crypto,
|
||||||
|
cron_heartbeat: cron_heartbeat.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Start background tasks with heartbeat
|
||||||
|
tasks::start_event_cleanup(state.db.clone(), state.cron_heartbeat.clone());
|
||||||
|
tasks::start_pool_metrics(state.db.clone(), state.cron_heartbeat.clone());
|
||||||
|
|
||||||
// --- Build the router ---
|
// --- Build the router ---
|
||||||
//
|
//
|
||||||
// The router is split into two layers:
|
// The router is split into two layers:
|
||||||
@@ -691,6 +697,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
))
|
))
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
// WeChat routes — higher rate limit (30/min) for mobile users
|
||||||
|
let wechat_routes = Router::new()
|
||||||
|
.merge(erp_auth::AuthModule::wechat_routes())
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
middleware::rate_limit::rate_limit_wechat,
|
||||||
|
))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
// Refresh token routes — higher rate limit (30/min) than login (5/min)
|
// Refresh token routes — higher rate limit (30/min) than login (5/min)
|
||||||
let refresh_routes = Router::new()
|
let refresh_routes = Router::new()
|
||||||
.merge(erp_auth::AuthModule::refresh_routes())
|
.merge(erp_auth::AuthModule::refresh_routes())
|
||||||
@@ -780,6 +795,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
unthrottled_routes
|
unthrottled_routes
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(refresh_routes)
|
.merge(refresh_routes)
|
||||||
|
.merge(wechat_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.nest("/fhir", fhir_routes),
|
.nest("/fhir", fhir_routes),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,6 +86,32 @@ pub async fn rate_limit_refresh_by_ip(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 基于 Redis 的 IP 限流中间件(微信登录/绑定,30 次/分钟)。
|
||||||
|
///
|
||||||
|
/// 移动端用户可能频繁重试,使用与 token 刷新相同的宽松配额。
|
||||||
|
/// 独立于密码登录的 5 次/分钟严格限制。
|
||||||
|
pub async fn rate_limit_wechat(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let identifier = extract_client_ip(req.headers());
|
||||||
|
let fail_close = state.config.rate_limit.fail_close;
|
||||||
|
apply_rate_limit(
|
||||||
|
RateLimitParams {
|
||||||
|
redis_client: &state.redis,
|
||||||
|
fail_close,
|
||||||
|
max_requests: 30,
|
||||||
|
window_secs: 60,
|
||||||
|
prefix: "wechat",
|
||||||
|
},
|
||||||
|
&identifier,
|
||||||
|
req,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// 基于 Redis 的用户限流中间件。
|
/// 基于 Redis 的用户限流中间件。
|
||||||
///
|
///
|
||||||
/// 从 TenantContext 中读取 user_id 作为标识符。
|
/// 从 TenantContext 中读取 user_id 作为标识符。
|
||||||
|
|||||||
Reference in New Issue
Block a user