1. 概述

本教程将探讨在Spring OAuth2安全应用中,通过模拟身份测试访问控制规则的多种方案。我们将使用MockMvc请求后处理器、WebTestClient修改器以及来自spring-security-testspring-addons的测试注解。

2. 为何选择Spring-Addons?

在OAuth2领域,spring-security-test仅提供需要MockMvcWebTestClient请求上下文的请求后处理器和修改器。这对@Controller测试可能足够,但测试@Service@Repository上的方法级安全(如@PreAuthorize@PostFilter等)时则存在局限。

使用@WithJwt@WithOidcLogin等注解,我们可以在单元测试中为任何@Component模拟安全上下文,无论Servlet还是响应式应用。因此我们将在部分测试中使用spring-addons-oauth2-test,它为大多数Spring OAuth2的Authentication实现提供了此类注解。

3. 测试目标

配套的GitHub仓库包含两个共享以下特性的资源服务器

  • 使用JWT解码器(而非不透明令牌内省)
  • 访问/secured-route/secured-methodROLE_AUTHORIZED_PERSONNEL权限
  • 认证缺失或无效时返回401(过期、错误签发者等),访问被拒绝时返回403(缺少角色)
  • 通过Java配置定义访问控制(Servlet应用用requestMatcher,响应式应用用pathMatcher)和方法级安全
  • 使用安全上下文中的Authentication数据构建响应负载

为展示Servlet和响应式测试API的细微差异,一个实现为Servlet应用(查看代码),另一个为响应式应用(查看代码)。

本文聚焦于单元测试和集成测试中的访问控制规则测试,断言响应的HTTP状态是否符合模拟用户身份的预期,或在测试其他@Component(如使用@PreAuthorize@PostFilter等注解的@Service@Repository)时断言是否抛出异常。

所有测试无需授权服务器即可运行,但若要启动被测资源服务器并用Postman等工具查询,则需要运行授权服务器。提供了Docker Compose文件快速搭建本地Keycloak实例:

4. 使用模拟认证的单元测试

"单元测试"指独立测试单个@Component(其他依赖将被模拟)。被测@Component可以是@WebMvcTest@WebFluxTest中的@Controller,也可以是普通JUnit测试中的其他安全@Service@Repository等。

*MockMvc和*WebTestClient会忽略Authorization请求头,无需提供有效访问令牌。虽然可以手动实例化或模拟任何认证实现并在每个测试开始时创建安全上下文,但这过于繁琐。我们将使用spring-security-testMockMvc请求后处理器、WebTestClient修改器或spring-addons注解,用自定义的模拟Authentication实例填充测试安全上下文**。

我们使用@WithMockUser只是为了说明它构建的是UsernamePasswordAuthenticationToken实例——这通常是个问题,因为OAuth2运行时配置会在安全上下文中放入其他类型的Authentication

  • 使用JWT解码器的资源服务器:JwtAuthenticationToken
  • 使用访问令牌内省(opaqueToken)的资源服务器:BearerTokenAuthentication
  • 使用oauth2Login的客户端:OAuth2AuthenticationToken
  • 自定义认证转换器可能返回任何类型。技术上OAuth2认证转换器可返回UsernamePasswordAuthenticationToken并在测试中使用@WithMockUser,但这非常不自然,此处不采用。

4.1. 重要说明

MockMvc预处理器和WebTestClient修改器不使用安全配置中定义的Bean构建测试Authentication实例。因此,*使用SecurityMockMvcRequestPostProcessors.jwt()SecurityMockServerConfigurers.mockJwt()*定义OAuth2声明不会影响认证名称和权限**,必须通过专用方法手动设置。

相比之下,spring-addons注解背后的工厂会扫描测试上下文中的认证转换器并使用它。因此,使用*@WithJwt时必须将自定义JwtAuthenticationConverter*暴露为Bean(而非在安全配置中内联为lambda):

