用户在微信开发者工具里看到首页全站 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 逐字节一致(零污染)
156 lines
5.7 KiB
JavaScript
156 lines
5.7 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { createRequire } from 'node:module'
|
||
|
||
/**
|
||
* 小程序鉴权契约守卫
|
||
*
|
||
* 覆盖此前只在真机/开发者工具上才会暴露的缺陷(2026-09-22 首页 401 事故及其延伸):
|
||
* 1. hydrate:没有 access token 时不得恢复登录态。
|
||
* 2. 登录:后端返回字段是 accessToken(不是 token),写错则 token 永远进不了
|
||
* storage → 每个请求都不带 Authorization → 全站 401。
|
||
* 3. 登录:后端返回 isNewUser(不是 needPhone),写错则 `!undefined === true`
|
||
* → 每个用户一登录就被标记成「已绑手机号」。
|
||
* 4. 刷新:/auth/refresh 需要 refreshToken(userId 可选,由令牌 subject 自证)。
|
||
* 5. 递归:/auth/* 自身返回 401 时不得触发 onSessionExpired —— 否则
|
||
* onSessionExpired → /auth/refresh → 401 → onSessionExpired 自我递归。
|
||
*
|
||
* 这些都是 `.js` 层缺陷,tsc 管不到(类型声明写的是 any),只有行为断言能兜住。
|
||
*/
|
||
const removed = []
|
||
const storage = {}
|
||
globalThis.wx = {
|
||
getStorageSync(key) {
|
||
if (key in storage) return storage[key]
|
||
if (key === 'isLoggedIn') return { isLoggedIn: true, userId: 'stale-user' }
|
||
return ''
|
||
},
|
||
setStorageSync(key, value) {
|
||
storage[key] = value
|
||
},
|
||
removeStorageSync(key) {
|
||
removed.push(key)
|
||
delete storage[key]
|
||
},
|
||
}
|
||
|
||
const require = createRequire(import.meta.url)
|
||
|
||
// ---- 1. hydrate:无 token 不得恢复登录态 ----
|
||
const authStore = require('../miniapp/stores/authStore.js')
|
||
authStore.hydrate()
|
||
assert.equal(authStore.state.isLoggedIn, false, '没有 access token 时不得恢复登录态')
|
||
assert.ok(removed.includes('isLoggedIn'), '应清理失真的登录快照')
|
||
|
||
const api = require('../miniapp/utils/api.js')
|
||
|
||
// ---- 2. 登录:必须持久化 accessToken,并按 isNewUser 判定手机号绑定 ----
|
||
api.post = async () => ({
|
||
accessToken: 'ACC-1',
|
||
refreshToken: 'REF-1',
|
||
userId: 'u-1',
|
||
expiresIn: 7200,
|
||
isNewUser: true, // 后端语义:true = 还没有手机号
|
||
})
|
||
assert.ok(authStore.loginWithWechat, '登录方法必须存在')
|
||
await authStore.loginWithWechat('wx-code')
|
||
assert.equal(
|
||
storage.accessToken,
|
||
'ACC-1',
|
||
'登录后必须把后端返回的 accessToken 写入 storage(字段名写成 token 会落空 → 全站 401)',
|
||
)
|
||
assert.equal(storage.refreshToken, 'REF-1', '登录后必须写入 refreshToken')
|
||
assert.equal(
|
||
authStore.state.phoneBound,
|
||
false,
|
||
'isNewUser=true 表示尚未绑定手机号,phoneBound 必须为 false(读 needPhone 会得到 !undefined === true)',
|
||
)
|
||
|
||
// 反向:已绑号用户必须被标成已绑定,防止把 phoneBound 写死成 false 也能过
|
||
const boundStore = require('../miniapp/stores/authStore.js')
|
||
api.post = async () => ({
|
||
accessToken: 'ACC-9',
|
||
refreshToken: 'REF-9',
|
||
userId: 'u-9',
|
||
expiresIn: 7200,
|
||
isNewUser: false,
|
||
})
|
||
await boundStore.loginWithWechat('wx-code-2')
|
||
assert.equal(
|
||
boundStore.state.phoneBound,
|
||
true,
|
||
'isNewUser=false 表示已绑手机号,phoneBound 必须为 true(映射方向不得反)',
|
||
)
|
||
|
||
// ---- 3. 刷新:必须带 refreshToken + userId,并持久化新的 accessToken ----
|
||
// 期望值从当前登录态动态取(上面做过两次登录),避免硬编码与执行顺序耦合。
|
||
// 前置断言保证动态期望不是「两边都是 undefined」的假通过。
|
||
const expectedRefreshToken = storage.refreshToken
|
||
const expectedUserId = authStore.state.userId
|
||
assert.ok(expectedRefreshToken, '前置:storage 中必须有 refreshToken')
|
||
assert.ok(expectedUserId, '前置:登录态中必须有 userId')
|
||
|
||
let refreshBody = null
|
||
api.post = async (url, body) => {
|
||
if (url === '/auth/refresh') {
|
||
refreshBody = body
|
||
return { accessToken: 'ACC-2', refreshToken: 'REF-2', expiresIn: 7200 }
|
||
}
|
||
return {}
|
||
}
|
||
await authStore.refresh()
|
||
assert.ok(refreshBody, '刷新必须发起 /auth/refresh 请求')
|
||
assert.equal(refreshBody.refreshToken, expectedRefreshToken, '刷新请求必须携带 refreshToken')
|
||
assert.equal(
|
||
refreshBody.userId,
|
||
expectedUserId,
|
||
'刷新请求必须携带 userId(后端会校验它与令牌主体一致,串号需被拦下)',
|
||
)
|
||
assert.equal(refreshBody.userId, authStore.state.userId, 'userId 必须取自当前登录态')
|
||
assert.equal(storage.accessToken, 'ACC-2', '刷新成功后必须持久化新的 accessToken')
|
||
|
||
// ---- 4. /auth/* 返回 401 不得递归触发 Session 失效 ----
|
||
delete api.post // 解除上面的桩,回到真实的 request 路径
|
||
delete api.get
|
||
|
||
let sessionExpiredCalls = 0
|
||
api.onSessionExpired = () => {
|
||
sessionExpiredCalls += 1
|
||
}
|
||
globalThis.wx.request = (opts) => {
|
||
opts.success({
|
||
statusCode: 401,
|
||
data: { success: false, code: 'UNAUTHORIZED', message: 'Refresh Token 无效或已过期' },
|
||
})
|
||
}
|
||
|
||
await api.post('/auth/refresh', { refreshToken: 'dead' }).then(
|
||
() => {
|
||
throw new Error('401 必须 reject')
|
||
},
|
||
(err) => {
|
||
assert.equal(err.code, 'UNAUTHORIZED', '应把服务端业务码透传给调用方')
|
||
},
|
||
)
|
||
assert.equal(
|
||
sessionExpiredCalls,
|
||
0,
|
||
'/auth/* 返回 401 不得触发 onSessionExpired —— 否则刷新与失效互相递归,页面会无限打 /auth/refresh',
|
||
)
|
||
assert.equal(storage.accessToken, 'ACC-2', '/auth/* 的 401 不得擅自清 token,清理由 AuthStore 判定')
|
||
|
||
// 对照:业务端点的 401 仍必须触发 Session 失效流程(守卫不得把正常路径一起挡掉)
|
||
await api.get('/home').then(
|
||
() => {
|
||
throw new Error('401 必须 reject')
|
||
},
|
||
() => {},
|
||
)
|
||
assert.equal(
|
||
sessionExpiredCalls,
|
||
1,
|
||
'业务端点 401 必须触发 onSessionExpired(否则不会尝试刷新,用户被直接踢出)',
|
||
)
|
||
assert.equal(storage.accessToken, undefined, '业务端点 401 应清理本地 token')
|
||
|
||
console.log('Miniapp auth contracts passed')
|