Files
danyang-match/tests/e2e/auth_refresh_test.py
T
WorkBuddy 825fd41ee4 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 逐字节一致(零污染)
2026-09-22 21:41:30 +08:00

125 lines
5.7 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.
"""
认证刷新链路 E2E。
背景:2026-09-22 用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」
这条路整条是断的,而且断在三个互不相干的地方:
1. 前端漏传 userId → 后端 `/auth/refresh` 直接 400;
2. 后端把 refresh token 交给 `validateToken` 校验,而那是用 **access 密钥**
验签的,refresh token 由 **refresh 密钥**签发 —— 必然失败;
3. 失败被抛成裸 `RuntimeException` → HTTP 500,客户端当成服务端故障重试。
第 1 层修好后第 2 层才显形,第 2 层修好后第 3 层才显形 —— 单层修复都会「看起来好了」。
所以这里断言的是**整条链路的可用性**,而不是某一步返回 200:
* 换发出来的 accessToken 必须真的能通过鉴权(访问受保护端点);
* 换发后旧 refreshToken 必须失效(可撤销性);
* 刷新失败必须回 401 而不是 500(客户端据此决定「重新登录」而非「重试」)。
此前全仓没有任何 /auth/refresh 用例,这正是该缺陷能长期存活的原因。
"""
import uuid
import pytest
pytestmark = pytest.mark.e2e
MISSING_UUID = "00000000-0000-0000-0000-000000000000"
def _refresh(api, token: str, refresh_token: str, **extra):
return api.call(
"POST",
"/auth/refresh",
json={"refreshToken": refresh_token, **extra},
)
def test_refresh_returns_access_token_that_really_works(api, new_user):
"""刷新换发的 accessToken 必须能通过鉴权 —— 只看 200 会被「返回了空壳令牌」骗过。"""
user = new_user()
resp = _refresh(api, user["token"], user["refresh_token"], userId=user["user_id"])
assert resp.status_code == 200, (
f"/auth/refresh 必须成功(refreshToken 由 refresh 密钥签发,"
f"用 access 密钥验签会必然失败)。实际 {resp.status_code}: {resp.text[:300]}"
)
body = resp.json().get("data") or {}
new_access = body.get("accessToken")
new_refresh = body.get("refreshToken")
assert new_access, f"刷新响应必须包含 accessToken:{body}"
assert new_refresh, f"刷新响应必须包含新的 refreshToken(令牌轮换):{body}"
# 令牌必须互异:JWT 的 iat 是秒级,若载荷只有 subject/iat/type,
# 同一秒内签发的令牌会逐字节相同 —— 「刷新即轮换」随之失效(旧令牌仍然可用)。
# 这个断言在 2026-09-22 咬出过该缺陷,故保留。
assert new_access != user["token"], "刷新应换发新令牌,而不是回显旧令牌"
assert new_refresh != user["refresh_token"], (
"刷新必须换发不同的 refreshToken,否则无法作废旧令牌"
)
# 关键:新令牌不是装饰品,必须真的能过鉴权
probe = api.call("GET", "/me/status", token=new_access)
assert probe.status_code == 200, (
f"刷新换发的 accessToken 必须能访问受保护端点,实际 {probe.status_code}: {probe.text[:300]}"
)
def test_refresh_does_not_require_user_id(api, new_user):
"""
userId 不是必填项:refresh token 的 subject 就是 userId,令牌自证身份。
强制要求客户端重复传一个它已经持有的凭据里已包含的信息,只会制造
「漏传 → 400」这类无意义失败(正是这次事故放大的起点)。
"""
user = new_user()
resp = _refresh(api, user["token"], user["refresh_token"])
assert resp.status_code == 200, (
f"只带 refreshToken 也必须能刷新(身份来自令牌 subject),实际 {resp.status_code}: {resp.text[:300]}"
)
assert (resp.json().get("data") or {}).get("accessToken"), "应换发 accessToken"
def test_refresh_rejects_mismatched_user_id(api, new_user):
"""显式传入的 userId 与令牌主体不一致时必须拒绝,防止拿 A 的令牌刷 B 的会话。"""
user = new_user()
other = new_user()
resp = _refresh(api, user["token"], user["refresh_token"], userId=other["user_id"])
assert resp.status_code in (401, 403), (
f"userId 与令牌主体不一致必须被拒绝(401/403),实际 {resp.status_code}: {resp.text[:300]}"
)
def test_rotating_refresh_token_invalidates_the_previous_one(api, new_user):
"""刷新后旧 refreshToken 必须失效(Redis 中登记值已被换发覆盖)。"""
user = new_user()
first = _refresh(api, user["token"], user["refresh_token"])
assert first.status_code == 200, f"前置条件失败:首次刷新应成功: {first.text[:300]}"
reused = _refresh(api, user["token"], user["refresh_token"])
assert reused.status_code == 401, (
f"已轮换掉的 refreshToken 必须失效并回 401,实际 {reused.status_code}: {reused.text[:300]}"
)
def test_invalid_refresh_token_is_401_not_500(api, new_user):
"""
刷新凭据无效 = 401,不是 500。
500 会让客户端判定为服务端故障并重试,而重试必然再失败;
401 才能让客户端停止重试、走「重新登录」。这也是小程序 api.js
区分 401 / 其他失败分支的前提。
"""
user = new_user()
for bad_token in (str(uuid.uuid4()), "not-a-jwt"):
resp = _refresh(api, user["token"], bad_token)
assert resp.status_code == 401, (
f"无效 refreshToken({bad_token[:12]}…) 应回 401,实际 {resp.status_code}: {resp.text[:300]}"
)
def test_refresh_without_refresh_token_is_400(api):
"""完全没带凭据属于请求问题 → 400(与「凭据无效」的 401 区分开)。"""
resp = api.call("POST", "/auth/refresh", json={})
assert resp.status_code == 400, (
f"缺少 refreshToken 应回 400,实际 {resp.status_code}: {resp.text[:300]}"
)