fix(auth): 修复刷新令牌链路三层断点,并把 121 处裸异常收敛为携带 HTTP 语义的异常
用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:
1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。
第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。
同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。
后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则
守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归
验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
注入前后工作区 SHA256 逐字节一致(零污染)
This commit is contained in:
@@ -133,3 +133,38 @@ def test_nested_operator_endpoints_still_require_operator_role(api, user_token):
|
||||
assert resp.status_code == 403, (
|
||||
f"用户 token 访问嵌套运营端点 {path} 应回 403,实际 {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# 业务拒绝(Service 层主动 throw)必须回 4xx 而不是 500。
|
||||
#
|
||||
# 2026-09-22 审计:全仓 121 处裸 `throw new RuntimeException(...)` 经兜底处理器
|
||||
# 一律变成 500。这比「状态码不精确」严重得多:
|
||||
# * 客户端把「活动不存在」读成「服务端故障」,于是重试,而重试必然再失败;
|
||||
# * 监控 / APM 的 5xx 指标被业务拒绝污染,真实故障淹没在噪声里;
|
||||
# * 网关可能对 5xx 触发熔断,一个用户传错 ID 就能影响其他用户。
|
||||
# 收敛后由 ApiResponseCode 自带 HTTP 语义(400 改请求 / 403 别重试 / 404 不存在 /
|
||||
# 409 先查状态)。这里在活栈上钉住这条链路,单元测试证明不了 Service 抛出的异常
|
||||
# 真的走到了正确的处理器。
|
||||
def test_service_thrown_rejections_are_4xx_not_5xx(api, new_user):
|
||||
"""Service 层主动拒绝 → 4xx,且带业务码(用于区分「谁的问题」)。"""
|
||||
user = new_user()
|
||||
cases = [
|
||||
# 活动不存在 → NOT_FOUND(此前裸抛 → 500)
|
||||
("POST", f"/activities/{MISSING_UUID}/register", user["token"],
|
||||
{"emergencyContactName": "张三", "emergencyContactPhone": "13800138000"},
|
||||
(404,)),
|
||||
# 举报自己 → BAD_REQUEST(此前裸抛 → 500)
|
||||
("POST", "/risk/reports", user["token"],
|
||||
{"reportedUserId": user["user_id"], "reasonType": "fraud", "description": "自举报"},
|
||||
(400,)),
|
||||
]
|
||||
for method, path, token, payload, expected in cases:
|
||||
resp = api.call(method, path, token=token, json=payload)
|
||||
assert resp.status_code in expected, (
|
||||
f"{method} {path} 应回 {expected},实际 {resp.status_code}。"
|
||||
f"若为 500,说明该拒绝仍是裸 RuntimeException 或未映射的异常类型:"
|
||||
f"{resp.text[:300]}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("success") is False, f"{path} 的 success 应为 false:{body}"
|
||||
assert body.get("code"), f"{path} 必须带业务码,否则客户端无法分辨原因:{body}"
|
||||
|
||||
Reference in New Issue
Block a user