Spring Boot微服务JWT集成测试401未授权问题修复求助
问题:Spring Boot JWT微服务集成测试返回401未授权
我正在实现一个基于JWT的Spring Boot微服务示例,为商品服务的相关端点配置了管理员和用户对应的Bearer Token,但所有集成测试均返回401未授权错误,该如何修复?
商品服务安全配置
@Configuration @EnableWebSecurity @RequiredArgsConstructor @EnableMethodSecurity public class SecurityConfig { private final UserServiceClient userServiceClient; @Bean protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); } @Bean public SecurityFilterChain filterChain( final HttpSecurity httpSecurity, final CustomBearerTokenAuthenticationFilter customBearerTokenAuthenticationFilter, final CustomAuthenticationEntryPoint customAuthenticationEntryPoint ) throws Exception { httpSecurity .exceptionHandling(customizer -> customizer.authenticationEntryPoint(customAuthenticationEntryPoint)) .cors(customizer -> customizer.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(customizer -> customizer .anyRequest().authenticated() ) .sessionManagement(customizer -> customizer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(customBearerTokenAuthenticationFilter, BearerTokenAuthenticationFilter.class); return httpSecurity.build(); } private CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("*")); configuration.setAllowedMethods(List.of("*")); configuration.setAllowedHeaders(List.of("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
集成测试方法
@Test void givenProductPagingRequest_whenGetProductsFromAdmin_thenReturnCustomPageProduct() throws Exception { // Given ProductPagingRequest pagingRequest = ProductPagingRequest.builder() .pagination( CustomPaging.builder() .pageSize(1) .pageNumber(1) .build() ).build(); String productId = UUID.randomUUID().toString(); ProductEntity expected = ProductEntity.builder() .id(productId) .name("Test Product") .unitPrice(BigDecimal.valueOf(12)) .amount(BigDecimal.valueOf(5)) .build(); List<ProductEntity> productEntities = new ArrayList<>(); productEntities.addAll(Collections.singletonList(expected)); Page<ProductEntity> productEntityPage = new PageImpl<>(productEntities, PageRequest.of(1, 1), productEntities.size()); List<Product> productDomainModels = productEntities.stream() .map(entity -> new Product(entity.getId(), entity.getName(), entity.getAmount(),entity.getUnitPrice())) .collect(Collectors.toList()); CustomPage<Product> productPage = CustomPage.of(productDomainModels, productEntityPage); // When when(productReadService.getProducts(any(ProductPagingRequest.class))).thenReturn(productPage); // Then mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(pagingRequest)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + mockAdminToken.getAccessToken())) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.httpStatus").value("OK")) .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess").value(true)) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].id").value(expected.getId())) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].name").value(expected.getName())) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].amount").value(expected.getAmount())) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].unitPrice").value(expected.getUnitPrice())); // Verify verify(productReadService, times(1)).getProducts(any(ProductPagingRequest.class)); }
CustomBearerTokenAuthenticationFilter代码
@Slf4j @Component @RequiredArgsConstructor public class CustomBearerTokenAuthenticationFilter extends OncePerRequestFilter { private final UserServiceClient userServiceClient; @Override protected void doFilterInternal(@NonNull final HttpServletRequest httpServletRequest, @NonNull final HttpServletResponse httpServletResponse, @NonNull final FilterChain filterChain) throws ServletException, IOException { log.debug("API Request was secured with Security!"); final String authorizationHeader = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION); if (Token.isBearerToken(authorizationHeader)) { final String jwt = Token.getJwt(authorizationHeader); userServiceClient.validateToken(jwt); } filterChain.doFilter(httpServletRequest, httpServletResponse); } }
测试结果
MockHttpServletRequest: HTTP Method = GET Request URI = /api/v1/products Parameters = {} Headers = [Content-Type:"application/json;charset=UTF-8", Authorization:"Bearer eyJ0eXAiOiJCZWFyZXIiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJmOGM3M2JhNy0zNDU2LTQ4NDgtOTFiYy1iN2E3OWM2M2E5ODciLCJpc3MiOiJJU1NVRVIiLCJpYXQiOjE3MjExNjE5MjYsImV4cCI6MTcyMTE2MzcyNiwidXNlclN0YXR1cyI6IkFDVElWRSIsInVzZXJMYXN0TmFtZSI6IkRvZSIsInVzZXJQaG9uZU51bWJlciI6IjEyMzQ1Njc4OTAxMDExIiwidXNlckVtYWlsIjoidXNlcmFkbWluQGV4YW1wbGUuY29tIiwidXNlclR5cGUiOiJVU0VSIiwidXNlckZpcnN0TmFtZSI6IkpvaG4iLCJ1c2VySWQiOiJjZTJkOGI2Yi0wZGVlLTRlNGYtODdjOS05ZTRkY2Y4ZDI5OGUifQ.SH5mUFw59Ux2HX6VCIeIifslZFx1RQSTzT1R_zgNbWX1K5vngoAkzFP4kjrOUgS8tqJnBuzY98t5bCZA74L0vuZkNibDdI7Pc8HwHL3k2H2x6vtGPIC0sEJOVWPiNu7Lgb0XF77xp0_KEKw_UkIwfgYY-CCKL-fcAKBwf4z5QY26rtgXxrHn8Ajmh9DCpya9_LnEcplLfcxRWFWmkN2IL8OsklO5EtSSRo14uaKb7ZE4J3lV57ZJG1ADmYfDFO_nJBNFmwSpaUa1VM_6AB1vOTiv4OliVhbA6PQzrQ7xeIGlaAinrV1AoZfOQIFO-rkkkwYd2D91ymTCVEpBrk60Cg", Content-Length:"44"] Body = {"pagination":{"pageNumber":0,"pageSize":1}} Session Attrs = {} Handler: Type = null Async: Async started = false Async result = null Resolved Exception: Type = null ModelAndView: View name = null View = null Model = null FlashMap: Attributes = null MockHttpServletResponse: Status = 401 Error message = null Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Content-Type:"application/json", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"] Content type = application/json Body = {"time":"2024-07-16T23:32:07.4830196","httpStatus":"UNAUTHORIZED","header":"AUTH ERROR","isSuccess":false} Forwarded URL = null Redirected URL = null Cookies = [] java.lang.AssertionError: Status expected:<200> but was:<401> Expected :200 Actual :401
修复方案
1. 核心问题分析
当前的CustomBearerTokenAuthenticationFilter仅调用了userServiceClient.validateToken(jwt)验证Token有效性,但没有将认证成功后的用户信息注入Spring Security的SecurityContext。Spring Security的授权机制依赖SecurityContext中的认证对象,因此即便Token有效,系统仍会判定用户未认证,返回401。
2. 修改CustomBearerTokenAuthenticationFilter
在Token验证成功后,构建Authentication对象并设置到SecurityContext中:
@Slf4j @Component @RequiredArgsConstructor public class CustomBearerTokenAuthenticationFilter extends OncePerRequestFilter { private final UserServiceClient userServiceClient; @Override protected void doFilterInternal(@NonNull final HttpServletRequest httpServletRequest, @NonNull final HttpServletResponse httpServletResponse, @NonNull final FilterChain filterChain) throws ServletException, IOException { log.debug("API Request was secured with Security!"); final String authorizationHeader = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION); if (Token.isBearerToken(authorizationHeader)) { final String jwt = Token.getJwt(authorizationHeader); // 调用用户服务验证Token,获取用户信息(需确保userServiceClient.validateToken返回用户角色等信息) UserInfo userInfo = userServiceClient.validateToken(jwt); // 转换用户角色为Spring Security的权限对象 Collection<GrantedAuthority> authorities = userInfo.getRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // 遵循Spring Security权限前缀规范 .collect(Collectors.toList()); // 创建认证对象 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userInfo.getUserId(), // 主体标识,可替换为完整用户对象 null, // JWT场景下无需存储凭证 authorities // 用户权限集合 ); // 将认证对象注入SecurityContext SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(httpServletRequest, httpServletResponse); } }
3. 完善集成测试逻辑
集成测试中需要MockuserServiceClient.validateToken方法,避免调用真实外部服务,并确保SecurityContext在测试后清理:
@SpringBootTest @AutoConfigureMockMvc class ProductControllerTest { @Autowired private MockMvc mockMvc; @MockBean private ProductReadService productReadService; @MockBean private UserServiceClient userServiceClient; @Autowired private ObjectMapper objectMapper; @AfterEach void tearDown() { // 清理SecurityContext,避免测试间状态污染 SecurityContextHolder.clearContext(); } @Test void givenProductPagingRequest_whenGetProductsFromAdmin_thenReturnCustomPageProduct() throws Exception { // Given ProductPagingRequest pagingRequest = ProductPagingRequest.builder() .pagination(CustomPaging.builder().pageSize(1).pageNumber(1).build()) .build(); String productId = UUID.randomUUID().toString(); ProductEntity expected = ProductEntity.builder() .id(productId) .name("Test Product") .unitPrice(BigDecimal.valueOf(12)) .amount(BigDecimal.valueOf(5)) .build(); List<ProductEntity> productEntities = Collections.singletonList(expected); Page<ProductEntity> productEntityPage = new PageImpl<>(productEntities, PageRequest.of(1, 1), productEntities.size()); List<Product> productDomainModels = productEntities.stream() .map(entity -> new Product(entity.getId(), entity.getName(), entity.getAmount(), entity.getUnitPrice())) .collect(Collectors.toList()); CustomPage<Product> productPage = CustomPage.of(productDomainModels, productEntityPage); // Mock业务服务和Token验证逻辑 when(productReadService.getProducts(any(ProductPagingRequest.class))).thenReturn(productPage); when(userServiceClient.validateToken(anyString())).thenReturn(UserInfo.builder() .userId("ce2d8b6b-0dee-4e4f-87c9-9e4dcf8d298e") .roles(List.of("ADMIN")) // 与过滤器中的权限前缀对应 .build()); // Then mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(pagingRequest)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + mockAdminToken.getAccessToken())) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.httpStatus").value("OK")) .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess").value(true)) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].id").value(expected.getId())) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].name").value(expected.getName())) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].amount").value(expected.getAmount())) .andExpect(MockMvcResultMatchers.jsonPath("$.response.content[0].unitPrice").value(expected.getUnitPrice())); // Verify verify(productReadService, times(1)).getProducts(any(ProductPagingRequest.class)); } }
4. 额外注意事项
- 确保
UserInfo类包含用户ID、角色列表等必要字段,与userServiceClient.validateToken的返回值结构匹配。 - 权限前缀需统一:过滤器中为角色添加
ROLE_前缀,测试中传入的角色名称需对应(如测试传ADMIN,过滤器转换为ROLE_ADMIN)。 - 如果
userServiceClient.validateToken在Token无效时抛出异常,需在过滤器中捕获并调用customAuthenticationEntryPoint返回标准401响应。
内容的提问来源于stack exchange,提问作者jacksondel
相关产品推荐
相关产品推荐

