1. 概述
本教程将探讨在Spring OAuth2安全应用中,通过模拟身份测试访问控制规则的多种方案。我们将使用MockMvc
请求后处理器、WebTestClient
修改器以及来自spring-security-test
和spring-addons
的测试注解。
2. 为何选择Spring-Addons?
在OAuth2领域,spring-security-test
仅提供需要MockMvc
或WebTestClient
请求上下文的请求后处理器和修改器。这对@Controller
测试可能足够,但测试@Service
或@Repository
上的方法级安全(如@PreAuthorize
、@PostFilter
等)时则存在局限。
使用@WithJwt
或@WithOidcLogin
等注解,我们可以在单元测试中为任何@Component
模拟安全上下文,无论Servlet还是响应式应用。因此我们将在部分测试中使用spring-addons-oauth2-test
,它为大多数Spring OAuth2的Authentication
实现提供了此类注解。
3. 测试目标
- 使用JWT解码器(而非不透明令牌内省)
- 访问
/secured-route
和/secured-method
需ROLE_AUTHORIZED_PERSONNEL
权限 - 认证缺失或无效时返回401(过期、错误签发者等),访问被拒绝时返回403(缺少角色)
- 通过Java配置定义访问控制(Servlet应用用
requestMatcher
,响应式应用用pathMatcher
)和方法级安全 - 使用安全上下文中的
Authentication
数据构建响应负载
为展示Servlet和响应式测试API的细微差异,一个实现为Servlet应用(查看代码),另一个为响应式应用(查看代码)。
本文聚焦于单元测试和集成测试中的访问控制规则测试,断言响应的HTTP状态是否符合模拟用户身份的预期,或在测试其他@Component
(如使用@PreAuthorize
、@PostFilter
等注解的@Service
或@Repository
)时断言是否抛出异常。
所有测试无需授权服务器即可运行,但若要启动被测资源服务器并用Postman等工具查询,则需要运行授权服务器。提供了Docker Compose文件快速搭建本地Keycloak实例:
- 管理控制台:http://localhost:8080/admin/master/console/#/baeldung
- 管理员账号:
admin / admin
- 已创建
baeldung
域,包含机密客户端(baeldung_confidential / secret
)和两个用户(authorized
和forbidden
,密码均为secret
)
4. 使用模拟认证的单元测试
"单元测试"指独立测试单个@Component
(其他依赖将被模拟)。被测@Component
可以是@WebMvcTest
或@WebFluxTest
中的@Controller
,也可以是普通JUnit测试中的其他安全@Service
、@Repository
等。
*MockMvc和*WebTestClient会忽略Authorization请求头,无需提供有效访问令牌。虽然可以手动实例化或模拟任何认证实现并在每个测试开始时创建安全上下文,但这过于繁琐。我们将使用spring-security-test的MockMvc请求后处理器、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会自动注入MockMvc
或WebTestClient
,由于是控制器单元测试,我们将模拟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
:测试基于角色的访问控制时通常足够,接受权限、用户名及要模拟的Authentication
和Principal
实现类型 -
@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连接实际组件。为继续使用模拟身份,需搭配MockMvc
或WebTestClient
。测试本身和模拟身份选项与单元测试相同,仅测试设置变化:
- 不再模拟组件或参数匹配器
- 使用
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
替代@WebMvcTest
或@WebFluxTest
,MOCK
环境最适合搭配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
实例,测试应使用@WithOpaqueToken
、opaqueToken()
或mockOpaqueToken()
- **使用*oauth2Login()*的客户端**通常在安全上下文中包含
OAuth2AuthenticationToken
,应使用@WithOAuth2Login
、@WithOidcLogin
、oauth2Login()
、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访问控制规则的两种方案:
spring-security-test
的MockMvc
请求后处理器和WebTestClient
修改器spring-addons-oauth2-test
的OAuth2测试注解
我们还看到测试@Controller
可使用MockMvc
请求后处理器、WebTestClient
修改器或注解,但只有后者能在测试其他类型组件时设置安全上下文。
一如既往,完整源代码可在GitHub获取。