From 27e63275527dfaf2fb98ab792865c325e95dbc87 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:04:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E7=A7=BB=E9=99=A4=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20IP=20=E9=99=90=E6=B5=81=E4=B8=AD=E9=97=B4=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=AD=A3=E5=B8=B8=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=A2=AB=E8=AF=AF=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RateLimitMiddleware 把登录/注册/发码等认证动作和普通 API 都按 IP 计入同一组桶,共享出口 IP 和页面流量会把正常登录误拦成 429。账号级错误密码锁定和发码冷却仍保留。 Fixes #292 Co-authored-by: Cursor --- .../app/src/windup_app/bootstrap/app.py | 4 +- .../windup_app/web/middleware/ratelimit.py | 148 ------------------ 2 files changed, 1 insertion(+), 151 deletions(-) delete mode 100644 backend/packages/app/src/windup_app/web/middleware/ratelimit.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index dfa3c39c..02e744ac 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -33,7 +33,6 @@ from windup_app.web.api.workflow_run import router as workflow_run_router from windup_app.web.handler.exception_handlers import register_exception_handlers from windup_app.web.middleware.auth import AuthMiddleware -from windup_app.web.middleware.ratelimit import RateLimitMiddleware def _env_flag(name: str) -> bool: @@ -88,9 +87,8 @@ def create_app() -> FastAPI: def health() -> dict[str, str]: return {"status": "ok"} - # 中间件(add_middleware 后加的先执行:请求先进 CORS → 再进 RateLimit → 再进 Auth → 最后到路由) + # 中间件(add_middleware 后加的先执行:请求先进 CORS → 再进 Auth → 最后到路由) app.add_middleware(AuthMiddleware) - app.add_middleware(RateLimitMiddleware) app.add_middleware( CORSMiddleware, allow_origins=_cors_origins(), diff --git a/backend/packages/app/src/windup_app/web/middleware/ratelimit.py b/backend/packages/app/src/windup_app/web/middleware/ratelimit.py deleted file mode 100644 index 11a05733..00000000 --- a/backend/packages/app/src/windup_app/web/middleware/ratelimit.py +++ /dev/null @@ -1,148 +0,0 @@ -"""接口限流中间件。 - -基于 Redis 的滑动窗口计数器,在鉴权中间件之前执行。 -Redis 不可用时优雅降级(跳过限流)。 -""" - -import logging - -from fastapi.responses import JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response - -from windup_common.enums.biz_code import BizCode -from windup_common.result import Response as Resp - -logger = logging.getLogger("windup.ratelimit") - -# -- 限流配置 ------------------------------------------------------------ - -# 全局 API 限流:单 IP 60 次/分钟 -GLOBAL_RATE = 60 -GLOBAL_WINDOW = 60 - -# 敏感接口限流:单 IP 10 次/分钟 -SENSITIVE_RATE = 10 -SENSITIVE_WINDOW = 60 - -# 用户级限流:120 次/分钟 -USER_RATE = 120 -USER_WINDOW = 60 - -# 敏感接口路径 -SENSITIVE_PATHS: set[str] = { - "/auth/register", - "/auth/login", - "/auth/send-code", - "/auth/login-by-code", - "/auth/reset-password", -} - -# -- Redis key 模板 ------------------------------------------------------ - -RATELIMIT_API_KEY = "ratelimit:api:{ip}" -RATELIMIT_SENSITIVE_KEY = "ratelimit:sensitive:{ip}" -RATELIMIT_USER_KEY = "ratelimit:api:{user_id}" - - -# 可信代理列表:只有这些来源的请求才信任 X-Forwarded-For -TRUSTED_PROXIES: set[str] = {"127.0.0.1", "::1", "172.16.0.0/12"} - - -def _is_trusted_proxy(host: str | None) -> bool: - """判断请求来源是否在可信代理列表中。""" - if not host: - return False - if host in TRUSTED_PROXIES: - return True - # Docker 网段 172.16.0.0/12 - try: - parts = host.split(".") - if len(parts) == 4 and parts[0] == "172" and 16 <= int(parts[1]) <= 31: - return True - except (ValueError, IndexError): - pass - return False - - -def _get_client_ip(request: Request) -> str: - """获取客户端 IP,仅在可信代理后才信任 X-Forwarded-For。""" - client_host = request.client.host if request.client else None - if _is_trusted_proxy(client_host): - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",")[0].strip() - return client_host or "unknown" - - -def _check_rate(redis_client, key: str, limit: int, window: int) -> bool: - """检查是否超出限流,返回 True 表示允许通过。""" - try: - current = redis_client.incr(key) - if current == 1: - redis_client.expire(key, window) - return current <= limit - except Exception: - # Redis 不可用时跳过限流 - logger.warning("[WINDUP] Redis 不可用,跳过限流检查 | key=%s", key) - return True - - -class RateLimitMiddleware(BaseHTTPMiddleware): - """接口限流中间件。""" - - def __init__(self, app) -> None: - super().__init__(app) - self._redis = None - self._redis_available = True - - @property - def redis(self): - if self._redis is None: - try: - from windup_framework.db.redis import get_redis - self._redis = get_redis() - # 测试连接 - self._redis.ping() - except Exception: - self._redis_available = False - logger.warning("[WINDUP] Redis 连接失败,限流中间件将跳过限流检查") - return None - return self._redis - - async def dispatch(self, request: Request, call_next) -> Response: - # Redis 不可用时直接放行 - if not self._redis_available or self.redis is None: - return await call_next(request) - - client_ip = _get_client_ip(request) - - # 全局限流 - if not _check_rate(self.redis, RATELIMIT_API_KEY.format(ip=client_ip), GLOBAL_RATE, GLOBAL_WINDOW): - logger.warning("[WINDUP] 全局限流触发 | ip=%s path=%s", client_ip, request.url.path) - return JSONResponse( - status_code=200, - content=Resp.fail("请求过于频繁", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"), - ) - - # 敏感接口额外限流 - if request.url.path in SENSITIVE_PATHS: - if not _check_rate(self.redis, RATELIMIT_SENSITIVE_KEY.format(ip=client_ip), SENSITIVE_RATE, SENSITIVE_WINDOW): - logger.warning("[WINDUP] 敏感接口限流触发 | ip=%s path=%s", client_ip, request.url.path) - return JSONResponse( - status_code=200, - content=Resp.fail("请求过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"), - ) - - # 用户级限流(已登录用户) - user_id = getattr(getattr(request.state, "current_user", None), "id", None) - if user_id is not None: - if not _check_rate(self.redis, RATELIMIT_USER_KEY.format(user_id=user_id), USER_RATE, USER_WINDOW): - logger.warning("[WINDUP] 用户限流触发 | user_id=%s", user_id) - return JSONResponse( - status_code=200, - content=Resp.fail("请求过于频繁", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"), - ) - - return await call_next(request)