Files
group_fqcd_jr/app/api/dependencies/rate_limit.py
T
lzf_0626 01ee68034d 新增登录接口:账号密码换访问令牌(POST /api/v1/auth/tokens)
背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
bootstrap.py 里各 Agent 的 allowed_roles 一直是分开的(CustomerServiceAgent 只要
customer、RiskAgent 要 risk_operator/admin、PlatformProbeAgent 只要 admin),
唯独缺"怎么证明你是谁";sys_user.password_hash 字段也一直存在,只是全是占位符
(种子写 'x'、worker 身份写 !worker-only-no-password-login!),从没写过真实密码。

实现:
- app/service/auth_service.py:bcrypt 校验 + 签发只含 sub 的 JWT + 审计。令牌里只放 sub
  是刻意的:角色/权限/数据范围由 IdentityService 每次请求查库解析
  (identity_repository.load_context),权限变更因此立即生效,现有鉴权链路一行未改。
- app/api/controllers/auth.py + app/api/schemas/auth.py:POST /api/v1/auth/tokens,
  响应含 roles/data_scope 供前端决定进哪个界面(鉴权仍以库里实时数据为准)。
- tools/set_user_password.py:设密码(客户 123456 / 员工 666666 / 管理员 88888888)。
  ⚠️ 脚本与文档均标注"仅限演示环境",这三种弱口令上线前必须更换。
- pyproject / requirements 加 bcrypt(cryptography 只用于 JWT,不提供密码哈希)。

安全约定(逐条有实现与测试):失败不区分原因 —— 用户不存在/密码错/账号停用返回同一条
401,否则接口就成了账号枚举器;用户不存在时也跑一次 bcrypt 以抹掉时序差异;
成功与失败都写 interaction_audit(actor_id 可空正是为失败场景准备的);绝不记录密码。

过程中踩到一个自己挖的坑:给登录路由挂了通用的 enforce_rate_limit,而它声明依赖
build_request_context ⇒ 变成"要登录先登录",所有登录都 401。改为新增
enforce_login_rate_limit:按客户端 IP 独立限流(60 秒 10 次)、不依赖认证上下文。
集成测试据此调整:注入恒放行替身隔离跨用例的计数累积,同时保留一个恒超限用例验证闸门
确实会拦 —— 不能因为加了替身就把这道防线测丢。

接口登记:docs/05 §19 加 A034;并更新 §11 —— 那里原写"JWT 签发、刷新、注销由统一身份
认证模块负责,Agent 平台不重复实现",现注明平台只做登录这一步,刷新/注销仍归该模块。

验证:ruff 干净 / mypy 183 文件 0 错 / 文档守卫 37 份无重号(此前因 docs/21 重号失败)/
unit+contract 1140 passed / integration 90 passed。
2026-09-11 20:43:21 +08:00

118 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""限流闸门(文档 §3.6 `RATE_LIMITED`:429、可重试)。
为什么用依赖而不是全局 HTTP 中间件:
- 限流维度是"用户 + 接口",用户来自认证后的 `RequestContext`。全局中间件跑在路由匹配
之前,拿不到上下文,只能退化成按 IP 限流(本地/内网部署里所有客户端常常共用一个
出口 IP,等于没有限流),或者自己再解析一次 JWT(第二套鉴权实现,违反单一入口)。
- 依赖抛的是 `AgentError` 家族异常,直接复用 `app/main.py` 的统一错误信封处理器;
中间件抛出异常会绕过该处理器,只能手写一份响应体,容易与文档 §3.4 漂移。
- 挂在路由上(`APIRouter(dependencies=[...])`)而不是每个函数里手写,新增接口不会
漏掉闸门。
顺序保证:本依赖声明依赖 `build_request_context`,因此**认证永远先于限流**——未带令牌
的请求仍是 401,不会因为限流计数变成 429(否则限流会掩盖鉴权失败)。
降级:后端返回 `None` 表示无法判定(Redis 不可用/未安装/超时),此时**放行**。限流是
保护措施,不能因为 Redis 故障把正常请求全部拒掉;降级只写运行日志,按文档 §11.2 不进
审计(限流拒绝本身也只写日志或指标)。
"""
import logging
from fastapi import Depends, Request
from app.api.dependencies.auth import build_request_context
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.rate_limit import RateLimitExceededError, RateLimitPolicy
from app.infrastructure.rate_limiter import CounterBackend, default_counter_backend
logger = logging.getLogger(__name__)
def get_counter_backend() -> CounterBackend:
"""计数后端工厂:模块级函数是唯一的替换点(测试注入替身,不连 Redis)。"""
return default_counter_backend()
def route_template(request: Request) -> str:
"""计数维度里的"接口"取路由模板,而不是原始 URL。
否则 `GET /agent-runs/{run_id}` 会被拆成无数个独立计数器,限流形同虚设。
"""
path = getattr(request.scope.get("route"), "path", None)
return str(path) if path else request.url.path
async def enforce_rate_limit(
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> None:
policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled:
return
template = route_template(request)
result = await get_counter_backend().increment(
policy.key(context.user_id, request.method, template), policy.window_seconds
)
if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template)
return
count, retry_after_seconds = result
if count > policy.max_requests:
logger.warning(
"触发限流 route=%s count=%s limit=%s", template, count, policy.max_requests
)
raise RateLimitExceededError(
f"请求过于频繁:每 {policy.window_seconds} 秒最多 {policy.max_requests} 次,"
f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds,
)
#: 登录端点的限流参数。比普通接口严得多:普通接口的 `policy.max_requests` 是按"已登录用户
#: 的操作频率"定的,而这里是**密码爆破**的入口,必须独立收紧。
LOGIN_WINDOW_SECONDS = 60
LOGIN_MAX_ATTEMPTS = 10
LOGIN_COUNTER_PREFIX = "login"
async def enforce_login_rate_limit(request: Request) -> None:
"""登录端点专用的限流闸门:按客户端 IP,**不依赖认证上下文**。
为什么不能复用 `enforce_rate_limit`:它声明依赖 `build_request_context`
(见本模块文档"顺序保证"),挂到登录端点就变成"要登录先登录"——登录请求本来
就不带令牌。而爆破恰恰发生在**没有令牌**的时候,所以这里必须另立一个闸门。
维度取客户端 IP + 路由模板:拿不到 `RequestContext.user_id`(那时还没有身份),
用 IP 是唯一可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但登录
端点的价值在于**挡住自动化爆破**,IP 维度足够,且不引入第二套鉴权解析。
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**并告警,
不因为限流组件故障把所有人挡在门外。
"""
policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled:
return
client = request.client.host if request.client is not None else "unknown"
template = route_template(request)
result = await get_counter_backend().increment(
f"{LOGIN_COUNTER_PREFIX}:{client}:{request.method}:{template}",
LOGIN_WINDOW_SECONDS,
)
if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template)
return
count, retry_after_seconds = result
if count > LOGIN_MAX_ATTEMPTS:
logger.warning(
"登录限流 route=%s ip=%s count=%s limit=%s",
template, client, count, LOGIN_MAX_ATTEMPTS,
)
raise RateLimitExceededError(
f"登录尝试过于频繁:每 {LOGIN_WINDOW_SECONDS} 秒最多 {LOGIN_MAX_ATTEMPTS} 次,"
f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds,
)