1. 项目概述:当微信小程序遇上JWT校验

最近在做一个微信小程序的后端项目,用的是Java,认证这块选了JWT。听起来是个挺标准的组合,对吧?微信小程序前端发请求,Java后端用Spring Security或者Shiro配上JWT做令牌校验。但真上手了才发现,这里面的坑比想象中多。不是简单的生成一个Token,前端存起来,每次请求带上就完事了。从小程序特有的登录流程,到JWT令牌的存储、刷新、失效处理,再到网络环境复杂带来的校验失败,每一步都可能让你调试到怀疑人生。特别是当你在本地测试一切正常,一上真机或者体验版就各种“无效令牌”、“认证失败”的时候,那种感觉真是让人头大。这篇文章,我就把自己趟过的这些坑,还有最终跑通的完整方案,从头到尾捋一遍。无论你是刚开始接触小程序后端开发,还是正在被JWT校验问题困扰,希望这些实战经验能帮你省下不少折腾的时间。

2. 核心思路与架构设计

2.1 为什么是JWT?以及小程序场景下的特殊考量

JWT(JSON Web Token)之所以流行,核心在于它的无状态和自包含。服务器不需要在内存或数据库里维护会话,Token本身通过签名保证了内容不可篡改,非常适合分布式和前后端分离的架构。对于微信小程序后端来说,这听起来很完美。

但直接套用Web端的JWT方案会出问题。首先, 小程序没有传统意义上的Cookie 。你不能依赖 HttpOnly 的Cookie来安全地存储Token,防止XSS攻击。Token必须由前端JavaScript代码来管理(存Storage),这就引入了被恶意脚本窃取的风险(虽然小程序沙箱环境相对安全,但并非绝对)。其次, 小程序的网络请求环境复杂 。开发者工具、真机调试、体验版、正式版,每个环境的域名、TLS证书校验策略都可能不同,直接影响携带Token的请求能否成功发出和接收。最后, 微信的登录流程是强制的 。你需要先用 wx.login() 拿到 code ,换 openid session_key ,然后才能发放你自己的业务JWT。这意味着你的JWT签发,必须和微信的登录态绑定,逻辑上多了一层。

所以,我们的设计思路必须调整:

  1. 双Token设计(Access Token + Refresh Token) :这是应对小程序Token存储风险和提高体验的关键。短期的Access Token(如2小时)用于业务API请求,长期的Refresh Token(如7天)用于静默刷新。即使Access Token泄露,窗口期也很短。
  2. Token存储策略 :Access Token存于小程序的 wx.setStorageSync 。Refresh Token呢?为了安全,最好存于后端数据库或缓存(关联用户ID),前端只持有一个与之对应的、无业务含义的“刷新标识符”(如UUID),或者利用微信的 storage 配合后端加密存储。绝对不要把Refresh Token明文放在前端。
  3. 与微信登录态绑定 :我们签发的JWT,其Payload里应该包含从微信服务器换取的 openid (用户唯一标识)。这样,后续的校验逻辑才能将JWT持有者映射到具体的微信用户。
  4. 网络适配 :确保后端API的域名在小程序管理后台正确配置,并且所有接口(包括登录、刷新Token的接口)都支持HTTPS和正确的CORS(如果需要)或小程序要求的请求头。

2.2 技术栈选型与关键依赖

后端以Spring Boot为例,这是目前Java领域最快捷的方案。

  • 核心安全框架 Spring Security 。它功能强大,生态完整,虽然学习曲线陡,但一旦掌握,处理认证授权非常优雅。对于中小项目, Sa-Token 是一个极佳的轻量级替代品,API更简单直观,文档友好,对于快速实现JWT支持非常高效。
  • JWT库 :推荐 java-jwt (Auth0) jjwt (Okta) 。两者都是广泛使用、维护良好的库。Spring Security官方没有自带JWT实现,需要集成它们。这里我选用 jjwt ,因为它和Spring Boot集成起来比较顺手。
  • 持久层与缓存 :用 MyBatis-Plus Spring Data JPA 操作数据库,存储用户基础信息及Refresh Token的关联关系。缓存用 Redis ,用来实现Token的黑名单(登出时让未过期的Token立刻失效)和临时存储刷新令牌,性能远胜数据库。
  • 小程序前端 :就是微信开发者工具和官方JavaScript API。关键点在于 wx.request 的封装,要能自动携带Token,并在收到401响应时自动尝试刷新Token。

