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 方案全部的出发点。
延伸阅读
- PHP 接口实战:设计与防护(token 鉴权的实现对照)
- Node 建站实战(中间件组装)
- 小程序商城实战:从选型到上线(登录态与购物车联动)
