分类:前端与小程序 · 作者:管理员 · 发布于 2026-09-17 · 阅读 0 · 标签:微信小程序 / JWT / Spring Boot / 登录鉴权 / Token管理

一、场景痛点:为什么微信小程序登录不那么简单

开发微信小程序时,每个需要用户身份的场景——购物车、订单列表、个人中心——都绕不开登录态管理。大部分教程只告诉你「调 wx.login 拿 code,换 openid,返回 token」,但真实生产环境要考虑的问题远比这多:

  • Token 过期了怎么办——用户操作到一半突然 401,体验极差
  • session_key 有有效期——微信的 session_key 不会主动刷新
  • 多端登录冲突——同一账号手机/平板同时登录,后登录踢前者
  • 越权风险——JWT 里只放 openid,能否防止用户 A 伪造用户 B

本文从实战出发,拆解一套完整的微信小程序 + Spring Boot JWT 登录方案,覆盖登录、鉴权、刷新、登出全链路。

二、微信小程序登录流程全景

标准流程分为三个阶段:

  1. 获取临时凭证:小程序端调用 wx.login() 获得 code(5 分钟有效)
  2. 后端换 Session:后端用 code + appid + secret 调微信接口 auth.code2Session,换取 openid(用户唯一标识)和 session_key(会话密钥,不返回前端)
  3. 签发 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 黑名单 + 自动刷新三件套。如果你正在开发微信小程序的登录模块,可以直接复用这里的代码骨架,根据业务需求调整用户信息字段和过期策略即可。