Maven核心依赖大概长这样:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

3. 核心细节解析与实操要点

3.1 微信登录流程与JWT签发融合

这是整个流程的起点,绝对不能出错。流程图如下:

小程序端 wx.login() -> 获取临时code -> 请求后端登录接口 -> 后端用code+appid+secret请求微信API -> 换取openid和session_key -> 后端生成或关联业务用户 -> 签发JWT(含openid) -> 返回给小程序。

关键实操点:

  1. session_key 的处理 :微信返回的 session_key 是敏感信息, 绝不能通过网络下发到小程序端 !它只应该存在于你的后端服务器。它的主要用途是后续解密微信的加密数据(如获取手机号)。所以,拿到后可以暂时存在Redis里,key为 openid ,设置一个较短的过期时间(如5分钟),以备解密时使用。
  2. JWT Payload设计 :不要存放敏感信息(如密码、 session_key )。标准字段如 sub (主题,可放用户ID或openid)、 iat (签发时间)、 exp (过期时间)是必须的。我强烈建议加一个自定义字段如 wx_openid ,方便后续业务逻辑直接取用。也可以放一些不敏感的用户信息,如昵称、头像URL(需从微信获取后存到你自己DB的),减少一次数据库查询。
    // 示例Payload构建
    Map<String, Object> claims = new HashMap<>();
    claims.put(“sub”, user.getId()); // 业务用户ID
    claims.put(“wx_openid”, openid);
    claims.put(“nickname”, user.getNickname());
    // exp, iat 由JWT库自动处理
    
  3. 密钥管理 :用于签名JWT的密钥(如HS256算法的secret)必须足够复杂,并且 不要硬编码在代码里 。使用环境变量、配置中心(如Apollo、Nacos)或云服务的密钥管理服务来存储。开发、测试、生产环境使用不同的密钥。

3.2 双Token刷新机制的具体实现

Access Token过期短是为了安全,但总不能让用户每2小时就重新登录一次。Refresh Token机制就是为了无感刷新。

流程:

  1. 登录成功时,后端不仅生成Access Token(AT),还生成一个唯一的Refresh Token(RT),将 RT 与用户ID的映射关系存入Redis或数据库,并设置较长的TTL(如7天)。将 AT RT 都返回给前端。
  2. 前端将 AT 用于日常API请求。将 RT 安全地存储 起来。如前所述,更安全的做法是后端只返回一个 refresh_token_id (如UUID),真正的RT存在后端关联此ID。
  3. 当请求API返回 401 Unauthorized (AT过期)时,前端不是直接跳转登录页,而是 自动发起一个到专用刷新接口的请求 ,携带 RT (或 refresh_token_id )。
  4. 后端刷新接口:
    • 校验 RT 是否有效(在缓存/DB中存在且未过期)。
    • 校验 RT 是否已被使用过(防止重放攻击,一次RT只允许换一次新的AT和RT)。
    • 校验该 RT 是否在黑名单(用户主动登出)。
    • 所有校验通过后, 使旧RT立即失效 ,然后生成新的 AT RT ,更新映射关系,返回给前端。
    • 前端用新的 AT 重试刚才失败的请求,并更新本地存储的Token。

