1. 概述
本教程將探討在具有 OAuth2 安全性的 Spring 應用中使用 Mocked 身份驗證來測試訪問控制規則的選項。
我們將使用 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. 測試內容是什麼?
配套的 GitHub 倉庫 包含兩個資源服務器,共享以下功能:
- 使用 JWT 解碼器進行安全保護(而不是不透明的令牌內省)
- 需要 ROLE_AUTHORIZED_PERSONNEL 權限才能訪問 /secured-route 和 /secured-method
- 如果身份驗證缺失或無效(過期、頒發者錯誤等),則返回 401 錯誤;如果訪問被拒絕(缺少角色),則返回 403 錯誤
- 使用 Java 配置定義訪問控制(Servlet 和響應式應用分別使用 requestMatcher 和 pathMatcher),以及方法安全
- 使用身份驗證上下文中的數據構建響應負載
為了説明 Servlet 和響應式測試 API 之間的細微差異,一個為 Servlet,另一個為響應式應用。
在本文中,我們將重點測試單元測試和集成測試中訪問控制規則,並斷言響應的 HTTP 狀態與根據模擬的用户身份的預期匹配,或者在單元測試其他@Component時,當@Controller、@Service或@Repository(使用@PreAuthorize、@PostFilter等)時,拋出異常。
所有測試都無需授權服務器即可通過,但如果想要啓動用於測試的資源服務器,並使用 Postman 等工具對其進行查詢,則需要一個服務器正在運行。 提供的 Docker Compose 文件可用於快速啓動 Keycloak 實例:
- 管理控制枱可用於:http://localhost:8080/admin/master/console/#/baeldung
- 管理員帳户為 admin / admin
- 已創建了一個名為 baeldung 的領域,其中包含一個秘密客户端 (baeldung_confidential / secret) 和兩個用户 (authorized 和 forbidden,兩者都使用 secret 作為密鑰)
4. 使用模擬認證進行單元測試
通過“單元測試”,我們指的是在一個隔離的 @Component 中進行測試,不依賴任何其他依賴項(我們將使用模擬)。被測試的 @Component 可以是 @Controller 在 @WebMvcTest 或 @WebFluxTest 中,也可以是任何其他受保護的 @Service、@Repository 等,在標準的 JUnit 測試中。
MockMvc 和 WebTestClient 會忽略 Authorization 頭部,因此無需提供有效的訪問令牌。當然,我們可以實例化或模擬任何認證實現,並在每個測試的開頭手動創建安全上下文,但這過於繁瑣。相反,我們將使用 spring-security-test 的 MockMvc 請求後處理程序、WebTestClient 變體或 spring-addons 註解,以便使用我們選擇的模擬 Authentication 實例填充測試安全上下文.
我們將使用 @WithMockUser 僅用於驗證它是否構建了 UsernamePasswordAuthenticationToken 實例,因為 OAuth2 運行時配置經常會將其他類型的 Authentication 放入安全上下文中:
- JwtAuthenticationToken 用於資源服務器,並帶有 JWT 解碼器
- BearerTokenAuthentication 用於資源服務器,進行訪問令牌隱式獲取 (opaqueToken)
- OAuth2AuthenticationToken 用於具有 oauth2Login 配置的客户端
- 如果我們在自定義認證轉換器中返回另一個 Authentication 實例,則可以返回任何內容。因此,從技術上講,OAuth2 認證轉換器可以返回 UsernamePasswordAuthenticationToken 實例,並使用 @WithMockUser 在測試中,但這是一種不太自然的選擇,我們不會在這裏使用它。
4.1. 重要提示
<em>MockMvc</em> 的預處理器和 <em>WebTestClient</em> 的修改器不使用安全配置中定義的 Bean 來構建測試 <em>Authentication</em> 實例。 因此,使用 <em>SecurityMockMvcRequestPostProcessors.jwt()</em> 或 <em>SecurityMockServerConfigurers.mockJwt()</em> 構建 <em>Authentication</em> 實例並不會影響身份驗證名稱和權限。 我們必須自己設置名稱和權限,使用專門的方法。
與之形成對比的是,spring-addons 註解背後的工廠會掃描測試上下文以查找身份驗證轉換器,並在找到它時使用它。 因此,在使用 <em>@WithJwt</em> 時,重要的是將自定義 <em>JwtAuthenticationConverter</em> 暴露為 Bean(而不是僅僅將其內聯為 security conf 中的 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;
}
}值得注意的是,認證轉換器被暴露為一個 @Bean,並被明確地注入到安全過濾器鏈中。 這樣,@WithJwt 背後工廠就可以利用它來從聲明中構建 Authentication,就像在運行時使用真實令牌時所做的那樣。
此外,在高級情況下,當認證轉換器返回的內容不是 JwtAuthenticationToken(或者資源服務器在進行令牌內省時,返回 BearerTokenAuthentication)時,僅 Spring Addons 的測試註解才會構建期望的 Authentication 類型。
4.2. 測試環境搭建
對於@Controller單元測試,我們應該使用@WebMvcTest來裝飾測試類,用於servlet應用,以及@WebFluxTest用於響應式應用。
Spring會自動注入MockMvc或WebTestClient,並且由於我們正在編寫控制器單元測試,因此我們將模擬MessageService。
以下是一個在servlet應用中,空@Controller單元測試的示例:
@WebMvcTest(controllers = GreetingController.class)
class GreetingControllerTest {
@MockBean
MessageService messageService;
@Autowired
MockMvc mockMvc;
//...
}@WebFluxTest(controllers = GreetingController.class)
class GreetingControllerTest {
private static final AnonymousAuthenticationToken ANONYMOUS =
new AnonymousAuthenticationToken("anonymous", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
@MockBean
MessageService messageService;
@Autowired
WebTestClient webTestClient;
//...
}現在,讓我們看看如何斷言 HTTP 狀態碼與我們之前設定的規範相符。
4.3. 使用 MockMvc 後處理程序進行單元測試
為了將測試安全上下文填充為 JwtAuthenticationToken,這是用於具有 JWT 解碼器的資源服務器的默認 Authentication 類型,我們將使用 jwt 後處理程序,用於 MockMvc 請求:
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;讓我們來看幾個使用 MockMvc 以及根據端點和模擬的身份驗證進行響應狀態斷言的示例:
@Test
void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
mockMvc.perform(get("/greet").with(SecurityMockMvcRequestPostProcessors.anonymous()))
.andExpect(status().isUnauthorized());
}在上述內容中,我們確保匿名請求無法收到問候語,並且會正確返回一個 401 錯誤碼。
現在,讓我們看看請求如何根據端點的安全規則以及分配給測試 JwtAuthenticationToken 的管理員,返回 200 Ok 或 403 Forbidden。
@Test
void givenUserIsGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception {
var secret = "Secret!";
when(messageService.getSecret()).thenReturn(secret);
mockMvc.perform(get("/secured-route").with(SecurityMockMvcRequestPostProcessors.jwt()
.authorities(new SimpleGrantedAuthority("ROLE_AUTHORIZED_PERSONNEL"))))
.andExpect(status().isOk())
.andExpect(content().string(secret));
}
@Test
void givenUserIsNotGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception {
mockMvc.perform(get("/secured-route").with(SecurityMockMvcRequestPostProcessors.jwt()
.authorities(new SimpleGrantedAuthority("admin"))))
.andExpect(status().isForbidden());
}4.4. 使用 WebTestClient 變異器進行單元測試
在響應式資源服務器中,安全上下文中 Authentication 類型的實現與 Servlet 中的實現相同:JwtAuthenticationToken。因此,我們將使用 mockJwt 變異器用於 WebTestClient:
import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers;與 <em >MockMvc</em > 後處理器不同,沒有匿名 <em >WebTestClient</em > 修改器。但是,可以通過輕鬆定義匿名 <em >Authentication</em > 實例並使用它通過泛型 <em >mockAuthentication</em > 修改器:
private static final AnonymousAuthenticationToken ANONYMOUS = new AnonymousAuthenticationToken(
"anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
@Test
void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
webTestClient.mutateWith(SecurityMockServerConfigurers.mockAuthentication(ANONYMOUS))
.get()
.uri("/greet")
.exchange()
.expectStatus()
.isUnauthorized();
}
@Test
void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception {
var greeting = "Whatever the service returns";
when(messageService.greet()).thenReturn(Mono.just(greeting));
webTestClient.mutateWith(SecurityMockServerConfigurers.mockJwt().authorities(List.of(
new SimpleGrantedAuthority("admin"),
new SimpleGrantedAuthority("ROLE_AUTHORIZED_PERSONNEL")))
.jwt(jwt -> jwt.claim(StandardClaimNames.PREFERRED_USERNAME, "ch4mpy")))
.get()
.uri("/greet")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo(greeting);
verify(messageService, times(1)).greet();
}
@Test
void givenUserIsNotGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception {
webTestClient.mutateWith(mockJwt().authorities(new SimpleGrantedAuthority("admin")))
.get()
.uri("/secured-route")
.exchange()
.expectStatus()
.isForbidden();
}<div>
<h1>Introduction</h1>
<p>This document provides an overview of the new API. It covers key features, usage examples, and troubleshooting tips.</p>
<h2>Key Features</h2>
<ul>
<li><strong>Data Validation:</strong> Ensures data integrity by validating input against predefined rules.</li>
<li><strong>Asynchronous Operations:</strong> Enables non-blocking operations for improved performance.</li>
<li><strong>Error Handling:</strong> Provides robust error handling mechanisms for graceful failure.</li>
</ul>
<h2>Usage Examples</h2>
<pre>
<code>
// Example JavaScript code
function fetchData(url) {
// Make an HTTP request
fetch(url)
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(error => {
console.error("Error fetching data:", error);
});
}
// Call the function
fetchData("https://example.com/api/data");
</code>
</pre>
<p>This example demonstrates how to use the API to retrieve data from a remote server.</p>
<h2>Troubleshooting</h2>
<h3>Common Issues</h3>
<ul>
<li><strong>Connection Errors:</strong> Verify network connectivity and DNS resolution.</li>
<li><strong>Authentication Errors:</strong> Double-check API keys and credentials.</li>
<li><strong>Invalid Data:</strong> Ensure data conforms to the expected format.</li>
</ul>
</div>
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 文件作為測試資源。這旨在模擬訪問令牌(或內省響應)的 JSON 負載(針對代表性用户personas 或 personae)。我們可以使用像 https://jwt.io 這樣的工具複製真實令牌的負載。
Ch4mpy 將是我們的測試用户,具有 AUTHORIZED_PERONNEL 角色:
{
"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_PERONNEL 角色的用户:
{
"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 時,選擇請求 MockMvc 後處理程序(或 WebTestClient 變體)與註解之間的選擇主要取決於團隊偏好,但為了單元測試 MessageService::getSecret 的訪問控制,spring-security-test 已經不再適用,我們需要使用 spring-addons 註解。
以下是 JUnit 設置:
- 使用 @ExtendWith(SpringExtension.class) 激活 Spring 自動裝配
- 導入並自動裝配 MessageService 以獲取經過Instrumentation 的實例
- 如果使用 @WithJwt,則需要導入包含 JwtAuthenticationConverter 以及 AuthenticationFactoriesTestConf 的配置。否則,使用 @EnableMethodSecurity 裝飾測試即可。
我們將斷言 MessageService 在用户缺少 ROLE_AUTHORIZED_PERSONNEL 權限時,每次都會拋出異常。
以下是一個在 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());
}
}一個 reactive 應用中 @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 <em @ParametrizedTest>
JUnit 5 允許定義可以在不同參數值下多次運行的測試。該參數可以是用於注入到安全上下文中模擬的 Authentication 對象。
<em @WithMockAuthentication 構建的認證實例獨立於 Spring 上下文,這使得它非常容易在參數化測試中使用:
@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
- 使用 @AuthenticationSource 裝飾測試,該裝飾器包含所有 @WithMockAuthentication 的數組
- 向測試方法添加 @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());
}
}使用 @ParameterizedTest 與 @WithJwt 時,我們的檢查清單如下:
- 使用 @TestInstance(Lifecycle.PER_CLASS) 裝飾測試類
- 自動注入 WithJwt.AuthenticationFactory
- 定義一個返回認證流的方法,使用認證工廠進行每個認證
- 使用 @ParameterizedTest 代替 @Test
- 使用 @MethodSource 裝飾測試,引用上述定義的該方法
- 在測試方法中添加 @ParameterizedAuthentication 參數
5. 使用模擬授權進行集成測試
我們將使用 Spring Boot 集成測試,並使用 @SpringBootTest,以便 Spring 將實際組件連接起來。為了繼續使用模擬身份,我們將使用它與 MockMvc 或 WebTestClient 結合使用。這些測試本身以及填充測試安全上下文的選項,與單元測試相同。僅更改了測試設置:
- 不再需要模擬組件或參數匹配器
- 我們將使用
@SpringBootTest(webEnvironment = WebEnvironment.MOCK),而不是@WebMvcTest或@WebFluxTest。MOCK環境是與MockMvc或WebTestClient結合使用模擬授權的最佳匹配 - 明確裝飾測試類,使用
@AutoConfigureMockMvc或@AutoConfigureWebTestClient,以便使用MockMvc或WebTestClient進行注入
以下是 Spring Boot servlet 集成測試的骨架:
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class ServletResourceServerApplicationTests {
@Autowired
MockMvc api;
// Test structure and mocked identities options are the same as seen before in unit tests
}這是在反應式應用中的等效實現:
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureWebTestClient
class ReactiveResourceServerApplicationTests {
@Autowired
WebTestClient api;
// Test structure and mocked identities options are the same as seen before in unit tests
}當然,這種集成測試可以保存模擬配置、參數捕獲等,但它也比單元測試慢得多,而且更加脆弱。我們應該謹慎使用它,可能覆蓋率低於 @WebMvcTest 或 @WebFluxTest,僅用於驗證自動裝配和跨組件通信是否正常工作。
6. 深入探索
我們目前測試了使用 JWT 解碼器保護的資源服務器,這些服務器在安全上下文中包含 JwtAuthenticationToken 實例。我們僅運行了帶有模擬 HTTP 請求的自動化測試,而沒有涉及任何授權服務器。
6.1. 使用任何類型的 OAuth2 身份驗證進行測試
如前所述,Spring OAuth2 安全上下文可以持有其他類型的 Authentication,因此在測試中,我們應該使用其他註解、請求後處理程序或轉換器:
- 默認情況下,具有 token 隱想的資源服務器具有 BearerTokenAuthentication 實例,測試應該使用 @WithOpaqueToken、opaqueToken() 或 mockOpaqueToken()
- 具有 oauth2Login() 的客户端通常具有 OAuth2AuthenticationToken 在其安全上下文中,我們使用 @WithOAuth2Login、@WithOidcLogin、oauth2Login()、oidcLogin()、mockOAuth2Login() 或 mockOidcLogin()
- 假設我們明確配置了自定義 Authentication 類型,例如使用 http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(…) 或類似方法。在這種情況下,我們可能需要提供自己的單元測試工具,當使用 spring-addons 實現作為示例時,這並不複雜。同一 Github 倉庫還包含帶有自定義 Authentication 的樣本以及專用測試註解
6.2. 運行示例應用程序
提供的示例項目包含 Keycloak 實例的 master 領域(realm)的屬性,該實例運行在 https://localhost:8443。 使用任何其他 OIDC 授權服務器都需要僅調整 issuer-uri 屬性以及 Java 配置中的 authorities mapper,即將 realmRoles2AuthoritiesConverter Bean 更改為將新的授權服務器注入的角色映射從私有聲明(claim)中轉換。
對於 Keycloak 設置的更多詳細信息,請參閲 官方入門指南。 對於 獨立 zip 部署,可能更容易上手。
要設置本地使用 TLS 的 Keycloak 實例,並使用自簽名證書,此 GitHub 倉庫 將非常有用。
授權服務器應至少包含:
- 兩個已聲明的用户,其中一個被授予 ROLE_AUTHORIZED_PERSONNEL 角色,而另一個未被授予
- 一個已聲明的客户端,啓用了授權碼流程,以便像 Postman 這樣的工具可以代表這些用户獲取訪問令牌
7. 結論
在本文中,我們探討了使用模擬身份驗證 Spring OAuth2 訪問控制規則,在 Servlet 和 Reactive 應用程序中,使用 MockMvc 和 WebTestClient 變體進行單元和集成測試的兩種選項:
- 使用 spring-security-test 中的 MockMvc 請求後處理程序和 WebTestClient 變體
- 使用 spring-addons-oauth2-test 中的 OAuth2 測試註解
我們還發現,可以使用 @Controllers 與 MockMvc 請求後處理程序、WebTestClient 變體或註解進行測試。但是,只有後者能夠在我們測試其他類型的組件時設置安全上下文。