@Configuration
@EnableMethodSecurity
@EnableWebSecurity
static class SecurityConf {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, Converter<Jwt, AbstractAuthenticationToken> authenticationConverter) throws Exception {
        http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwtResourceServer -> jwtResourceServer.jwtAuthenticationConverter(authenticationConverter)));
        ...
    }

    @Bean
    JwtAuthenticationConverter authenticationConverter(Converter<Jwt, Collection<GrantedAuthority>> authoritiesConverter) {
        final var authenticationConverter = new JwtAuthenticationConverter();
        authenticationConverter.setPrincipalClaimName(StandardClaimNames.PREFERRED_USERNAME);
        authenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        return authenticationConverter;
    }
}

注意认证转换器被显式注入安全过滤器链。这样@WithJwt背后的工厂就能用它与声明构建Authentication,与运行时处理真实令牌的方式完全一致。

另外,当认证转换器返回非JwtAuthenticationToken(或使用令牌内省的资源服务器中的BearerTokenAuthentication)时,只有Spring addons的测试注解能构建期望的Authentication类型。

4.2. 测试设置

对于@Controller*单元测试,Servlet应用应使用@WebMvcTest装饰测试类,响应式应用使用@WebFluxTest***。

Spring会自动注入MockMvcWebTestClient,由于是控制器单元测试,我们将模拟MessageService

Servlet应用中空的@Controller单元测试结构如下:

@WebMvcTest(controllers = GreetingController.class)
class GreetingControllerTest {

    @MockBean
    MessageService messageService;

    @Autowired
    MockMvc mockMvc;

    //...
}

4.5. 使用Spring-Addons注解测试控制器

在Servlet和响应式应用中,测试注解的使用方式完全相同。

只需添加spring-addons-oauth2-test依赖:

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-oauth2-test</artifactId>
    <version>7.6.12</version>
    <scope>test</scope>
</dependency>

该库提供多种注解覆盖以下场景:

  • @WithMockAuthentication:测试基于角色的访问控制时通常足够,接受权限、用户名及要模拟的AuthenticationPrincipal实现类型
  • @WithJwt:测试使用JWT解码器的资源服务器时使用,依赖从安全配置中获取的Converter<Jwt, ? extends AbstractAthenticationToken>(响应式应用中为Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>>)和测试类路径中的JSON负载,可完全控制声明并生成与运行时相同JWT负载对应的认证实例
  • @WithOpaqueToken:与@WithJwt类似,用于使用令牌内省的资源服务器,依赖获取OpaqueTokenAuthenticationConverter(或ReactiveOpaqueTokenAuthenticationConverter)的工厂
  • @WithOAuth2Login@WithOidcLogin:测试使用OAuth2登录的客户端时使用

测试前,需定义一些JSON文件作为测试资源,用于模拟代表性用户(personas)的访问令牌JSON负载(或内省响应)。可使用jwt.io等工具复制真实令牌负载。

ch4mpy将是拥有AUTHORIZED_PERSONNEL角色的测试用户:

{
  "iss": "https://localhost:8443/realms/master",
  "sub": "281c4558-550c-413b-9972-2d2e5bde6b9b",
  "iat": 1695992542,
  "exp": 1695992642,
  "preferred_username": "ch4mpy",
  "realm_access": {
    "roles": [
      "admin",
      "ROLE_AUTHORIZED_PERSONNEL"
    ]
  },
  "email": "[email protected]",
  "scope": "openid email"
}

再定义一个没有AUTHORIZED_PERSONNEL角色的用户:

{
  "iss": "https://localhost:8443/realms/master",
  "sub": "2d2e5bde6b9b-550c-413b-9972-281c4558",
  "iat": 1695992551,
  "exp": 1695992651,
  "preferred_username": "tonton-pirate",
  "realm_access": {
    "roles": [
      "uncle",
      "skipper"
    ]
  },
  "email": "[email protected]",
  "scope": "openid email"
}