注意事项:

  • 刷新接口本身也需要被保护 ,但不能用AT。通常的做法是将其设计为“白名单”接口,或者用一种特殊的、低权限的认证方式(如基于RT本身的签名)。在Spring Security中,你可以将这个接口路径从安全链中排除,然后在方法内手动进行上述RT校验。
  • 防止RT泄露 :因为RT有效期长,一旦泄露危害大。除了不向前端暴露明文RT,还要确保刷新接口防重放、防爆破。可以限制同一用户刷新频率,记录审计日志。
  • 并发刷新问题 :如果用户在AT即将过期时快速连续发起多个请求,可能同时触发多个刷新请求。需要在后端处理刷新逻辑时加锁(如基于用户ID的Redis分布式锁),确保同一时间只有一个刷新请求能成功,其他请求要么等待新Token,要么复用刚生成的新Token。

3.3 Spring Security整合JWT过滤器链

这是后端校验的核心。我们需要在Spring Security的过滤器链中插入一个自定义的JWT认证过滤器。

标准流程:

  1. 创建 JwtAuthenticationFilter :继承 OncePerRequestFilter 。在 doFilterInternal 方法中:
    • 从HTTP请求头 Authorization 中提取Token(格式: Bearer <your-jwt-token> )。
    • 如果头不存在或格式不对,直接放行(交给后续的过滤器,最终会因未认证而被拒绝访问受保护资源)。
    • 如果存在,则使用JWT库和密钥验证Token的签名和有效期。
    • 如果验证通过,从Token的Payload中提取用户标识(如 openid 或用户ID)。
    • 根据这个标识,从数据库或缓存中加载完整的用户信息( UserDetails 对象)。
    • 创建一个 UsernamePasswordAuthenticationToken 对象(代表已认证的主体),并设置到 SecurityContextHolder 中。这样,后续的控制器(Controller)就能通过 @AuthenticationPrincipal 注解获取当前用户了。
  2. 配置 SecurityFilterChain :在配置类中,通过 HttpSecurity 配置。
    • 将自定义的 JwtAuthenticationFilter 放在 UsernamePasswordAuthenticationFilter 之前。
    • 禁用默认的Session管理和表单登录: .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .formLogin().disable()
    • 配置URL的授权规则(哪些路径需要什么角色/权限)。
    • 别忘了处理跨域(CORS)和异常(如 AuthenticationEntryPoint 处理401, AccessDeniedHandler 处理403)。

一个容易踩的坑: 过滤器里验证Token过期时, jjwt 会抛出 ExpiredJwtException 。你不能简单地因为Token过期就返回401,因为前端可能正在用有效的RT进行刷新。更好的做法是,在过滤器中,如果捕获到过期异常,先检查请求是否来自白名单的刷新接口,或者检查请求头中是否携带了有效的RT标识。如果不是,再返回401,让前端触发刷新流程。

4. 实操过程与核心环节实现

4.1 后端JWT工具类与登录接口实现

首先,我们封装一个JWT工具类,负责生成和解析Token。

@Component
public class JwtUtil {
    @Value(“${jwt.secret}”)
    private String secretKey;
    @Value(“${jwt.access-token.expiration}”)
    private long accessTokenExpiration;
    @Value(“${jwt.refresh-token.expiration}”)
    private long refreshTokenExpiration;

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // 生成Access Token
    public String generateAccessToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put(“roles”, userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        // 添加自定义声明,如wx_openid
        // claims.put(“wx_openid”, openid);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername()) // 通常放用户名或用户ID
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    // 生成Refresh Token (内容更简单,只是一个唯一标识)
    public String generateRefreshToken() {
        // 可以就是一个UUID
        return UUID.randomUUID().toString();
        // 或者也封装成一个JWT,但声明更少,过期时间更长
        // return Jwts.builder().setSubject(UUID.randomUUID().toString())... .compact();
    }

    // 从Token中提取用户名(用户ID)
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 验证Token是否有效(未过期且签名正确)
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(getSigningKey())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            // 即使过期,我们也可能想获取其中的声明(比如用于刷新流程)
            // 这里直接抛出,由调用方处理
            throw e;
        } catch (JwtException e) {
            throw new RuntimeException(“Invalid JWT token”, e);
        }
    }
}

