做小程序后端,登录态看似简单——不就是调个code2session拿 openid 吗?真正上线后才会发现一堆问题:令牌过期要不要让用户重新登录?刷新令牌被人截获怎么办?同一账号在手机和平板同时登录要不要互踢?存储用 Redis 还是 JWT 无状态?本文把我们项目里踩过坑的一整套登录态方案完整梳理出来,包含可运行的核心代码和 4 个实战踩坑点。
一、整体流程:从 wx.login 到拿到业务令牌
小程序的登录是微信托管的,标准流程如下:
- 小程序端调用
wx.login()拿到临时登录凭证code(有效期仅 5 分钟,且只能用一次) - 后端拿
code+appid+appsecret请求微信code2session接口,换取openid(用户在当前小程序的唯一标识)和session_key - 后端根据 openid 查库或建用户,签发自己的业务令牌(access token + refresh token)
- 之后所有业务请求在 header 中携带 access token,后端校验通过即放行
关键点:openid 只在后端用来建立用户身份,绝不能下发给前端当作长期令牌;session_key涉及用户敏感数据解密,要安全存储且不能暴露给前端。
// 后端换码的核心逻辑(伪代码,实际用 WebClient/RestTemplate)publicLoginVOlogin(Stringcode){// 1. code 换 openidCode2SessionRespresp=wechatClient.code2session(appid,secret,code);if(resp.getErrcode()!=null&&resp.getErrcode()!=0){thrownewBizException("微信登录失败:"+resp.getErrmsg());}Stringopenid=resp.getOpenid();// 2. 查库或建档Useruser=userMapper.findByOpenid(openid);if(user==null){user=User.builder().openid(openid).createdAt(LocalDateTime.now()).build();userMapper.insert(user);}// 3. 签发双令牌returntokenService.issue(user.getId());}二、为什么要用双令牌而不是单个长效 token
最朴素的方案是登录后发一个有效期很长的 token(比如 30 天),简单但危险:token 一旦被截获(日志泄露、抓包、恶意转发),在整个有效期内都可以冒充用户,且无法主动失效。
双令牌方案把"安全性"和"体验"分开:
- access token:有效期短,建议 2 小时,用于所有业务请求,泄露后风险窗口小
- refresh token:有效期长,建议 15-30 天,只在 access token 过期时调用刷新接口换新,且必须可被服务端主动作废
用户全程无感知——access token 过期后用 refresh token 静默换新,不需要再走一次wx.login,也不会被踢到登录页。
三、令牌存储:有状态 Redis 还是无状态 JWT
这是争议最多的点。我们的选型是refresh token 存 Redis(有状态),access token 用 JWT(无状态校验),理由:
- access token 用 JWT,自包含 uid、过期时间,网关层本地验签即可,不用每个请求都打一次 Redis,性能好
- refresh token 存 Redis,可以做到主动作废、单点登录互踢、登录设备管理——这是纯 JWT 无状态方案的死穴(JWT 签发后在过期前无法撤回)
publicLoginVOissue(Longuid){StringaccessToken=JwtBuilder.builder().claim("uid",uid).expire(Duration.ofHours(2)).signWith(hmacKey).build();// refresh token 用随机串,不携带业务信息StringrefreshToken=SecureRandomUtil.uuid();// key 设计:refresh:{token} -> uid;同时记录登录设备redis.setex("refresh:"+refreshToken,Duration.ofDays(15),String.valueOf(uid));redis.setex("login:uid:"+uid,Duration.ofDays(15),refreshToken);returnnewLoginVO(accessToken,refreshToken,7200);}四、刷新接口:必须做的旋转和复用检测
刷新不是"拿着 refresh token 换新 access token"这么简单,直接给出我们踩坑后的实现要点:
- 令牌旋转(rotation):每次刷新都签发新的 refresh token,旧的立即删除。这样 refresh token 不是长期固定的,截获者拿到一个旧的也用不了
- 复用检测:如果一个已经被旋转掉的 refresh token 又被拿来用,说明令牌很可能泄露——此时直接作废该用户的全部登录态,强制重新登录
- 刷新接口要限流:同一设备短时间大量刷新属于异常,配合限流防暴力尝试
publicLoginVOrefresh(StringoldRefreshToken){Stringkey="refresh:"+oldRefreshToken;StringuidStr=redis.get(key);if(uidStr==null){// 可能是过期,也可能是在使用"已旋转的旧令牌"LongleakedUid=redis.get("refresh_reuse:"+oldRefreshToken);if(leakedUid!=null){// 命中复用检测:令牌疑似泄露,踢掉该用户全部会话redis.del("login:uid:"+leakedUid);thrownewTokenReusedException("登录态异常,请重新登录");}thrownewUnauthorizedException("refresh token 无效或已过期");}Longuid=Long.valueOf(uidStr);// 旧令牌不是直接删,而是短时记录用于复用检测(如保留 10 分钟)redis.setex("refresh_reuse:"+oldRefreshToken,Duration.ofMinutes(10),uidStr);redis.del(key);returnissue(uid);}五、多端互踢与单点登录
有些业务要求"同一账号只允许在一台设备登录",新设备登录后旧设备被踢下线。有状态存储让这件事变得简单:签发时维护login:uid:{uid} -> 当前有效refreshToken,每次刷新时校验请求带来的 refresh token 是否等于 Redis 里记录的值,不一致说明已经有更新的登录,拒绝并提示"账号在其他设备登录"。
如果业务允许多端在线但要可管理,则改为login:uid:{uid}下存设备 hash(deviceId -> token),用户可以在"我的设备"里查看和远程下线,后台只需删除对应 token。
access token 是 JWT、无法即时失效的问题怎么解决?两种工程做法:
- JWT 里带一个
loginVersion,用户修改密码、被踢时把 Redis 中用户版本号 +1,网关校验 JWT 版本是否一致(每次请求一次 Redis 查询,可接受) - 维护一张短 TTL 的作废清单,被踢用户的 access token jti 放入黑名单(因为 access token 只有 2 小时,黑名单体量很小)
六、前端配合:无感刷新的正确姿势
小程序端建议把令牌放在wx.setStorageSync,并封装统一的请求函数处理 401:
constrequest=async(options)=>{constresp=awaitwxRequest({...options,header:{Authorization:'Bearer '+getAccess()},});if(resp.statusCode===401&&!options._retried){// access 过期,用 refresh 静默换新后重放原请求constnewTokens=awaitrefreshAccess();setTokens(newTokens);returnrequest({...options,_retried:true});}returnresp;};必须加并发锁:多个请求同时 401 时,只允许第一个去刷新,其余请求等待同一个刷新结果,否则会连发多个刷新请求,触发旋转机制导致互相作废。
七、四个实战踩坑点
坑 1:code2session 没做失败重试和降级
微信接口偶发超时,如果后端直接把错误抛给用户,用户会莫名其妙登录失败。对网络超时类错误做有限次重试;同时要注意区分错误码——40029(code 无效)不能重试,通常是前端重复提交了同一个 code,要在小程序端防重复点击。
坑 2:时钟不同步导致 JWT 大面积失效
服务集群如果有机器系统时间不准,签发的 JWT 在别的机器校验时会出现"还没生效"或"提前过期"。上线前务必确认所有节点开启 NTP 校时;JWT 校验时可以给一个小的 leeway(如 30 秒)容忍微小偏差,但不要给太大。
坑 3:refresh token 明文进了日志和 URL
排查问题时把整个请求参数打日志,refresh token 就进了日志系统,等于白做安全设计。要对令牌做脱敏(只留前后几位),且令牌只放在请求 body 或 header,不要拼在 URL 上——URL 会被网关、代理、浏览器历史记录留存。
坑 4:换绑手机号后登录态没处理
很多小程序支持更换绑定手机号,换绑本质是身份凭证变更,旧设备上的登录态应根据业务策略强制重新验证或全部作废。漏掉这一步,旧手机持有者仍能操作账号,属于账号安全事故。
八、小结
一套可靠的小程序登录态方案可以概括为:wx.login+ 后端code2session建立身份,access token 用短效 JWT 保证性能,refresh token 用有状态 Redis 存储支持旋转、作废和多端管理,刷新接口做令牌旋转与复用检测,前端用带并发锁的无感刷新重放请求。安全性、性能和用户体验三者可以同时兼顾,关键是想清楚每一种令牌"被泄露后怎么办"。