用户在微信开发者工具里看到首页全站 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 逐字节一致(零污染)
125 lines
5.7 KiB
Python
125 lines
5.7 KiB
Python
"""
|
||
认证刷新链路 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]}"
|
||
)
|