接着,实现登录控制器。它接收小程序传来的 code ,调用微信接口,然后签发JWT。

@RestController
@RequestMapping(“/api/auth”)
public class AuthController {
    @Autowired
    private WeChatService weChatService; // 封装了请求微信API的逻辑
    @Autowired
    private UserService userService;
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @PostMapping(“/login”)
    public ApiResponse<LoginResult> login(@RequestBody LoginRequest request) {
        // 1. 用code换openid和session_key
        WeChatAuthResponse weChatResp = weChatService.code2Session(request.getCode());
        String openid = weChatResp.getOpenid();
        String sessionKey = weChatResp.getSession_key();

        // 2. 根据openid查找或创建本地用户
        User user = userService.findOrCreateByOpenid(openid);

        // 3. 将session_key临时存入Redis (key: “session_key:” + openid, 过期时间5分钟)
        redisTemplate.opsForValue().set(“session_key:” + openid, sessionKey, 5, TimeUnit.MINUTES);

        // 4. 加载UserDetails (Spring Security需要的格式)
        UserDetails userDetails = userService.loadUserByUsername(user.getUsername());

        // 5. 生成双Token
        String accessToken = jwtUtil.generateAccessToken(userDetails);
        String refreshToken = jwtUtil.generateRefreshToken(); // 假设是UUID格式

        // 6. 将Refresh Token与用户关联存入Redis (key: “refresh_token:” + refreshToken, value: user.getId(), 过期时间7天)
        redisTemplate.opsForValue().set(“refresh_token:” + refreshToken, user.getId(), 7, TimeUnit.DAYS);

        // 7. 返回结果
        LoginResult result = new LoginResult();
        result.setAccessToken(accessToken);
        result.setRefreshToken(refreshToken); // 注意:生产环境考虑返回refresh_token_id而非明文
        result.setExpiresIn(jwtUtil.getAccessTokenExpiration() / 1000); // 秒数
        result.setUserInfo(...); // 部分用户信息
        return ApiResponse.success(result);
    }
}

4.2 前端小程序请求封装与Token管理

小程序端需要一个统一的 request 封装,处理Token的自动携带和刷新。

// utils/request.js
const BASE_URL = ‘https://your-api-domain.com‘;
let isRefreshing = false; // 是否正在刷新
let failedQueue = []; // 刷新期间失败的请求队列

function processQueue(error, token = null) {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
}

function refreshToken() {
  return new Promise((resolve, reject) => {
    wx.request({
      url: `${BASE_URL}/api/auth/refresh`,
      method: ‘POST‘,
      header: {
        ‘Authorization-Refresh‘: `Bearer ${wx.getStorageSync(‘refreshToken‘)}` // 使用特殊的头携带RT
      },
      success: (res) => {
        if (res.data.code === 0) {
          const newAccessToken = res.data.data.accessToken;
          const newRefreshToken = res.data.data.refreshToken; // 可能返回新的RT
          wx.setStorageSync(‘accessToken‘, newAccessToken);
          if (newRefreshToken) {
            wx.setStorageSync(‘refreshToken‘, newRefreshToken);
          }
          resolve(newAccessToken);
        } else {
          // 刷新失败,跳转登录页
          reject(new Error(‘Refresh token failed‘));
          wx.removeStorageSync(‘accessToken‘);
          wx.removeStorageSync(‘refreshToken‘);
          wx.reLaunch({ url: ‘/pages/login/login‘ });
        }
      },
      fail: (err) => {
        reject(err);
        wx.removeStorageSync(‘accessToken‘);
        wx.removeStorageSync(‘refreshToken‘);
        wx.reLaunch({ url: ‘/pages/login/login‘ });
      }
    });
  });
}

