Files
danyang-match/miniapp/services/authService.js
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

58 lines
2.2 KiB
JavaScript
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.
/**
* 认证服务(规范 §131 services 层 / API_INVENTORY 认证域)
*
* 仅封装 auth/wechat/login、auth/phone/bind、auth/refresh、auth/logout 四个端点。
* Token 持久化、Session 失效回调由 utils/api.js 统一处理;本层不读写本地存储。
* 刷新 Token 在 401 自动重试由 AuthStore 协调(单飞锁),见 stores/authStore。
*/
const api = require('../utils/api');
/**
* 微信登录(API_INVENTORY 行 8)
* @param {{ code: string }} payload wx.login 拿到的 code
* @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);
}
/**
* 绑定手机号(API_INVENTORY 行 9)
* @param {{ phoneCode: string }} payload wx.getPhoneNumber 拿到的 encryptedData/iv 或 phoneCode
* @returns {Promise<{ bound: true }>}
*/
function bindPhone(payload) {
return api.post('/auth/phone/bind', payload, undefined, payload.phoneCode || payload.encryptedData);
}
/**
* 刷新 Token(API_INVENTORY 行 10)
* 后端 /auth/refresh 只强制要求 refreshToken(userId 由令牌 subject 自证身份);
* 这里仍传 userId,让后端校验它与令牌主体一致 —— 串号时能被提前拦下。
* @param {string} userId 当前登录用户 ID(来自 AuthStore.state.userId)
* @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number }>}
*/
function refresh(userId) {
const refreshToken = api.getRefreshToken();
return api.post('/auth/refresh', { userId, refreshToken });
}
/**
* 登出(API_INVENTORY 行 11)
* 幂等:键由 api 层托管,网络异常重试时复用同一把,避免后端重复记账。
* @returns {Promise<void>}
*/
function logout() {
return api.post('/auth/logout', {}, undefined, api.idempotencyKey('logout'));
}
module.exports = {
wechatLogin,
bindPhone,
refresh,
logout,
};