From e4cd336afa6e62aa49a2ebdc9f255aa711eb9010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 20:14:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9D=83=E9=99=90=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=8F=98=20500=E3=80=81=E8=AE=BF=E5=AE=A2=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E6=97=A0=E9=99=90=E6=B5=81=EF=BC=8C=E5=B9=B6=E8=AE=A9?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E6=B5=8B=E8=AF=95=E8=B7=9F=E4=B8=8A=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. 权限不足从 500 修回 403(14 个测试失败里的 11 个) `app/service/authorization_service.py` 的 `_deny` 构造 `InteractionAudit(...)` 时 **漏了 `created_at`**(该列 NOT NULL 且无默认值),而 `raise ForbiddenAgentError` 写在 `session.commit()` **之后** —— commit 必抛 IntegrityError (1048: Column 'created_at' cannot be null),于是**永远走不到 raise**: 预期 403,实际 500:Internal Server Error **⇒ 任何「权限不足」的请求都变成 500**,破坏 docs/05 §3.6 的错误码契约。 全仓 100+ 处 `InteractionAudit(...)` 都跟着 `created_at=now`,只有这一处没有 (由 2026-09-14 的 `857c106`「投顾工作台:三接口支持按客户出方案」引入)。 修两处: - 补 `created_at`; - **并把审计写入失败与 403 解耦**(try/except + `logger.warning(exc_info=True)`): 安全判定不该依赖审计表是否可写 —— 拒绝就是拒绝。但也绝不静默,审计缺失是合规问题。 ## 2. 访客令牌端点补限流(P0-4) `app/api/controllers/visitor_tokens.py` 此前**零认证、零限流**,可以不限量铸造 有效 JWT;每个都能调 `/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流 按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ **限流被天然绕过**。 把 `rate_limit.py` 的登录闸门抽成通用的 `_enforce_ip_rate_limit(...)`, 新增 `enforce_visitor_token_rate_limit` 挂到该端点的 router 上。 ⚠️ 刻意**用独立计数器前缀**(`visitor-token` vs `login`)而不是直接复用登录闸门: 共用会让两者互相挤占配额 —— 正常访客刷几次页面就把别人挡在登录外。 阈值 30 次/分钟(比登录的 10 次宽松,因为访客进站/刷新会正常签发)。 ## 3. 测试跟上代码(2 个) - `test_product_recommendation_service.py`:monkeypatch 打在 `current_for_agent` 上, 而 `generate()` 现在走 `current_for_customer(customer_id, context)` —— 补丁不生效, 真实方法被执行并命中鉴权抛 403。改为按新签名打补丁。 - `test_authorization_service.py` 等 3 个:随第 1 项一起恢复。 ## 实测 - `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed** (修复前 **14 failed** / 1426 passed) - `_deny`:权限不足恢复 **403**(原为 500) - 访客限流:连发 40 次 -> `{201: 30, 429: 10}`,首次被拒返回 `RATE_LIMITED「访客令牌签发过于频繁:每 60 秒最多 30 次」` (修复前:连发 15 次全部 201、无限流) - `ruff check` -> All checks passed ## 剩余 2 个失败(需产品决策,未擅自处理) 1. `test_advisor_workspace_registers_documented_operation_endpoints` —— `employee-advisor/dashboard/index.html` 已被**整个替换**为一个自包含静态页 (`data-page-node-id` 属性、内联全部 CSS/JS、硬编码 `API="http://127.0.0.1:8000"`、 自带「离线本地引擎」),**不引用 `api-client.js` / `app-shell.js`、不调 `mountShell`**。 测试断言的是旧页面措辞("组合分析"),新页面写的是"生成推荐方案"。 是接受替换后的页面(改测试断言),还是恢复挂平台壳的版本,属产品决策。 2. `test_docs_endpoint_ids` —— `docs/05` §19 表里被插入了分组标题行 (`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。 --- app/api/controllers/visitor_tokens.py | 13 +++- app/api/dependencies/rate_limit.py | 78 +++++++++++++++---- app/service/authorization_service.py | 28 ++++++- .../test_product_recommendation_service.py | 9 ++- 4 files changed, 105 insertions(+), 23 deletions(-) diff --git a/app/api/controllers/visitor_tokens.py b/app/api/controllers/visitor_tokens.py index c141bec..0d200d3 100644 --- a/app/api/controllers/visitor_tokens.py +++ b/app/api/controllers/visitor_tokens.py @@ -1,10 +1,19 @@ -from fastapi import APIRouter, status +from fastapi import APIRouter, Depends, status +from app.api.dependencies.rate_limit import enforce_visitor_token_rate_limit from app.api.schemas.visitor_tokens import VisitorTokenResponse from app.core.config import get_settings from app.core.security import VisitorTokenIssuer -router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"]) +#: ⚠️ 本端点**无认证**(访客此时还没有身份),但**必须限流** —— 它此前零限流, +#: 可以不限量铸造有效 JWT,每个都能调 `/api/v1/agent-runs` 触发 LLM 调用, +#: 而 agent-runs 的限流按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ 限流被绕过。 +#: 闸门与登录同级别(按客户端 IP),但用**独立计数器前缀**,不与登录互相挤占配额。 +router = APIRouter( + prefix="/api/v1/visitor-tokens", + tags=["visitor-tokens"], + dependencies=[Depends(enforce_visitor_token_rate_limit)], +) @router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED) diff --git a/app/api/dependencies/rate_limit.py b/app/api/dependencies/rate_limit.py index 5c06f78..a6bbe44 100644 --- a/app/api/dependencies/rate_limit.py +++ b/app/api/dependencies/rate_limit.py @@ -77,20 +77,37 @@ LOGIN_WINDOW_SECONDS = 60 LOGIN_MAX_ATTEMPTS = 10 LOGIN_COUNTER_PREFIX = "login" +#: 访客令牌签发端点的限流参数。 +#: ⚠️ 与登录**必须是独立计数器**(不同 `prefix`):共用会让两者互相挤占配额 —— +#: 正常访客刷几次页面就把别人挡在登录外,反之亦然。 +#: 阈值比登录宽松(访客进站/刷新会正常签发),但足以把"脚本循环铸造身份"从 +#: 毫秒级降到每分钟几十次:每个令牌都是一次可用的 LLM 调用额度, +#: 且访客的 `sub` 每次都是新随机值,**按 user_id 计的限流天然被绕过**。 +VISITOR_WINDOW_SECONDS = 60 +VISITOR_MAX_ATTEMPTS = 30 +VISITOR_COUNTER_PREFIX = "visitor-token" -async def enforce_login_rate_limit(request: Request) -> None: - """登录端点专用的限流闸门:按客户端 IP,**不依赖认证上下文**。 - 为什么不能复用 `enforce_rate_limit`:它声明依赖 `build_request_context` - (见本模块文档"顺序保证"),挂到登录端点就变成"要登录先登录"——登录请求本来 - 就不带令牌。而爆破恰恰发生在**没有令牌**的时候,所以这里必须另立一个闸门。 +async def _enforce_ip_rate_limit( + request: Request, + *, + prefix: str, + window_seconds: int, + max_attempts: int, + label: str, +) -> None: + """按客户端 IP 限流的通用闸门,**不依赖认证上下文**。 - 维度取客户端 IP + 路由模板:拿不到 `RequestContext.user_id`(那时还没有身份), - 用 IP 是唯一可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但登录 - 端点的价值在于**挡住自动化爆破**,IP 维度足够,且不引入第二套鉴权解析。 + 适用于"请求到达时还没有身份"的端点:登录、访客令牌签发。 + 它们都不能用 `enforce_rate_limit` —— 那个声明依赖 `build_request_context` + (见本模块文档"顺序保证"),挂上去就变成"要令牌先有令牌"。 - 降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**并告警, - 不因为限流组件故障把所有人挡在门外。 + 维度取客户端 IP + 路由模板:那时拿不到 `RequestContext.user_id`,IP 是唯一 + 可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但这些端点的价值 + 在于**挡住自动化脚本**,IP 维度足够,且不引入第二套鉴权解析。 + + 降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行** + 并告警,不因为限流组件故障把所有人挡在门外。 """ policy = RateLimitPolicy.from_settings(get_settings()) if not policy.enabled: @@ -98,20 +115,49 @@ async def enforce_login_rate_limit(request: Request) -> None: 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, + f"{prefix}:{client}:{request.method}:{template}", + window_seconds, ) if result is None: logger.warning("限流后端不可用,降级放行 route=%s", template) return count, retry_after_seconds = result - if count > LOGIN_MAX_ATTEMPTS: + if count > max_attempts: logger.warning( - "登录限流 route=%s ip=%s count=%s limit=%s", - template, client, count, LOGIN_MAX_ATTEMPTS, + "%s 限流 route=%s ip=%s count=%s limit=%s", + label, template, client, count, max_attempts, ) raise RateLimitExceededError( - f"登录尝试过于频繁:每 {LOGIN_WINDOW_SECONDS} 秒最多 {LOGIN_MAX_ATTEMPTS} 次," + f"{label}过于频繁:每 {window_seconds} 秒最多 {max_attempts} 次," f"请在 {retry_after_seconds} 秒后重试", retry_after_seconds, ) + + +async def enforce_login_rate_limit(request: Request) -> None: + """登录端点专用的限流闸门(文案与阈值保持原样,见本模块既有契约测试)。""" + await _enforce_ip_rate_limit( + request, + prefix=LOGIN_COUNTER_PREFIX, + window_seconds=LOGIN_WINDOW_SECONDS, + max_attempts=LOGIN_MAX_ATTEMPTS, + label="登录尝试", + ) + + +async def enforce_visitor_token_rate_limit(request: Request) -> None: + """访客令牌签发端点的限流闸门。 + + 该端点此前**零认证、零限流**:可以不限量地铸造有效 JWT,每个都能调 + `/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流按 `user_id` 计、 + 访客 `sub` 每次都是新随机值 ⇒ 限流被天然绕过,等于免费刷模型额度并灌爆 + `agent_run` / `conversation` 表。这里补上与登录同级别的闸门。 + """ + await _enforce_ip_rate_limit( + request, + prefix=VISITOR_COUNTER_PREFIX, + window_seconds=VISITOR_WINDOW_SECONDS, + max_attempts=VISITOR_MAX_ATTEMPTS, + label="访客令牌签发", + ) + diff --git a/app/service/authorization_service.py b/app/service/authorization_service.py index 8657e48..5d1bf63 100644 --- a/app/service/authorization_service.py +++ b/app/service/authorization_service.py @@ -1,8 +1,13 @@ +import logging +from datetime import UTC, datetime + from app.core.contracts import RequestContext from app.core.errors import ForbiddenAgentError from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit +logger = logging.getLogger(__name__) + class AuthorizationService: """所有受保护操作的公共权限闸门。 @@ -64,8 +69,25 @@ class AuthorizationService: portal=context.portal, action_type="permission.denied", detail={"permission": permission, "trace_id": context.trace_id}, + # ⚠️ 必须显式给:`interaction_audit.created_at` 是 NOT NULL 且**无默认值**。 + # 这里漏传过一次(2026-09-14 的 `857c106`),后果不是"审计少一条",而是 + # `session.commit()` 抛 IntegrityError(1048: Column 'created_at' cannot be null), + # 而 `raise ForbiddenAgentError` 写在 commit 之后 —— 于是**任何权限不足的请求 + # 都变成 500 而不是 403**,破坏 docs/05 §3.6 的错误码契约。 + created_at=datetime.now(UTC).replace(tzinfo=None), ) - async with SessionFactory() as session: - session.add(audit) - await session.commit() + try: + async with SessionFactory() as session: + session.add(audit) + await session.commit() + except Exception: + # 审计写失败不能把「权限不足」变成 500 —— **拒绝就是拒绝**, + # 安全判定不该依赖审计表是否可写。 + # 但也绝不静默:审计缺失是合规问题,必须留下告警供排查。 + logger.warning( + "权限拒绝审计写入失败(不影响本次 403)permission=%s trace_id=%s", + permission, + context.trace_id, + exc_info=True, + ) raise ForbiddenAgentError("权限不足") diff --git a/tests/unit/service/test_product_recommendation_service.py b/tests/unit/service/test_product_recommendation_service.py index 34d99f2..b461f63 100644 --- a/tests/unit/service/test_product_recommendation_service.py +++ b/tests/unit/service/test_product_recommendation_service.py @@ -64,7 +64,12 @@ async def test_recommendation_returns_evidence_and_exclusion_reasons(monkeypatch async def authority(_self, _customer_id): return _authority(3) - async def goal(_self, _context): + # ⚠️ 这里必须与生产代码实际调用的方法一致:`generate()` 走的是 + # `current_for_customer(customer_id, context)`("按客户出方案"), + # 不再是 `current_for_agent(context)`。补丁打在旧名字上不会生效, + # 于是真实方法被执行、命中 `_assert_customer_access` 的鉴权并以 403 结束 + # —— 表现为测试报"权限不足",而真正的原因是**测试与代码脱节**。 + async def goal(_self, _customer_id, _context): return _goal() monkeypatch.setattr( @@ -72,7 +77,7 @@ async def test_recommendation_returns_evidence_and_exclusion_reasons(monkeypatch authority, ) monkeypatch.setattr( - "app.service.product_recommendation_service.InvestmentGoalService.current_for_agent", + "app.service.product_recommendation_service.InvestmentGoalService.current_for_customer", goal, )