【问题标题】:How to mock JWT authenticaiton in a Spring Boot Unit Test?如何在 Spring Boot 单元测试中模拟 JWT 身份验证?
【发布时间】:2020-08-13 11:15:56
【问题描述】:

我已在 this example 之后将使用 Auth0JWT 身份验证添加到我的 Spring Boot REST API。

现在,正如预期的那样,我之前工作的Controller 单元测试给出了401 Unauthorized 而不是200 OK 的响应代码,因为我在测试中没有通过任何 JWT。

如何模拟我的 REST 控制器测试的 JWT/Authentication 部分?

单元测试类:

    @AutoConfigureMockMvc
    public class UserRoundsControllerTest extends AbstractUnitTests {

        private static String STUB_USER_ID = "user3";
        private static String STUB_ROUND_ID = "7e3b270222252b2dadd547fb";

        @Autowired
        private MockMvc mockMvc;

        private Round round;

        private ObjectId objectId;

        @BeforeEach
        public void setUp() {
            initMocks(this);
            round = Mocks.roundOne();
            objectId = Mocks.objectId();
        }

        @Test
        public void shouldGetAllRoundsByUserId() throws Exception {

            // setup
            given(userRoundService.getAllRoundsByUserId(STUB_USER_ID)).willReturn(Collections.singletonList(round));

            // mock the rounds/userId request
            RequestBuilder requestBuilder = Requests.getAllRoundsByUserId(STUB_USER_ID);

            // perform the requests
            MockHttpServletResponse response = mockMvc.perform(requestBuilder)
                .andReturn()
                .getResponse();

            // asserts
            assertNotNull(response);
            assertEquals(HttpStatus.OK.value(), response.getStatus());
        }

        //other tests
}

请求类(上面使用):

public class Requests {

    private Requests() {
    }

    public static RequestBuilder getAllRoundsByUserId(String userId) {

        return MockMvcRequestBuilders
            .get("/users/" + userId + "/rounds/")
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON);
    }

}

Spring 安全配置:

/**
 * Configures our application with Spring Security to restrict access to our API endpoints.
 */
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${auth0.audience}")
    private String audience;

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuer;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        /*
        This is where we configure the security required for our endpoints and setup our app to serve as
        an OAuth2 Resource Server, using JWT validation.
        */

        http.cors().and().csrf().disable().sessionManagement().
            sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()
            .mvcMatchers(HttpMethod.GET,"/users/**").authenticated()
            .mvcMatchers(HttpMethod.POST,"/users/**").authenticated()
            .mvcMatchers(HttpMethod.DELETE,"/users/**").authenticated()
            .mvcMatchers(HttpMethod.PUT,"/users/**").authenticated()
            .and()
            .oauth2ResourceServer().jwt();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        /*
        By default, Spring Security does not validate the "aud" claim of the token, to ensure that this token is
        indeed intended for our app. Adding our own validator is easy to do:
        */

        NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
            JwtDecoders.fromOidcIssuerLocation(issuer);

        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

        jwtDecoder.setJwtValidator(withAudience);

        return jwtDecoder;
    }


    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

抽象单元测试类:

@ExtendWith(SpringExtension.class)
@SpringBootTest(
    classes = PokerStatApplication.class,
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
public abstract class AbstractUnitTests {

   // mock objects etc


}

【问题讨论】:

  • 一种方法是在测试配置文件的情况下禁用安全性。因此,不应在测试配置文件的情况下初始化您的 SecurityConfig bean。
  • @S_learner 能否提供一个代码示例?
  • 您需要将 JWT 令牌作为附加的 HTTP 标头传递,Jhipster 的示例应用程序有这样的单元测试:github.com/jhipster/jhipster-sample-app/blob/master/src/test/…

标签: java spring spring-boot unit-testing jwt


【解决方案1】:

对于像我这样的其他人,他们从看起来像数不清的 StackOverlow 中收集了有关如何执行此操作的答案的信息后,这里是最终对我有用的总结(使用 Kotlin 语法,但它也适用于 Java) :

第 1 步 - 定义要在测试中使用的自定义 JWT 解码器

注意 JwtClaimNames.SUB 条目 - 这是最终可通过 authentication.getName() 字段访问的用户名。

val jwtDecoder = JwtDecoder {
        Jwt(
                "token",
                Instant.now(),
                Instant.MAX,
                mapOf(
                        "alg" to "none"
                ),
                mapOf(
                        JwtClaimNames.SUB to "testUser"
                )
        )
}

第 2 步 - 定义 TestConfiguration

这个类进入你的test 文件夹。我们这样做是为了用一个始终将用户视为经过身份验证的存根替换真正的实现。

请注意,我们还没有完成,请检查第 3 步。

@TestConfiguration
class TestAppConfiguration {

    @Bean // important
    fun jwtDecoder() {
        // Initialize JWT decoder as described in step 1
        // ...

        return jwtDecoder
    }

}

第 3 步 - 更新您的主要配置以避免 bean 冲突

如果不进行此更改,您的测试 bean 和生产 bean 将会发生冲突,从而导致冲突。添加这一行会延迟 bean 的解析,并让 Spring 将测试 bean 优先于生产 bean。

但有一点需要注意,因为此更改有效地消除了 JwtDecoder 实例的生产构建中的 bean 冲突保护。

@Configuration
class AppConfiguration {

    @Bean
    @ConditionalOnMissingBean // important
    fun jwtDecoder() {
        // Provide decoder as you would usually do
    }

}

第 4 步 - 在您的测试中导入 TestAppConfiguration

这可确保您的测试确实考虑了 TestConfiguration。

@SpringBootTest
@Import(TestAppConfiguration::class)
class MyTest {

    // Your tests

}

第 5 步 - 将 @WithMockUser 注释添加到您的测试中

您实际上不需要为注释提供任何参数。

@Test
@WithMockUser
fun myTest() {
    // Test body
}

第 6 步 - 在测试期间提供身份验证标头

mockMvc
    .perform(
        post("/endpointUnderTest")
            .header(HttpHeaders.AUTHORIZATION, "Bearer token") // important
    )
    .andExpect(status().isOk)

【讨论】:

    【解决方案2】:

    如果我正确理解您的情况,有一种解决方案。

    在大多数情况下,如果请求标头中存在令牌,JwtDecoder bean 会执行令牌解析和验证。

    您的配置示例:

        @Bean
        JwtDecoder jwtDecoder() {
            /*
            By default, Spring Security does not validate the "aud" claim of the token, to ensure that this token is
            indeed intended for our app. Adding our own validator is easy to do:
            */
    
            NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
                JwtDecoders.fromOidcIssuerLocation(issuer);
    
            OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);
            OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
            OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
    
            jwtDecoder.setJwtValidator(withAudience);
    
            return jwtDecoder;
        }
    

    所以对于测试,你需要添加这个 bean 的存根,并且为了在 spring 上下文中替换这个 bean,你需要使用它的测试配置。

    可能是这样的:

    @TestConfiguration
    public class TestSecurityConfig {
    
      static final String AUTH0_TOKEN = "token";
      static final String SUB = "sub";
      static final String AUTH0ID = "sms|12345678";
    
      public JwtDecoder jwtDecoder() {
        // This anonymous class needs for the possibility of using SpyBean in test methods
        // Lambda cannot be a spy with spring @SpyBean annotation
        return new JwtDecoder() {
          @Override
          public Jwt decode(String token) {
            return jwt();
          }
        };
      }
    
      public Jwt jwt() {
    
        // This is a place to add general and maybe custom claims which should be available after parsing token in the live system
        Map<String, Object> claims = Map.of(
            SUB, USER_AUTH0ID
        );
    
        //This is an object that represents contents of jwt token after parsing
        return new Jwt(
            AUTH0_TOKEN,
            Instant.now(),
            Instant.now().plusSeconds(30),
            Map.of("alg", "none"),
            claims
        );
      }
    
    }
    

    要在测试中使用此配置,只需选择此测试安全配置:

    @SpringBootTest(classes = TestSecurityConfig.class)

    在测试请求中也应该是带有Bearer .. something 之类的令牌的授权标头。

    以下是有关您的配置的示例:

        public static RequestBuilder getAllRoundsByUserId(String userId) {
    
            return MockMvcRequestBuilders
                .get("/users/" + userId + "/rounds/")
                .accept(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.AUTHORIZATION, "Bearer token"))
                .contentType(MediaType.APPLICATION_JSON);
        }
    

    【讨论】:

    • 我认为您在 TestSecurityConfig 中的 JwtDecoder 上缺少 @Bean。至少我通过在真正的 JwtDecoder bean 定义上添加它加上 @MissingOnConditionalBean 来让它工作。
    • 那你如何使用request builder呢?因为在@WebMvcTest 中或多或少使用了它,而@SpringBootTest 不能使用它。
    • 我收到tokenValue cannot be empty 错误。有什么想法吗?
    【解决方案3】:

    试试@WithMockUser

        @Test
        @WithMockUser(username="ahmed",roles={"ADMIN"})
        public void shouldGetAllRoundsByUserId() throws Exception {
    

    【讨论】:

      【解决方案4】:

      您可以获取 Bearer 令牌并将其作为 HTTP 标头传递。下面是测试方法的一个示例sn-p,供您参考,

      @Test
      public void existentUserCanGetTokenAndAuthentication() throws Exception {
         String username = "existentuser";
         String password = "password";
      
         String body = "{\"username\":\"" + username + "\", \"password\":\" 
                    + password + "\"}";
      
         MvcResult result = mvc.perform(MockMvcRequestBuilders.post("/token")
                .content(body))
                .andExpect(status().isOk()).andReturn();
      
         String response = result.getResponse().getContentAsString();
         response = response.replace("{\"access_token\": \"", "");
         String token = response.replace("\"}", "");
      
         mvc.perform(MockMvcRequestBuilders.get("/users/" + userId + "/rounds")
            .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
      }
      

      【讨论】:

        【解决方案5】:

        在 test/resources 中创建 application.properties(它将覆盖 main 但仅用于测试阶段)

        通过指定关闭安全性:

        security.ignored=/**
        security.basic.enable= false
        spring.autoconfigure.exclude= org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
        

        【讨论】:

          【解决方案6】:

          SecurityConfig bean 可以有条件地加载为,

          @Configuration
          @EnableWebSecurity
          public class SecurityConfig {
          
            @Bean
            @Profile("!test")
            public WebSecurityConfigurerAdapter securityEnabled() {
          
              return new WebSecurityConfigurerAdapter() {
          
                @Override
                protected void configure(HttpSecurity http) throws Exception {
                  // your code goes here
                }
          
              };
            }
          
            @Bean
            @Profile("test")
            public WebSecurityConfigurerAdapter securityDisabled() {
          
              return new WebSecurityConfigurerAdapter() {
          
                @Override
                protected void configure(HttpSecurity http) throws Exception {
                  http.authorizeRequests().anyRequest().permitAll();
                }
              };
            }
          
          }
          

          所以在测试配置文件的情况下,这个 bean 不会被初始化。这意味着现在安全性被禁用,所有端点都可以在没有任何授权标头的情况下访问。

          现在“测试”配置文件需要在运行测试的情况下处于活动状态,可以这样做,

          @RunWith(SpringRunner.class)
          @ActiveProfiles("test")
          @WebMvcTest(UserRoundsController.class)
          public class UserRoundsControllerTest extends AbstractUnitTests {
          
          // your code goes here
          
          }
          

          现在这个测试将使用配置文件“test”运行。 此外,如果您想拥有与此测试相关的任何属性,可以将其放在 src/test/resources/application-test.properties 下。

          希望这会有所帮助!否则请告诉我。

          更新: 基本思想是禁用测试配置文件的安全性。在之前的代码中,即使在配置了特定于配置文件的 bean 之后,也会启用默认安全性。

          【讨论】:

          • 对不起,这似乎不起作用 - 我已经添加了上面的代码,但在未经授权的情况下仍然遇到相同的问题 401。我已将我的抽象单元测试类添加到我的问题中以提供帮助..
          • 我现在在尝试此操作时收到以下错误:WebSecurityConfigurers 上的@Order 必须是唯一的。 com.ryd.pokerstats.pokerstats.auth.SecurityConfig$$EnhancerBySpringCGLIB$$f1a72b2@baa9ce4 已经使用了 100 的顺序,因此它也不能在 com.ryd.pokerstats.pokerstats.auth.SecurityConfig$1@5b332439 上使用。
          • 这只是禁用身份验证,并不是解决方案。忽略这样一个事实,即增加了获得误报测试通过的风险,它也没有涵盖代码依赖于“当前用户”的场景。我已经看到这样的解决方案最终导致开发人员将“当前用户”作为请求参数发送,这是一个很大的安全问题。
          猜你喜欢
          • 2016-04-04
          • 2019-12-28
          • 2018-02-27
          • 2022-10-05
          • 1970-01-01
          • 2020-06-09
          • 2017-11-02
          • 1970-01-01
          • 2021-07-20
          相关资源
          最近更新 更多