function request(options) {
  // 1. 设置基础URL和头
  options.url = `${BASE_URL}${options.url}`;
  const header = options.header || {};
  const accessToken = wx.getStorageSync(‘accessToken‘);
  if (accessToken) {
    header[‘Authorization‘] = `Bearer ${accessToken}`;
  }
  options.header = header;

  // 2. 返回Promise
  return new Promise((resolve, reject) => {
    const doRequest = (token) => {
      if (token) {
        options.header[‘Authorization‘] = `Bearer ${token}`;
      }
      wx.request({
        ...options,
        success: (res) => {
          if (res.statusCode === 401) {
            // Token过期或无效
            const originalRequest = options;
            if (!isRefreshing) {
              isRefreshing = true;
              refreshToken()
                .then(newToken => {
                  // 刷新成功,重试原请求
                  originalRequest.header[‘Authorization‘] = `Bearer ${newToken}`;
                  wx.request({
                    ...originalRequest,
                    success: resolve,
                    fail: reject
                  });
                  // 处理队列中的其他请求
                  processQueue(null, newToken);
                })
                .catch(err => {
                  // 刷新失败,队列中的请求也都失败
                  processQueue(err, null);
                  reject(err);
                })
                .finally(() => {
                  isRefreshing = false;
                });
            } else {
              // 正在刷新,将当前请求加入队列
              failedQueue.push({ resolve, reject, options });
            }
          } else if (res.statusCode >= 200 && res.statusCode < 300) {
            resolve(res);
          } else {
            reject(res);
          }
        },
        fail: reject
      });
    };
    doRequest();
  });
}

// 导出常用的方法
export function get(url, data = {}, options = {}) {
  return request({ url, data, method: ‘GET‘, ...options });
}
export function post(url, data = {}, options = {}) {
  return request({ url, data, method: ‘POST‘, ...options });
}
// ... 其他方法

app.js onLaunch 中,可以尝试检查本地是否有Token,并验证其有效性(例如调用一个 /api/auth/check 接口),以实现冷启动时的自动登录。

4.3 安全配置与Redis集成

