一、场景痛点:为什么微信小程序登录不那么简单
开发微信小程序时,每个需要用户身份的场景——购物车、订单列表、个人中心——都绕不开登录态管理。大部分教程只告诉你「调 wx.login 拿 code,换 openid,返回 token」,但真实生产环境要考虑的问题远比这多:
- Token 过期了怎么办——用户操作到一半突然 401,体验极差
- session_key 有有效期——微信的 session_key 不会主动刷新
- 多端登录冲突——同一账号手机/平板同时登录,后登录踢前者
- 越权风险——JWT 里只放 openid,能否防止用户 A 伪造用户 B
本文从实战出发,拆解一套完整的微信小程序 + Spring Boot JWT 登录方案,覆盖登录、鉴权、刷新、登出全链路。
二、微信小程序登录流程全景
标准流程分为三个阶段:
- 获取临时凭证:小程序端调用
wx.login()获得code(5 分钟有效) - 后端换 Session:后端用
code+appid+secret调微信接口auth.code2Session,换取openid(用户唯一标识)和session_key(会话密钥,不返回前端) - 签发 JWT:后端用
openid等信息生成 JWT 返回小程序,小程序后续请求携带 JWT
// 伪代码:登录流程核心逻辑
@Service
public class WxAuthService {
public LoginResponse wxLogin(String code) {
// 1. 用 code 换 session
WxSessionResult session = wxApi.code2Session(code);
// 2. 根据 openid 查询或创建用户
User user = userService.findOrCreate(session.getOpenid());
// 3. 生成 JWT
String accessToken = jwtUtil.generateAccessToken(user.getId(), user.getOpenid());
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getOpenid());
// 4. 存入 Redis(已登录设备信息)
sessionManager.recordLogin(user.getId(), deviceId);
return new LoginResponse(accessToken, refreshToken, user.getId());
}
}
三、JWT 双 Token 方案:Access Token + Refresh Token
单 Token 方案最大的问题是:Token 过期后用户必须重新登录。对于电商小程序这种需要频繁操作的产品,体验不可接受。双 Token 方案是最成熟的做法。
- Access Token:有效期短(15-30 分钟),携带用户身份,每次请求验证
- Refresh Token:有效期长(7-30 天),仅用于刷新 Access Token,走专门的刷新接口
3.1 JWT 工具类实现
@Component
public class JwtTokenUtils {
@Value("${jwt.access-expiration:1800000}") // 30分钟
private long accessExpiration;
@Value("${jwt.refresh-expiration:604800000}") // 7天
private long refreshExpiration;
@Value("${jwt.secret}")
private String secret;
public String generateAccessToken(Long userId, String openid) {
return Jwts.builder()
.setSubject(userId.toString())
.claim("openid", openid)
.claim("type", "access")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessExpiration))
.signWith(SignatureAlgorithm.HS256, secret.getBytes())
.compact();
}
public String generateRefreshToken(Long userId, String openid) {
return Jwts.builder()
.setSubject(userId.toString())
.claim("openid", openid)
.claim("type", "refresh")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshExpiration))
.signWith(SignatureAlgorithm.HS256, secret.getBytes())
.compact();
}
}
3.2 刷新接口:巧妙避开 Access Token 过期导致 login 循环
刷新接口的特殊之处在于——它不能跟普通接口走同一套 JWT 鉴权过滤器。因为请求刷新时 Access Token 可能已过期,会被过滤器拦截返回 401。解决方案有两种:
- 方案 A(推荐):刷新接口走独立的过滤器,只验证 Refresh Token,不检查 Access Token
- 方案 B:刷新接口放入白名单,不经过 JWT 过滤器
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/refresh")
public Result refresh(@RequestHeader("X-Refresh-Token") String refreshToken) {
// 验证 Refresh Token
if (!jwtUtil.validateToken(refreshToken, "refresh")) {
return Result.error(401, "Refresh Token 已过期,请重新登录");
}
String openid = jwtUtil.getClaim(refreshToken, "openid");
Long userId = Long.valueOf(jwtUtil.getSubject(refreshToken));
// 关键:校验 Redis 中的 refresh token 是否一致(防多端互踢场景)
String storedToken = redisTemplate.opsForValue()
.get("refresh:token:" + userId);
if (!refreshToken.equals(storedToken)) {
return Result.error(401, "账号在其他设备登录,请重新登录");
}
// 颁发新的 Token 对
String newAccessToken = jwtUtil.generateAccessToken(userId, openid);
String newRefreshToken = jwtUtil.generateRefreshToken(userId, openid);
// 更新 Redis
redisTemplate.opsForValue().set("refresh:token:" + userId,
newRefreshToken, 7, TimeUnit.DAYS);
return Result.ok(new TokenPair(newAccessToken, newRefreshToken));
}
}
四、JWT 过滤器:Spring Boot 中拦截与放行的正确姿势
利用 Spring Security 的 OncePerRequestFilter 实现 JWT 鉴权,核心逻辑:
- 从
Authorization: Bearer xxx头提取 Token - 验证签名和有效期
- 校验 Token 类型为
access - 检查 Redis 黑名单(已登出的 Token)
- 设置 SecurityContext
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
// 刷新接口直接放行(由控制器自己验证 Refresh Token)
if (request.getRequestURI().contains("/api/auth/refresh")) {
chain.doFilter(request, response);
return;
}
if (token != null && jwtUtil.validateToken(token, "access")) {
// 检查是否已被登出(黑名单)
if (redisTemplate.hasKey("blacklist:token:" + token)) {
response.setStatus(401);
response.getWriter().write("{\"code\":401,\"msg\":\"Token 已失效\"}");
return;
}
Long userId = Long.valueOf(jwtUtil.getSubject(token));
String openid = jwtUtil.getClaim(token, "openid");
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userId, openid, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}
踩坑记录:一定要注意过滤器顺序——Spring Security 的过滤器链默认在自定义 Filter 之前。可以在SecurityConfig中使用addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)确保 JWT 过滤器在 Security 认证过滤器之前执行。
五、安全守则:这些坑你一定遇到
5.1 OpenID 不等于用户身份
同一微信用户对不同的公众号/小程序的 openid 是不同的。如果你有多个小程序(比如用户的公司有「王中王装饰广场」和「智科城」两个小程序),同一用户的 openid 不一样。正确的做法是:
- 在用户表里加
unionid字段(需微信开放平台绑定) - 用
unionid作为跨小程序的用户关联标识 - 没有
unionid时,每个小程序独立维护用户体系
5.2 session_key 不在前端存储
session_key 是微信下发的敏感密钥,用于解密用户信息(如手机号)或验签。它绝对不能返回给前端,也不能存到 JWT 里。后端使用时建议加密存储或存入 Redis,用完及时清除。
5.3 Token 登出黑名单
JWT 本身无法作废(无状态),登出时必须用 Redis 黑名单。
@PostMapping("/logout")
public Result logout(@AuthenticationPrincipal Long userId,
@RequestHeader("Authorization") String token) {
// 将当前 Access Token 加入黑名单(有效期到其自然过期时间)
String actualToken = token.replace("Bearer ", "");
long expireTime = jwtUtil.getRemainingExpiration(actualToken);
redisTemplate.opsForValue().set(
"blacklist:token:" + actualToken,
"1", expireTime, TimeUnit.MILLISECONDS);
// 清除 Refresh Token
redisTemplate.delete("refresh:token:" + userId);
return Result.ok();
}
5.4 防止 Token 被篡改
JWT 的签名保证了数据完整性,但有个安全细节容易被忽略:jwt.secret 必须足够复杂,且不要硬编码在代码中,通过配置文件注入。生产环境建议用 generateSecret() 方法生成不小于 256 位的密钥。
// 生成一个安全的密钥
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String secretString = Base64.getEncoder().encodeToString(key.getEncoded());
六、小程序端:优雅处理 Token 过期
小程序端需要拦截所有 API 响应,检测 401 后自动刷新 Token。核心逻辑:
// 小程序 app.js - 请求拦截器
const request = (url, data, options = {}) => {
const token = wx.getStorageSync('access_token');
const refreshToken = wx.getStorageSync('refresh_token');
return new Promise((resolve, reject) => {
wx.request({
url: 'https://app.example.com/api' + url,
data,
header: { 'Authorization': 'Bearer ' + token },
success: (res) => {
if (res.statusCode === 401) {
// 自动刷新 Token
refreshTokenAndRetry(url, data, refreshToken, resolve, reject);
} else {
resolve(res.data);
}
},
fail: reject
});
});
};
const refreshTokenAndRetry = (url, data, refreshToken, resolve, reject) => {
wx.request({
url: 'https://app.example.com/api/auth/refresh',
header: { 'X-Refresh-Token': refreshToken },
success: (res) => {
if (res.data.code === 200) {
const { accessToken, refreshToken: newRefresh } = res.data.data;
wx.setStorageSync('access_token', accessToken);
wx.setStorageSync('refresh_token', newRefresh);
// 用新 Token 重试原始请求
request(url, data).then(resolve).catch(reject);
} else {
// 刷新失败,跳转登录页
wx.removeStorageSync('access_token');
wx.removeStorageSync('refresh_token');
wx.navigateTo({ url: '/pages/login/login' });
}
}
});
};
这种方案用户全程无感知——Access Token 过期后自动刷新、自动重试,只有 Refresh Token 也过期时才需要重新微信授权。
七、总结:一份可落地的配置清单
- Access Token 有效期 15-30 分钟,Refresh Token 7-30 天
- Refresh Token 存 Redis,登出时清除
- 黑名单 防止已登出 Token 继续使用
- session_key 不返回前端,用完即弃
- 刷新接口 不走 JWT 过滤器或用独立验证逻辑
- 小程序端 请求拦截器自动刷新,用户无感知
这份方案经多个电商小程序生产验证,核心是双 Token + Redis 黑名单 + 自动刷新三件套。如果你正在开发微信小程序的登录模块,可以直接复用这里的代码骨架,根据业务需求调整用户信息字段和过期策略即可。