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:
@@ -52,7 +52,15 @@ Page({
|
||||
wx.switchTab({ url: ROUTE.TAB.HOME })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败,请重试'
|
||||
// api.js 的 reject 值是普通对象 { code, message, requestId },不是 Error 实例。
|
||||
// 此前只认 `instanceof Error`,于是服务端文案(「账号已被封禁」「该手机号已被…」)
|
||||
// 全被丢掉,用户只看到笼统的「登录失败,请重试」,无从判断该怎么处理。
|
||||
const fromRejection =
|
||||
err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string'
|
||||
? (err as { message: string }).message
|
||||
: ''
|
||||
const message =
|
||||
fromRejection || (err instanceof Error ? err.message : '') || '登录失败,请重试'
|
||||
console.error('[Login] 登录失败:', err)
|
||||
this.setData({ error: message, loggingIn: false })
|
||||
appStore.toastError(message)
|
||||
|
||||
@@ -10,7 +10,10 @@ const api = require('../utils/api');
|
||||
/**
|
||||
* 微信登录(API_INVENTORY 行 8)
|
||||
* @param {{ code: string }} payload wx.login 拿到的 code
|
||||
* @returns {Promise<{ token: string, refreshToken: string, expiresIn: number, needPhone: boolean, needProfile: boolean }>}
|
||||
* @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number,
|
||||
* userId: string, isNewUser: boolean }>}
|
||||
* 注意是 accessToken(不是 token)、isNewUser(不是 needPhone/needProfile)——
|
||||
* 这两处字段名此前都写错,导致 token 落空与「误判已绑手机号」,见 authStore。
|
||||
*/
|
||||
function wechatLogin(payload) {
|
||||
return api.post('/auth/wechat/login', payload, undefined, payload.code);
|
||||
@@ -27,8 +30,8 @@ function bindPhone(payload) {
|
||||
|
||||
/**
|
||||
* 刷新 Token(API_INVENTORY 行 10)
|
||||
* 后端 /auth/refresh 要求 body 同时包含 userId 与 refreshToken(userId 用于定位
|
||||
* Redis 中存储的 refresh token),缺少任一都会返回 400。故此处必须带 userId。
|
||||
* 后端 /auth/refresh 只强制要求 refreshToken(userId 由令牌 subject 自证身份);
|
||||
* 这里仍传 userId,让后端校验它与令牌主体一致 —— 串号时能被提前拦下。
|
||||
* @param {string} userId 当前登录用户 ID(来自 AuthStore.state.userId)
|
||||
* @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number }>}
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,7 @@ class AuthStore extends BaseStore {
|
||||
/**
|
||||
* 微信登录
|
||||
* @param {string} code wx.login() 拿到的 code
|
||||
* @returns {Promise<{ needPhone: boolean, needProfile: boolean }>}
|
||||
* @returns {Promise<{ isNewUser: boolean }>} isNewUser = 后端判定「尚未绑定手机号」
|
||||
*/
|
||||
async loginWithWechat(code) {
|
||||
this.setState({ authStatus: 'logging_in', lastError: null });
|
||||
@@ -66,7 +66,14 @@ class AuthStore extends BaseStore {
|
||||
const patch = {
|
||||
isLoggedIn: true,
|
||||
userId: result.userId || null,
|
||||
phoneBound: !result.needPhone,
|
||||
/**
|
||||
* 必须读后端真实字段 isNewUser。
|
||||
* 此前读的是 `!result.needPhone` —— 后端从不返回 needPhone,恒为 undefined,
|
||||
* 于是 `!undefined === true`:**每个用户一登录就被标记成已绑手机号**,
|
||||
* 「我的」页不再引导绑号。这是与 accessToken/token 同族的字段名不一致缺陷,
|
||||
* 也是本项目第三次踩到「跨端字段名对不上」(见 types/index.ts 的 UserLoginResult)。
|
||||
*/
|
||||
phoneBound: !result.isNewUser,
|
||||
authStatus: 'idle',
|
||||
};
|
||||
this.setState(patch);
|
||||
@@ -74,7 +81,8 @@ class AuthStore extends BaseStore {
|
||||
{ isLoggedIn: true, userId: patch.userId, phoneBound: patch.phoneBound },
|
||||
STORAGE_KEYS.IS_LOGGED_IN,
|
||||
);
|
||||
return { needPhone: !!result.needPhone, needProfile: !!result.needProfile };
|
||||
// 只回传后端确实提供的字段:needProfile 后端没有对应字段,凭空造 false 会误导调用方。
|
||||
return { isNewUser: !!result.isNewUser };
|
||||
} catch (err) {
|
||||
this.setState({ authStatus: 'idle', lastError: err });
|
||||
throw err;
|
||||
@@ -103,7 +111,9 @@ class AuthStore extends BaseStore {
|
||||
this._refreshPromise = (async () => {
|
||||
this.setState({ authStatus: 'refreshing' });
|
||||
try {
|
||||
// 后端 /auth/refresh 需要 userId;后端返回 accessToken(非 token)。
|
||||
// 后端返回 accessToken(非 token)。userId 已非必需(后端从 refreshToken 的
|
||||
// subject 自证身份),仍显式带上:后端会校验它与令牌主体一致,能提前拦下
|
||||
// 「拿 A 的令牌刷 B 的会话」这类串号。
|
||||
const result = await authService.refresh(this.state.userId);
|
||||
api.saveToken(result.accessToken, result.refreshToken);
|
||||
this.setState({ authStatus: 'idle', isLoggedIn: true });
|
||||
|
||||
@@ -124,6 +124,23 @@ class ApiClient {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为认证端点(/auth/*)。
|
||||
*
|
||||
* 认证端点自身返回 401 时**不得**触发 Session 失效流程。原因:401 分支会调用
|
||||
* `onSessionExpired`,而它(AuthStore.handleSessionExpired)会去调 `/auth/refresh`
|
||||
* —— 若 `/auth/refresh` 自己也回 401,就形成「刷新 → 失效 → 再刷新」的自我递归。
|
||||
*
|
||||
* 这条路径在 2026-09-22 之前是隐性的:后端当时把「refresh token 无效」抛成
|
||||
* 500,401 分支根本走不到。后端把语义修正为 401(正确)之后,这层守卫
|
||||
* 就成了必需 —— 否则修好一个缺陷会引入另一个。
|
||||
*
|
||||
* 认证端点的失败由调用方(AuthStore)判定,本层只如实上报。
|
||||
*/
|
||||
isAuthEndpoint(url) {
|
||||
return typeof url === 'string' && url.indexOf('/auth/') === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用请求方法
|
||||
* @param {{url: string, method?: string, data?: any, headers?: object, idempotencyKey?: string, timeout?: number}} options
|
||||
@@ -189,6 +206,17 @@ class ApiClient {
|
||||
} else if (res.statusCode === 401) {
|
||||
// 服务端 message 优先:token 失效的原因可能各不相同(过期/被踢/未登录)。
|
||||
const body = res.data || {};
|
||||
if (this.isAuthEndpoint(url)) {
|
||||
// /auth/refresh 等端点自己回 401 = 「刷新凭据已失效」。
|
||||
// 此处若照常 clearToken + onSessionExpired,就会再次发起 /auth/refresh(自我递归)。
|
||||
// 状态与清理交给调用方:AuthStore.refresh 失败时会调用 _handleExpired()。
|
||||
reject({
|
||||
code: body.code || REQUEST_ERROR.UNAUTHORIZED,
|
||||
message: body.message || '请重新登录',
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.clearToken();
|
||||
if (typeof this.onSessionExpired === 'function') this.onSessionExpired();
|
||||
reject({ code: REQUEST_ERROR.UNAUTHORIZED, message: body.message || '请重新登录', requestId });
|
||||
|
||||
Reference in New Issue
Block a user