2.2 登录鉴权:code2session 与 token

场景:登录态丢失引发的投诉

迁移后第二周,客服收到一批诡异的投诉:用户说自己明明登录着,点收藏却提示要重新登录。我们查了半天,发现是 token 过期逻辑写得含糊。小程序的登录链路和网页完全不同:用户没有账号密码,唯一凭证是 wx.login 返回的临时 code,后端拿 code 调 code2session 接口换 openid 和 session_key,再自己生成 token 发给小程序。session_key 不能直接下发,否则等于把加密钥匙交给了客户端。

我们最早的设计是 token 永不过期,结果上面那批投诉的原因不是过期,而是 Redis 重启弄丢了会话,而 token 本身又没有续期能力,老 token 查不到会话就判定未登录。这件事之后我们重做了登录态:双 token 机制,短 token 过期前用长 token 静默换取新短 token,会话全部走 Redis 并设置自动续期,登出时双向清理。

投诉复盘时我们还发现一个有趣的细节:同一用户在两分钟内发起了四十多次登录请求。一查代码,前端某个页面的 onShow 里每次都调 wx.login,页面切来切去就把登录接口打爆了。登录链路的设计必须同时考虑服务端逻辑和小程序端的使用姿势,任何一端没考虑周全都会表现为用户投诉。

登录成功率后来被我们列为一级指标。有段时间它稳定在百分之九十九点九,突然某周跌到百分之九十八,查下来是微信侧接口偶发抖动,我们加重试和对 code 的本地缓存,一周后又爬回百分之九十九点九。建议各位把登录成功率单独画一张图,它比任何接口耗时都更早暴露链路问题。

方案:双 token 与静默续期

具体方案是小程序启动时只调一次 wx.login,拿到 code 换两个东西:access_token 有效期两小时,refresh_token 有效期三十天。access_token 放在小程序全局变量里刷新页面不丢,refresh_token 存 storage 换设备会丢。access_token 每次接口请求带在 header,服务端校验失败返回特定 code,前端收到该 code 就用 refresh_token 调刷新接口,拿到新 access_token 重放原请求,整个过程用户无感知。Redis 里的会话用 uid 做 key,token 只存指纹不存明文:

环节 存放位置 有效期 清理时机
code 内存变量 五分钟 换取后失效
access_token 全局变量 两小时 自动续期
refresh_token storage 三十天 登出或改密
会话 Redis 滑动续期 主动登出
session_key 仅服务端 会话级 不下发

会话为什么用滑动续期而不是固定过期?活跃用户经常遇到弹登录框,是非常挫败的体验;而危险的是 token 被盗后永久有效。滑动续期是两者之间的平衡:用户活跃就会话不过期,用户离开就自然收敛,refresh_token 三十天兜底。

各环节的超时和重试也单独约定过,这张表是踩了好几次坑后定稿的,凡是按钮响应慢、登录失败率高,先对着它查一遍:

调用方 超时 重试 备注
wx.login 八秒 一次 code 五分钟内复用
code2session 五秒 两次 指数退避
刷新短 token 五秒 一次 失败引导重登
会话读 Redis 二百毫秒 一次 降级查库

配置与代码

后端换 token 的核心是调微信的 code2session 接口,返回的 openid 落库为用户唯一标识,session_key 存 Redis 供后续解密小程序的加密数据:

const crypto = require('crypto');
async function login(code) {
  const url = 'https://api.weixin.qq.com/sns/jscode2session'
    + '?appid=' + APPID
    + '&secret=' + SECRET
    + '&js_code=' + code
    + '&grant_type=authorization_code';
  const { openid, session_key } = await request(url);
  const uid = await userModel.upsertByOpenid(openid);
  const access = crypto.randomBytes(16).toString('hex');
  const refresh = crypto.randomBytes(24).toString('hex');
  await redis.setex('session:access:' + access, 7200, uid);
  await redis.setex('session:refresh:' + refresh, 2592000, uid);
  await redis.setex('sk:' + uid, 86400, session_key);
  return { access_token: access, refresh_token: refresh };
}

鉴权中间件只做三件事:取 header 的 token、查 Redis、失败返回约定的 code=1001,前端按这个 code 触发静默刷新:

module.exports = async (ctx, next) => {
  const token = ctx.header['x-auth-token'];
  if (!token) return ctx.throw(1001, '未登录');
  const uid = await redis.get('session:access:' + token);
  if (!uid) return ctx.throw(1001, '登录态失效');
  await redis.expire('session:access:' + token, 7200); // 滑动续期
  ctx.state.uid = +uid;
  await next();
};

刷新接口里加了互斥锁,防止同一长 token 被并发刷出多份会话。锁失败就直接返回旧 token 的剩余有效期,让前端下次再试,而不是报错让用户重登:

async function refresh(refresh_token) {
  const uid = await redis.get('session:refresh:' + refresh_token);
  if (!uid) return null;
  const lock = await redis.set('lock:refresh:' + refresh_token, 1, 'NX', 'EX', 5);
  if (!lock) return { retry_later: true };
  const access = crypto.randomBytes(16).toString('hex');
  await redis.setex('session:access:' + access, 7200, uid);
  return { access_token: access };
}

踩坑

第一个坑是 wx.login 的 code 有效期只有五分钟,前端有个旧包每次开页面都重新 login,偶发拿到过期 code,后端换不出 openid 直接报错,排查靠 CLS 里的登录失败日志。第二个坑是并发刷新,首页同时发十几个请求,token 同时失效时前端发起了十个刷新,后端互斥没做好,Redis 里会话被重复覆盖,加了分布式锁,同一 refresh_token 只允许一个刷新在飞。第三个坑是 session_key 被我误下发到小程序端一次,用于解密手机号,代码评审时被同事揪出来,session_key 只能留在服务端,从此解密逻辑全部移到后端。第四个坑是登出做了半套,清了 access 没清 refresh,导致登出后用 refresh 还能把会话刷回来,前后端各清一次才彻底。第五个坑是换机登录的风控,异地换设备时 refresh_token 直接复用让一批被盗号用户无声无息被接管,加了设备指纹比对,换设备强制重新 wx.login。第六个坑是测试不到位,我们的测试账号永远用同一台机器, Redis 会话一直热着,从来测不出过期路径,上线后第一个过期案例就是真实用户,后来测试流程里加了冷冻会话用例,专门走续期分支。第七个坑是并发请求里的 token 雪崩,短 token 失效瞬间,首屏七八个请求同时返回 1001,前端各自触发刷新导致会话被撕扯,最后前端统一由一个请求管理器接管刷新,其余请求排队等待。第八个坑是 uid 的类型,MySQL 里是 BIGINT,拿到 JS 里个别大 uid 精度丢失, Redis 里存的 uid 和 header 带回来的对不上,统一转成字符串比较后才消停。

小结

小程序登录的本质是用 code 换 openid、再用 openid 换自家 token。双 token 加静默续期能让用户几乎永远感知不到登录态存在,Redis 滑动过期是主力。凡是涉及钥匙性质的值,一律留在服务端。登录链路的排障直接看 CLS,可以配合 腾讯云综合实践 的方式建立检索习惯,把登录成功率也纳入监控指标。会话设计这块没有银弹,我们的原则是宁可多一次静默刷新,也不要让用户看到登录框,这是体验的底线,也是这套双 token 方案全部的出发点。

延伸阅读