资源服务器的春季集成测试基于spring-cloud-starter-oauth2

问题描述

我正在将Spring Boot和Spring Cloud用于oAuth2资源服务器。这是配置:

pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-acl</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

...
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR8</version>

ResourceServerConfig

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Value("${my-app.security.audience}")
    private String audience;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.resourceId(audience);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .cors().and()
                .httpBasic().disable()
                .formLogin().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests(authorize -> authorize
                    .antMatchers("/actuator/**").permitAll() // TODO: Enable basic auth for actuator
                    .anyRequest().authenticated()
                );
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration().applyPermitDefaultValues();
        corsConfiguration.addAllowedMethod("PATCH");
        source.registerCorsConfiguration("/**",corsConfiguration);
        return source;
    }

    @Bean
    public ResourceServerProperties resourceServerProperties() {
        return new ResourceServerProperties(null,null);
    }
}

OidcJwkTokenStoreConfig

@Configuration
public class OidcJwkTokenStoreConfig {
    private final ResourceServerProperties resource;

    public OidcJwkTokenStoreConfig(ResourceServerProperties resource) {
        this.resource = resource;
    }

    @Bean
    public TokenStore jwkTokenStore(UserDetailsService userDetailsService) {
        DefaultAccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();
        tokenConverter.setUserTokenConverter(new MvcUserAuthenticationConverter(userDetailsService));
        return new JwkTokenStore(this.resource.getJwk().getKeySetUri(),tokenConverter);
    }
}

自定义用户身份验证转换器

public class MvcUserAuthenticationConverter implements UserAuthenticationConverter {
    private final String SUB = "sub";
    private final UserDetailsService userDetailsService;

    public MvcUserAuthenticationConverter(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    public Map<String,?> convertUserAuthentication(Authentication userAuthentication) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Authentication extractAuthentication(Map<String,?> map) {
        if (map.containsKey(SUB)) {
            Object principal = map.get(SUB);
            Collection<? extends GrantedAuthority> authorities = getAuthorities(map);
            if (userDetailsService != null) {
                UserDetails user = userDetailsService.loadUserByUsername((String) map.get(SUB));
                authorities = user.getAuthorities();
                principal = user;
            }
            return new UsernamePasswordAuthenticationToken(principal,"N/A",authorities);
        }
        return null;
    }

    private Collection<? extends GrantedAuthority> getAuthorities(Map<String,?> map) {
        if (!map.containsKey(AUTHORITIES)) {
            return null;
        }
        Object authorities = map.get(AUTHORITIES);
        if (authorities instanceof String) {
            return AuthorityUtils.commaSeparatedStringToAuthorityList((String) authorities);
        }
        if (authorities instanceof Collection) {
            return AuthorityUtils.commaSeparatedStringToAuthorityList(StringUtils
                    .collectionToCommaDelimitedString((Collection<?>) authorities));
        }
        throw new IllegalArgumentException("Authorities must be either a String or a Collection");
    }
}

如何进行集成测试?

单元测试不是一个大问题。但是当涉及到集成测试时,我很挣扎。如何模拟/跳过提供真实的承载令牌?首选的解决方案是使用MockMvc进行集成测试。

到目前为止,我得到了以下内容:

@SpringBootTest
@Testcontainers
@ContextConfiguration(
        initializers = ProjectResourceTest.Initializer.class,classes = {ProjectResourceTest.ApiTestConfiguration.class,MyApplication.class}
)
@AutoConfigureMockMvc
public class ProjectResourceTest {
    @Container
    private static final MongoDBContainer mongoDB = new MongoDBContainer("mongo:4.2.5");