在Spring Security配置类中,我们需要精细地配置过滤器链和与Redis的交互。

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private JwtAuthenticationFilter jwtAuthFilter;
    @Autowired
    private CustomAuthenticationEntryPoint authenticationEntryPoint; // 自定义401处理
    @Autowired
    private CustomAccessDeniedHandler accessDeniedHandler; // 自定义403处理

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .cors().configurationSource(corsConfigurationSource()).and() // 配置CORS
            .csrf().disable() // 无状态API,禁用CSRF
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() // 无状态
            .authorizeHttpRequests(authz -> authz
                .requestMatchers(“/api/auth/login”, “/api/auth/refresh”, “/api/public/**”).permitAll() // 公开接口
                .requestMatchers(“/api/admin/**”).hasRole(“ADMIN”) // 管理员接口
                .anyRequest().authenticated() // 其他所有接口都需要认证
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) // 添加JWT过滤器
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint) // 处理未认证
                .accessDeniedHandler(accessDeniedHandler); // 处理权限不足

        return http.build();
    }

    // CORS配置(小程序要求)
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(Arrays.asList(“https://*.yourdomain.com“)); // 或具体小程序域名
        configuration.setAllowedMethods(Arrays.asList(“GET”, “POST”, “PUT”, “DELETE”, “OPTIONS”));
        configuration.setAllowedHeaders(Arrays.asList(“*”));
        configuration.setAllowCredentials(true); // 如果需要携带Cookie,但JWT通常不需要
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration(“/**“, configuration);
        return source;
    }
}

对于Refresh Token的存储和校验,我们使用Redis。在刷新接口的实现中:

@Service
public class TokenRefreshService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private UserService userService;

    public LoginResult refreshAccessToken(String refreshToken) {
        // 1. 从Redis查找Refresh Token对应的用户ID
        String userIdKey = “refresh_token:” + refreshToken;
        String userId = redisTemplate.opsForValue().get(userIdKey);
        if (userId == null) {
            throw new RuntimeException(“Refresh token is invalid or expired”);
        }

        // 2. (可选) 检查此RT是否已被使用(防重放),这里用删除操作实现“一次性”
        // Boolean delete = redisTemplate.delete(userIdKey);
        // if (Boolean.FALSE.equals(delete)) { ... }

        // 3. 加载用户信息
        UserDetails userDetails = userService.loadUserById(userId);

        // 4. 生成新的双Token
        String newAccessToken = jwtUtil.generateAccessToken(userDetails);
        String newRefreshToken = jwtUtil.generateRefreshToken();

        // 5. 使旧RT失效,保存新RT
        redisTemplate.delete(userIdKey);
        redisTemplate.opsForValue().set(“refresh_token:” + newRefreshToken, userId, 7, TimeUnit.DAYS);

        // 6. 返回新Token
        LoginResult result = new LoginResult();
        result.setAccessToken(newAccessToken);
        result.setRefreshToken(newRefreshToken);
        result.setExpiresIn(jwtUtil.getAccessTokenExpiration() / 1000);
        return result;
    }
}

5. 常见问题与排查技巧实录

5.1 真机调试与网络环境问题

这是新手最常掉进去的坑。在开发者工具里一切正常,一到真机预览就报错 401 或者网络请求失败。

  • 问题1:域名不合法或未配置

    • 现象 :真机请求失败,开发者工具正常。错误信息可能包含“不在以下合法域名列表中”。
    • 排查 :登录微信小程序后台,在「开发」->「开发管理」->「开发设置」->「服务器域名」中,确保你的后端API域名(如 https://api.yourdomain.com )已添加到「request合法域名」列表中。 注意 :不能使用IP地址,必须是有备案的域名,且必须是HTTPS。
    • 技巧 :开发阶段,可以在开发者工具->详情->本地设置中,勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”。但这 仅对工具生效 ,真机必须配置合法域名。
  • 问题2:TLS版本或证书问题

    • 现象 :部分安卓机型或低版本微信无法连接。
    • 排查 :确保你的服务器支持TLS 1.2及以上版本。可以使用在线工具检查你的域名SSL证书和TLS支持情况。微信小程序要求HTTPS,且对证书有要求(如不能是自签名证书,必须由受信任的CA机构签发)。
    • 技巧 :使用Let‘s Encrypt等免费CA签发证书,既合规又免费。配置Nginx或你的Java应用服务器时,注意包含完整的证书链。
  • 问题3:请求头 Authorization 被拦截

    • 现象 :开发者工具能看到请求头里有 Authorization: Bearer xxx ,但后端日志显示没有收到这个头。
    • 排查 :可能是服务器(如Nginx)配置问题。检查Nginx配置,确保没有通过 underscores_in_headers on; 指令来允许带下划线的请求头( Authorization 头是标准的,通常没问题,但有些自定义头带下划线会被Nginx默认丢弃)。更常见的是后端CORS配置没有允许 Authorization 头。
    • 技巧 :在后端的CORS配置中,明确设置 configuration.setAllowedHeaders(Arrays.asList(“Authorization”, “Content-Type”, “X-Requested-With”)); 。使用浏览器的开发者工具或抓包工具(如Charles、Fiddler)查看实际发送和接收的HTTP请求/响应头,这是定位这类问题的黄金手段。

5.2 JWT令牌校验失败深度排查

  • 问题1:签名无效(Signature Invalid)

    • 现象 :后端解析JWT时抛出 SignatureException
    • 排查
      1. 密钥不一致 :检查生成Token和验证Token使用的Secret Key是否完全一致。确保生产环境和开发环境配置了不同的密钥,并且当前运行环境加载了正确的配置。
      2. 算法不匹配 :生成Token时指定了HS256,验证时也必须用HS256。检查代码中 signWith parserBuilder().setSigningKey 使用的算法是否匹配。
      3. Token被篡改 :虽然可能性小,但可以对比Token各部分Base64解码后的内容。一个JWT由 Header.Payload.Signature 三部分组成。你可以将Header和Payload部分用Base64解码,看看内容是否合理。任何字符的改动都会导致签名验证失败。
    • 技巧 :在开发环境,可以将解密后的Payload打印到日志中(注意不要打印敏感信息),确认其中包含你期望的字段(如 sub , exp , wx_openid )。
  • 问题2:令牌过期(Token Expired)

    • 现象 :抛出 ExpiredJwtException
    • 排查
      1. 检查Token的 exp 声明,确认是否真的过期。可以使用 jwt.io 这个网站粘贴你的Token进行解码查看(离线操作,确保安全)。
      2. 检查服务器时间是否正确。如果服务器时间比实际时间快,会导致Token被判定为提前过期。确保服务器使用NTP服务同步时间。
    • 技巧 :在前端,可以在发起请求前,先本地解析JWT的Payload(JavaScript库如 jsonwebtoken ),判断过期时间,如果临近过期(如还剩5分钟),可以主动提前调用刷新接口,而不是等到请求返回401再处理,提升用户体验。
  • 问题3:无法获取认证信息(SecurityContext为空)

    • 现象 :过滤器似乎通过了,但在Controller里用 @AuthenticationPrincipal 获取到的用户信息为null。
    • 排查
      1. 确保你的JWT过滤器正确地将 Authentication 对象设置到了 SecurityContextHolder.getContext().setAuthentication(authentication) 。并且,这个操作是在请求线程内完成的。
      2. Spring Security的 SecurityContext 默认是与线程绑定的。如果你在过滤器中使用了异步处理,或者在Controller中开启了新线程,会导致上下文丢失。确保认证逻辑在同步的过滤器链中完成。
      3. 检查你的Controller方法参数注解是否正确。对于从JWT中提取的自定义字段,你可能需要实现一个 ArgumentResolver 来解析。
    • 技巧 :在过滤器中,在设置完 SecurityContext 之后,可以临时打印一行日志,确认认证信息已设置。在Controller中,也可以通过 SecurityContextHolder.getContext().getAuthentication() 直接获取来验证。

5.3 性能与安全性优化要点

  • 性能

    • 减少数据库查询 :每次请求都根据JWT中的用户ID查数据库,会给DB造成压力。可以将常用的、不常变的用户信息(如昵称、头像)也编码到JWT的Payload中,或者将 UserDetails 对象在验证通过后缓存到Redis中(键如 user:details:<userId> ,过期时间略短于Access Token)。
    • 黑名单的存储策略 :用户登出后,需要将未过期的Token加入黑名单。如果所有Token都存,量会很大。一个折中方案是:只将登出时仍剩余有效时间较长的Token(如超过5分钟)加入黑名单,短时间即将过期的可以忽略。黑名单的Key可以设计为 jwt:blacklist:<token的简略指纹> ,值设为登出时间,并设置TTL为该Token的原剩余过期时间。
  • 安全

    • 密钥轮转 :定期(如每季度)更换JWT签名密钥。旧密钥需要在一段时间内(如新旧Token重叠期)同时有效,用于验证旧的未过期Token,新签发的Token则使用新密钥。
    • Token绑定 :除了用户标识,可以在JWT中加入一个 jti (JWT ID)唯一标识,并将此 jti 与客户端指纹(如IP地址、User-Agent的哈希)绑定存入Redis。校验时,不仅验签名和过期,还校验绑定关系是否一致,防止Token被劫持后在其它设备使用。
    • 接口限流与风控 :对登录、刷新Token等敏感接口实施严格的限流(如每秒1次),防止暴力破解和枚举攻击。记录异常登录尝试(如IP、设备),达到阈值后临时锁定。

整个方案从设计到实现,细节非常多。最关键的是理解小程序这个特定场景下的约束(无Cookie、微信登录流程、域名限制),然后在JWT无状态认证的通用最佳实践之上,做出针对性的适配(双Token、与微信绑定、网络兼容)。调试阶段,善用日志和抓包工具,从小程序端发出的请求开始,一步步追踪到后端过滤器和控制器,是解决问题的唯一捷径。

更多推荐