现在可移除测试体中的身份模拟,改用注解装饰测试方法。为演示同时使用@WithMockAuthentication@WithJwt,实际测试中只需一种。仅需定义权限或名称时选前者,需控制多个声明时选后者:

@Test
@WithAnonymousUser
void givenRequestIsAnonymous_whenGetSecuredMethod_thenUnauthorized() throws Exception {
    api.perform(get("/secured-method"))
        .andExpect(status().isUnauthorized());
}

@Test
@WithMockAuthentication({ "admin", "ROLE_AUTHORIZED_PERSONNEL" })
void givenUserIsGrantedWithRoleAuthorizedPersonnel_whenGetSecuredMethod_thenOk() throws Exception {
    final var secret = "Secret!";
    when(messageService.getSecret()).thenReturn(secret);

    api.perform(get("/secured-method"))
        .andExpect(status().isOk())
        .andExpect(content().string(secret));
}

@Test
@WithMockAuthentication({ "admin" })
void givenUserIsNotGrantedWithRoleAuthorizedPersonnel_whenGetSecuredMethod_thenForbidden() throws Exception {
    api.perform(get("/secured-method"))
        .andExpect(status().isForbidden());
}

@Test
@WithJwt("ch4mpy.json")
void givenUserIsCh4mpy_whenGetSecuredMethod_thenOk() throws Exception {
    final var secret = "Secret!";
    when(messageService.getSecret()).thenReturn(secret);

    api.perform(get("/secured-method"))
        .andExpect(status().isOk())
        .andExpect(content().string(secret));
}

@Test
@WithJwt("tonton-pirate.json")
void givenUserIsTontonPirate_whenGetSecuredMethod_thenForbidden() throws Exception {
    api.perform(get("/secured-method"))
        .andExpect(status().isForbidden());
}

注解与BDD(行为驱动开发)范式完美契合

  • 前置条件(Given)在注解中
  • 测试执行(When)和结果断言(Then)在测试体中

4.6. 测试受保护的@Service或@Repository方法

测试@Controller时,选择请求后处理器/修改器还是注解主要取决于团队偏好,但要测试MessageService::getSecret的访问控制,spring-security-test已不再适用,必须使用spring-addons注解。

JUnit设置:

  • 使用@ExtendWith(SpringExtension.class)启用Spring自动注入
  • 导入并注入MessageService获取实例
  • 若使用@WithJwt,需导入包含JwtAuthenticationConverter的配置和AuthenticationFactoriesTestConf,否则用@EnableMethodSecurity装饰测试即可

我们将断言当用户缺少ROLE_AUTHORIZED_PERSONNEL权限时MessageService抛出异常。

Servlet应用中完整的@Service单元测试:

@ExtendWith(SpringExtension.class)
@TestInstance(Lifecycle.PER_CLASS)
@Import({ MessageService.class, SecurityConf.class })
@ImportAutoConfiguration(AuthenticationFactoriesTestConf.class)
class MessageServiceUnitTest {
    @Autowired
    MessageService messageService;
    
    @MockBean
    JwtDecoder jwtDecoder;

    @Test
    void givenSecurityContextIsNotSet_whenGreet_thenThrowsAuthenticationCredentialsNotFoundException() {
        assertThrows(AuthenticationCredentialsNotFoundException.class, () -> messageService.getSecret());
    }

    @Test
    @WithAnonymousUser
    void givenUserIsAnonymous_whenGreet_thenThrowsAccessDeniedException() {
        assertThrows(AccessDeniedException.class, () -> messageService.getSecret());
    }

    @Test
    @WithJwt("ch4mpy.json")
    void givenUserIsCh4mpy_whenGreet_thenReturnGreetingWithPreferredUsernameAndAuthorities() {
        assertEquals("Hello ch4mpy! You are granted with [admin, ROLE_AUTHORIZED_PERSONNEL].", 
          messageService.greet());
    }