    public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
            TestPropertyValues values = TestPropertyValues.of(
                    "spring.data.mongodb.uri=" + mongoDB.getReplicaSetUrl()
            );
            values.applyTo(configurableApplicationContext);
        }
    }

    @TestConfiguration
    public static class ApiTestConfiguration {
        @Bean
        @Primary
        public UserDetailsService userDetailsService() {
            MvcUser defaultUser = new MvcUser("default-user","Default User");

            return new InMemoryUserDetailsManager(singletonList(defaultUser));
        }
    }

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    MongoTemplate mongo;

    @Test
    @WithUserDetails("default-user")
    void shouldReturnAllProjects() throws Exception {
        ProjectEntity project = ProjectFaker.newProjectEntity();
        mongo.save(project);

        mockMvc.perform(get("/api/v1/projects"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.key",is(project.getKey())))
                .andExpect(jsonPath("$.name",is(project.getName())))
                .andExpect(jsonPath("$.createdAt",is(project.getCreatedAt())));
    }
}

但是这种方法以以下异常结束

java.lang.IllegalStateException: Unable to create SecurityContext using @org.springframework.security.test.context.support.WithUserDetails(setupBefore=TEST_METHOD,userDetailsServiceBeanName=,value=default-user)

    at org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.createTestSecurityContext(WithSecurityContextTestExecutionListener.java:126)
    at org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.createTestSecurityContext(WithSecurityContextTestExecutionListener.java:96)
    at org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.beforeTestMethod(WithSecurityContextTestExecutionListener.java:62)
    at org.springframework.test.context.TestContextManager.beforeTestMethod(TestContextManager.java:289)
    at org.springframework.test.context.junit.jupiter.SpringExtension.beforeEach(SpringExtension.java:108)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeEachCallbacks$1(TestMethodTestDescriptor.java:161)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs$5(TestMethodTestDescriptor.java:197)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeMethodsOrCallbacksUntilExceptionOccurs(TestMethodTestDescriptor.java:197)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeEachCallbacks(TestMethodTestDescriptor.java:160)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:131)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:71)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:135)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:125)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:123)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:122)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:80)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:125)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:123)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:122)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:80)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:125)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:123)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:122)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:80)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:125)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:123)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:122)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:80)
    at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:32)
    at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
    at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:51)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:248)
    at org.junit.platform.launcher.core.DefaultLauncher.lambda$execute$5(DefaultLauncher.java:211)
    at org.junit.platform.launcher.core.DefaultLauncher.withInterceptedStreams(DefaultLauncher.java:226)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:199)
    at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:132)
    at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:71)
    at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
    at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:220)
    at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:53)
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.security.core.userdetails.UserDetailsService' available
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:351)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:342)
    at org.springframework.security.test.context.support.WithUserDetailsSecurityContextFactory.findUserDetailsService(WithUserDetailsSecurityContextFactory.java:78)
    at org.springframework.security.test.context.support.WithUserDetailsSecurityContextFactory.createSecurityContext(WithUserDetailsSecurityContextFactory.java:58)
    at org.springframework.security.test.context.support.WithUserDetailsSecurityContextFactory.createSecurityContext(WithUserDetailsSecurityContextFactory.java:44)
    at org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.createTestSecurityContext(WithSecurityContextTestExecutionListener.java:123)
    ... 61 more

我的自定义UserDetailsS​​ervice

@Service
public class MvcUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Autowired
    public MvcUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<UserEntity> user = userRepository.findByUserId(username);

        if (user.isPresent()) {
            return new MvcUser(user.get());
        } else {
            return createNewUser();
        }
    }
    ...
}

我在这里想念什么?

解决方法

可以执行以下操作:

  1. 为测试覆盖资源服务器配置,并确保在类路径上放置了有效的RSA公钥(例如/src/test/resources

application.yml中的示例/src/test/resources

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:id_rsa.pub

这应该可以使您的应用程序启动,并且不需要与授权服务器进行任何HTTP通信。

  1. 接下来,结合使用@SpringBootTest@AutoConfigureMockMvc对模拟的Servlet环境进行测试。确保您拥有spring-security-test依赖项。

  2. 摆脱测试中的自定义UserDetailsService bean,而是依靠常规的自动配置。

  3. 现在您可以使用例如@WithMockUser,以便在测试时在Spring SecurityContext中提供模拟用户。

要在集成测试过程中提供真实的承载令牌,可以执行以下操作:

  1. 覆盖您的配置以指向伪造的授权服务器(例如,使用WireMock启动本地HTTP服务器)
  2. 使用您的应用程序请求JWKS的授权服务模拟应用程序(充当资源服务器)的初始HTTP通信。您可以在其中创建内存中的RSA密钥对,并让模拟的身份提供者返回公钥
  3. 创建一个有效的JWT并使用私钥对其进行签名
  4. 使用例如访问您启动的应用程序WebTestClientTestRestTemplate并将令牌添加为请求标头的一部分(为此您需要@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

相关问答

错误1:Request method ‘DELETE‘ not supported 错误还原:...
错误1:启动docker镜像时报错:Error response from daemon:...
错误1:private field ‘xxx‘ is never assigned 按Alt...
报错如下,通过源不能下载,最后警告pip需升级版本 Requirem...