fix(auth): 微信登录端点独立限流 30 次/分钟

真机调试首次登录即触发 '请求过于频繁' 错误,根因是微信登录
与密码登录共享 5 次/分钟的限制,且 extract_client_ip 在无
代理头时返回 'unknown',所有真机请求共享同一个 rate limit key。

修复:将微信登录/绑定路由从 public_routes 拆分为独立的
wechat_routes,使用 30 次/分钟的宽松限流(与 token 刷新一致)。
密码登录保持 5 次/分钟的严格限制不变。
This commit is contained in:
iven
2026-06-05 16:33:42 +08:00
parent 976b9d94a0
commit 01a0fffc43
3 changed files with 60 additions and 7 deletions

View File

@@ -23,12 +23,23 @@ impl AuthModule {
/// These routes do not require a valid JWT token.
/// The caller wraps this into whatever state type the application uses.
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
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/login", axum::routing::post(auth_handler::login))
.route(
"/auth/wechat/login",
axum::routing::post(wechat_handler::wechat_login),

View File

@@ -437,12 +437,6 @@ async fn main() -> anyhow::Result<()> {
outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone());
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)
erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone());
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());
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 {
db,
config,
@@ -664,8 +665,13 @@ async fn main() -> anyhow::Result<()> {
.build(),
ai_state,
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 ---
//
// The router is split into two layers:
@@ -691,6 +697,15 @@ async fn main() -> anyhow::Result<()> {
))
.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)
let refresh_routes = Router::new()
.merge(erp_auth::AuthModule::refresh_routes())
@@ -780,6 +795,7 @@ async fn main() -> anyhow::Result<()> {
unthrottled_routes
.merge(public_routes)
.merge(refresh_routes)
.merge(wechat_routes)
.merge(protected_routes)
.nest("/fhir", fhir_routes),
)

View File

@@ -86,6 +86,32 @@ pub async fn rate_limit_refresh_by_ip(
.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 的用户限流中间件。
///
/// 从 TenantContext 中读取 user_id 作为标识符。