    @Test
    @WithMockUser(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" }, username = "ch4mpy")
    void givenSecurityContextIsPopulatedWithUsernamePasswordAuthenticationToken_whenGreet_thenThrowsClassCastException() {
        assertThrows(ClassCastException.class, () -> messageService.greet());
    }
}

响应式应用的@Service单元测试差别不大:

@ExtendWith(SpringExtension.class)
@TestInstance(Lifecycle.PER_CLASS)
@Import({ MessageService.class, SecurityConf.class })
@ImportAutoConfiguration(AuthenticationFactoriesTestConf.class)
class MessageServiceUnitTest {
    @Autowired
    MessageService messageService;
    
    @MockBean
    ReactiveJwtDecoder jwtDecoder;

    @Test
    void givenSecurityContextIsEmpty_whenGreet_thenThrowsAuthenticationCredentialsNotFoundException() {
        assertThrows(AuthenticationCredentialsNotFoundException.class, () -> messageService.greet()
            .block());
    }

    @Test
    @WithAnonymousUser
    void givenUserIsAnonymous_whenGreet_thenThrowsClassCastException() {
        assertThrows(ClassCastException.class, () -> messageService.greet()
            .block());
    }

    @Test
    @WithJwt("ch4mpy.json")
    void givenUserIsCh4mpy_whenGreet_thenReturnGreetingWithPreferredUsernameAndAuthorities() {
        assertEquals("Hello ch4mpy! You are granted with [admin, ROLE_AUTHORIZED_PERSONNEL].", 
          messageService.greet().block());
    }

