You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 18:15:55