背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
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。
118 lines
5.4 KiB
Python
118 lines
5.4 KiB
Python
"""限流闸门(文档 §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,
|
||
)
|