    @Test
    @WithMockUser(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" }, username = "ch4mpy")
    void givenSecurityContextIsPopulatedWithUsernamePasswordAuthenticationToken_whenGreet_thenThrowsClassCastException() {
        assertThrows(ClassCastException.class, () -> messageService.greet().block());
    }
}

4.7. JUnit 5 @ParametrizedTest

JUnit 5允许定义使用不同参数多次运行的测试,该参数可以是放入安全上下文的模拟Authentication

@WithMockAuthentication独立构建认证实例,非常易于在参数化测试中使用:

@ParameterizedTest
@AuthenticationSource({
        @WithMockAuthentication(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" }, name = "ch4mpy"),
        @WithMockAuthentication(authorities = { "uncle", "PIRATE" }, name = "tonton-pirate") })
void givenUserIsAuthenticated_whenGetGreet_thenOk(@ParameterizedAuthentication Authentication auth) throws Exception {
    final var greeting = "Whatever the service returns";
    when(messageService.greet()).thenReturn(greeting);

    api.perform(get("/greet"))
        .andExpect(status().isOk())
        .andExpect(content().string(greeting));

    verify(messageService, times(1)).greet();
}

注意要点:

  • 使用@ParameterizedTest替代@Test
  • 用包含所有@WithMockAuthentication@AuthenticationSource装饰测试
  • 测试方法添加@ParameterizedAuthentication参数

由于@WithJwt使用应用上下文中的Bean构建Authentication实例,需额外操作:

@TestInstance(Lifecycle.PER_CLASS)
class MessageServiceUnitTest {

    @Autowired
    WithJwt.AuthenticationFactory authFactory;

    private Stream<AbstractAuthenticationToken> allIdentities() {
        final var authentications = authFactory.authenticationsFrom("ch4mpy.json", "tonton-pirate.json").toList();
        return authentications.stream();
    }

    @ParameterizedTest
    @MethodSource("allIdentities")
    void givenUserIsAuthenticated_whenGreet_thenReturnGreetingWithPreferredUsernameAndAuthorities(@ParameterizedAuthentication Authentication auth) {
        final var jwt = (JwtAuthenticationToken) auth;
        final var expected = "Hello %s! You are granted with %s.".formatted(jwt.getTokenAttributes().get(StandardClaimNames.PREFERRED_USERNAME), auth.getAuthorities());
        assertEquals(expected, messageService.greet());
    }
}

@WithJwt参数化测试清单:

  • @TestInstance(Lifecycle.PER_CLASS)装饰测试类
  • 注入WithJwt.AuthenticationFactory
  • 定义返回认证流的方法,使用认证工厂处理每个JSON
  • @ParameterizedTest替代@Test
  • 用引用上述方法的@MethodSource装饰测试
  • 测试方法添加@ParameterizedAuthentication参数

5. 使用模拟授权的集成测试

我们将使用*@SpringBootTest*编写Spring Boot集成测试,使Spring连接实际组件。为继续使用模拟身份,需搭配MockMvcWebTestClient。测试本身和模拟身份选项与单元测试相同,仅测试设置变化:

  • 不再模拟组件或参数匹配器
  • 使用@SpringBootTest(webEnvironment = WebEnvironment.MOCK)替代@WebMvcTest@WebFluxTestMOCK环境最适合搭配MockMvc/WebTestClient的模拟授权
  • 显式用@AutoConfigureMockMvc@AutoConfigureWebTestClient装饰测试类以注入MockMvc/WebTestClient

Servlet应用的Spring Boot集成测试框架:

@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class ServletResourceServerApplicationTests {
    @Autowired
    MockMvc api;
    
    // 测试结构和模拟身份选项与单元测试相同
}

响应式应用的等效版本:

@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureWebTestClient
class ReactiveResourceServerApplicationTests {
    @Autowired
    WebTestClient api;
    
    // 测试结构和模拟身份选项与单元测试相同
}

此类集成测试节省了模拟配置、参数捕获器等设置,但比单元测试更慢且更脆弱。应谨慎使用,覆盖率可低于@WebMvcTest@WebFluxTest,仅验证自动注入和组件间通信是否正常。

6. 进阶内容

目前我们测试了使用JWT解码器保护的资源服务器,其安全上下文包含JwtAuthenticationToken实例。仅运行了模拟HTTP请求的自动化测试,未涉及授权服务器。

6.1. 测试任意类型的OAuth2认证

如前所述,Spring OAuth2安全上下文可容纳其他类型的Authentication,此时需在测试中使用其他注解、请求后处理器或修改器

  • 默认情况下,使用令牌内省的资源服务器安全上下文中包含BearerTokenAuthentication实例,测试应使用@WithOpaqueTokenopaqueToken()mockOpaqueToken()
  • **使用*oauth2Login()*的客户端**通常在安全上下文中包含OAuth2AuthenticationToken,应使用@WithOAuth2Login@WithOidcLoginoauth2Login()oidcLogin()mockOAuth2Login()mockOidcLogin()
  • 若通过http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(…)等显式配置自定义Authentication类型,则需提供自己的单元测试工具。使用spring-addons实现作为示例并不复杂。该GitHub仓库还包含自定义Authentication的示例和专用测试注解

6.2. 运行示例应用

示例项目包含运行在https://localhost:8443的Keycloak master域配置属性。使用其他OIDC授权服务器只需调整issuer-uri属性和Java配置中的权限映射器:修改realmRoles2AuthoritiesConverter Bean以从新授权服务器存放角色的私有声明映射权限。

Keycloak设置详情参考官方入门指南独立zip分发版指南可能最易上手。

使用自签名证书搭建带TLS的本地Keycloak实例,可参考此GitHub仓库

授权服务器至少需要:

  • 两个声明用户,一个拥有ROLE_AUTHORIZED_PERSONNEL权限,另一个没有
  • 一个启用授权码流的声明客户端,供Postman等工具代表用户获取访问令牌

7. 总结

本文探讨了在Servlet和响应式应用中,通过模拟身份单元测试和集成测试Spring OAuth2访问控制规则的两种方案:

我们还看到测试@Controller可使用MockMvc请求后处理器、WebTestClient修改器或注解,但只有后者能在测试其他类型组件时设置安全上下文。

一如既往,完整源代码可在GitHub获取。


原始标题:Testing Spring OAuth2 Access-Control | Baeldung