測試方法安全性
本節示範如何使用 Spring Security 的測試支援來測試基於方法的安全性。我們先介紹一個 MessageService
,它要求使用者必須通過身份驗證才能存取。
-
Java
-
Kotlin
public class HelloMessageService implements MessageService {
@PreAuthorize("authenticated")
public String getMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello " + authentication;
}
}
class HelloMessageService : MessageService {
@PreAuthorize("authenticated")
fun getMessage(): String {
val authentication: Authentication = SecurityContextHolder.getContext().authentication
return "Hello $authentication"
}
}
`getMessage` 的結果是一個 `String`,它向目前的 Spring Security `Authentication` 說 “Hello”。以下列表顯示範例輸出。
Hello org.springframework.security.authentication.UsernamePasswordAuthenticationToken@ca25360: Principal: org.springframework.security.core.userdetails.User@36ebcb: Username: user; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_USER; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER
安全性測試設定
在我們可以使用 Spring Security 測試支援之前,我們必須執行一些設定。
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class) (1)
@ContextConfiguration (2)
public class WithMockUserTests {
// ...
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration
class WithMockUserTests {
// ...
}
1 | `@ExtendWith` 指示 spring-test 模組應該建立一個 `ApplicationContext`。如需更多資訊,請參閱 Spring 參考文件。 |
2 | `@ContextConfiguration` 指示 spring-test 使用哪個設定來建立 `ApplicationContext`。由於未指定任何設定,將會嘗試預設設定位置。這與使用現有的 Spring Test 支援沒有區別。如需更多資訊,請參閱 Spring 參考文件。 |
Spring Security 透過 `WithSecurityContextTestExecutionListener` 掛鉤到 Spring Test 支援,這確保我們的測試以正確的使用者身分執行。它透過在執行測試之前填充 `SecurityContextHolder` 來實現這一點。如果您使用反應式方法安全性,您還需要 `ReactorContextTestExecutionListener`,它會填充 `ReactiveSecurityContextHolder`。測試完成後,它會清除 `SecurityContextHolder`。如果您只需要 Spring Security 相關的支援,您可以將 `@ContextConfiguration` 替換為 `@SecurityTestExecutionListeners`。 |
請記住,我們將 `@PreAuthorize` 註解新增到我們的 `HelloMessageService`,因此它要求使用者必須通過身份驗證才能調用它。如果我們執行測試,我們預期以下測試將會通過。
-
Java
-
Kotlin
@Test(expected = AuthenticationCredentialsNotFoundException.class)
public void getMessageUnauthenticated() {
messageService.getMessage();
}
@Test(expected = AuthenticationCredentialsNotFoundException::class)
fun getMessageUnauthenticated() {
messageService.getMessage()
}
@WithMockUser
問題是「我們如何才能最輕鬆地以特定使用者身分執行測試?」答案是使用 `@WithMockUser`。以下測試將以使用者名稱為 "user"、密碼為 "password" 且角色為 "ROLE_USER" 的使用者身分執行。
-
Java
-
Kotlin
@Test
@WithMockUser
public void getMessageWithMockUser() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser
fun getMessageWithMockUser() {
val message: String = messageService.getMessage()
// ...
}
具體來說,以下情況屬實:
-
使用者名稱為 `user` 的使用者不必存在,因為我們模擬了使用者物件。
-
在 `SecurityContext` 中填充的 `Authentication` 類型為 `UsernamePasswordAuthenticationToken`。
-
`Authentication` 上的 Principal 是 Spring Security 的 `User` 物件。
-
`User` 的使用者名稱為 `user`。
-
`User` 的密碼為 `password`。
-
使用名為 `ROLE_USER` 的單一 `GrantedAuthority`。
前面的範例很方便,因為它讓我們可以使用許多預設值。如果我們想以不同的使用者名稱執行測試呢?以下測試將以使用者名稱 `customUser` 執行(同樣,使用者實際上不需要存在)。
-
Java
-
Kotlin
@Test
@WithMockUser("customUsername")
public void getMessageWithMockUserCustomUsername() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser("customUsername")
fun getMessageWithMockUserCustomUsername() {
val message: String = messageService.getMessage()
// ...
}
我們也可以輕鬆自訂角色。例如,以下測試以使用者名稱 `admin` 和角色 `ROLE_USER` 和 `ROLE_ADMIN` 調用。
-
Java
-
Kotlin
@Test
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public void getMessageWithMockUserCustomUser() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser(username="admin",roles=["USER","ADMIN"])
fun getMessageWithMockUserCustomUser() {
val message: String = messageService.getMessage()
// ...
}
如果我們不希望值自動以 `ROLE_` 作為前綴,我們可以使用 `authorities` 屬性。例如,以下測試以使用者名稱 `admin` 和 `USER` 和 `ADMIN` 權限調用。
-
Java
-
Kotlin
@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
...
}
@Test
@WithMockUser(username = "admin", authorities = ["ADMIN", "USER"])
fun getMessageWithMockUserCustomUsername() {
val message: String = messageService.getMessage()
// ...
}
在每個測試方法上放置註解可能有點繁瑣。相反地,我們可以將註解放置在類別層級。然後每個測試都會使用指定的使用者。以下範例以使用者名稱為 `admin`、密碼為 `password` 且具有 `ROLE_USER` 和 `ROLE_ADMIN` 角色的使用者身分執行每個測試。
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {
// ...
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles=["USER","ADMIN"])
class WithMockUserTests {
// ...
}
如果您使用 JUnit 5 的 `@Nested` 測試支援,您也可以將註解放置在封閉類別上,以應用於所有巢狀類別。以下範例以使用者名稱為 `admin`、密碼為 `password` 且具有 `ROLE_USER` 和 `ROLE_ADMIN` 角色的使用者身分,為這兩個測試方法執行每個測試。
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {
@Nested
public class TestSuite1 {
// ... all test methods use admin user
}
@Nested
public class TestSuite2 {
// ... all test methods use admin user
}
}
@ExtendWith(SpringExtension::class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = ["USER", "ADMIN"])
class WithMockUserTests {
@Nested
inner class TestSuite1 { // ... all test methods use admin user
}
@Nested
inner class TestSuite2 { // ... all test methods use admin user
}
}
預設情況下,`SecurityContext` 在 `TestExecutionListener.beforeTestMethod` 事件期間設定。這相當於在 JUnit 的 `@Before` 之前發生。您可以將其變更為在 `TestExecutionListener.beforeTestExecution` 事件期間發生,這是在 JUnit 的 `@Before` 之後但在調用測試方法之前。
@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)
@WithAnonymousUser
使用 `@WithAnonymousUser` 允許以匿名使用者身分執行。當您希望以特定使用者身分執行大多數測試,但希望以匿名使用者身分執行一些測試時,這特別方便。以下範例使用 @WithMockUser 執行 `withMockUser1` 和 `withMockUser2`,並使用 `anonymous` 作為匿名使用者。
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class)
@WithMockUser
public class WithUserClassLevelAuthenticationTests {
@Test
public void withMockUser1() {
}
@Test
public void withMockUser2() {
}
@Test
@WithAnonymousUser
public void anonymous() throws Exception {
// override default to run as anonymous user
}
}
@ExtendWith(SpringExtension.class)
@WithMockUser
class WithUserClassLevelAuthenticationTests {
@Test
fun withMockUser1() {
}
@Test
fun withMockUser2() {
}
@Test
@WithAnonymousUser
fun anonymous() {
// override default to run as anonymous user
}
}
預設情況下,`SecurityContext` 在 `TestExecutionListener.beforeTestMethod` 事件期間設定。這相當於在 JUnit 的 `@Before` 之前發生。您可以將其變更為在 `TestExecutionListener.beforeTestExecution` 事件期間發生,這是在 JUnit 的 `@Before` 之後但在調用測試方法之前。
@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)
@WithUserDetails
雖然 `@WithMockUser` 是一種方便的入門方式,但在所有情況下可能都無法運作。例如,某些應用程式期望 `Authentication` Principal 為特定類型。這樣做是為了讓應用程式可以將 Principal 稱為自訂類型,並減少對 Spring Security 的耦合。
自訂 Principal 通常由自訂 `UserDetailsService` 返回,該 `UserDetailsService` 返回一個同時實作 `UserDetails` 和自訂類型的物件。對於這種情況,使用自訂 `UserDetailsService` 建立測試使用者很有用。這正是 `@WithUserDetails` 所做的事情。
假設我們有一個作為 bean 公開的 `UserDetailsService`,以下測試以 `UsernamePasswordAuthenticationToken` 類型的 `Authentication` 和從 `UserDetailsService` 返回的使用者名稱為 `user` 的 Principal 調用。
-
Java
-
Kotlin
@Test
@WithUserDetails
public void getMessageWithUserDetails() {
String message = messageService.getMessage();
...
}
@Test
@WithUserDetails
fun getMessageWithUserDetails() {
val message: String = messageService.getMessage()
// ...
}
我們也可以自訂用於從 `UserDetailsService` 查找使用者的使用者名稱。例如,此測試可以使用從 `UserDetailsService` 返回的使用者名稱為 `customUsername` 的 Principal 執行。
-
Java
-
Kotlin
@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
String message = messageService.getMessage();
...
}
@Test
@WithUserDetails("customUsername")
fun getMessageWithUserDetailsCustomUsername() {
val message: String = messageService.getMessage()
// ...
}
我們也可以提供明確的 bean 名稱來查找 `UserDetailsService`。以下測試使用 bean 名稱為 `myUserDetailsService` 的 `UserDetailsService` 查找使用者名稱 `customUsername`。
-
Java
-
Kotlin
@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
public void getMessageWithUserDetailsServiceBeanName() {
String message = messageService.getMessage();
...
}
@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
fun getMessageWithUserDetailsServiceBeanName() {
val message: String = messageService.getMessage()
// ...
}
如同我們對 `@WithMockUser` 所做的那樣,我們也可以將註解放置在類別層級,以便每個測試都使用相同的使用者。但是,與 `@WithMockUser` 不同,`@WithUserDetails` 要求使用者必須存在。
預設情況下,`SecurityContext` 在 `TestExecutionListener.beforeTestMethod` 事件期間設定。這相當於在 JUnit 的 `@Before` 之前發生。您可以將其變更為在 `TestExecutionListener.beforeTestExecution` 事件期間發生,這是在 JUnit 的 `@Before` 之後但在調用測試方法之前。
@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)
@WithSecurityContext
我們已經看到,如果我們不使用自訂 `Authentication` Principal,`@WithMockUser` 是一個絕佳的選擇。接下來,我們發現 `@WithUserDetails` 讓我們可以使用自訂 `UserDetailsService` 來建立我們的 `Authentication` Principal,但要求使用者必須存在。現在我們看到一個提供最大彈性的選項。
我們可以建立自己的註解,使用 `@WithSecurityContext` 來建立我們想要的任何 `SecurityContext`。例如,我們可以建立一個名為 `@WithMockCustomUser` 的註解。
-
Java
-
Kotlin
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "rob";
String name() default "Rob Winch";
}
@Retention(AnnotationRetention.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class)
annotation class WithMockCustomUser(val username: String = "rob", val name: String = "Rob Winch")
您可以看到 `@WithMockCustomUser` 使用 `@WithSecurityContext` 註解進行註解。這就是向 Spring Security 測試支援發出訊號,表明我們打算為測試建立一個 `SecurityContext`。`@WithSecurityContext` 註解要求我們指定一個 `SecurityContextFactory` 來建立新的 `SecurityContext`,給定我們的 `@WithMockCustomUser` 註解。以下列表顯示了我們的 `WithMockCustomUserSecurityContextFactory` 實作。
-
Java
-
Kotlin
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails principal =
new CustomUserDetails(customUser.name(), customUser.username());
Authentication auth =
UsernamePasswordAuthenticationToken.authenticated(principal, "password", principal.getAuthorities());
context.setAuthentication(auth);
return context;
}
}
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
override fun createSecurityContext(customUser: WithMockCustomUser): SecurityContext {
val context = SecurityContextHolder.createEmptyContext()
val principal = CustomUserDetails(customUser.name, customUser.username)
val auth: Authentication =
UsernamePasswordAuthenticationToken(principal, "password", principal.authorities)
context.authentication = auth
return context
}
}
我們現在可以使用我們的新註解和 Spring Security 的 `WithSecurityContextTestExecutionListener` 來註解測試類別或測試方法,以確保我們的 `SecurityContext` 已正確填充。
在建立您自己的 `WithSecurityContextFactory` 實作時,很高興知道它們可以使用標準 Spring 註解進行註解。例如,`WithUserDetailsSecurityContextFactory` 使用 `@Autowired` 註解來取得 `UserDetailsService`。
-
Java
-
Kotlin
final class WithUserDetailsSecurityContextFactory
implements WithSecurityContextFactory<WithUserDetails> {
private UserDetailsService userDetailsService;
@Autowired
public WithUserDetailsSecurityContextFactory(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
public SecurityContext createSecurityContext(WithUserDetails withUser) {
String username = withUser.value();
Assert.hasLength(username, "value() must be non-empty String");
UserDetails principal = userDetailsService.loadUserByUsername(username);
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
}
class WithUserDetailsSecurityContextFactory @Autowired constructor(private val userDetailsService: UserDetailsService) :
WithSecurityContextFactory<WithUserDetails> {
override fun createSecurityContext(withUser: WithUserDetails): SecurityContext {
val username: String = withUser.value
Assert.hasLength(username, "value() must be non-empty String")
val principal = userDetailsService.loadUserByUsername(username)
val authentication: Authentication =
UsernamePasswordAuthenticationToken(principal, principal.password, principal.authorities)
val context = SecurityContextHolder.createEmptyContext()
context.authentication = authentication
return context
}
}
預設情況下,`SecurityContext` 在 `TestExecutionListener.beforeTestMethod` 事件期間設定。這相當於在 JUnit 的 `@Before` 之前發生。您可以將其變更為在 `TestExecutionListener.beforeTestExecution` 事件期間發生,這是在 JUnit 的 `@Before` 之後但在調用測試方法之前。
@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)
測試 Meta 註解
如果您經常在測試中重複使用相同的使用者,則不必重複指定屬性是理想的。例如,如果您有許多與使用者名稱為 `admin` 且角色為 `ROLE_USER` 和 `ROLE_ADMIN` 的管理使用者相關的測試,您必須編寫:
-
Java
-
Kotlin
@WithMockUser(username="admin",roles={"USER","ADMIN"})
@WithMockUser(username="admin",roles=["USER","ADMIN"])
與其到處重複此操作,不如使用 Meta 註解。例如,我們可以建立一個名為 `WithMockAdmin` 的 Meta 註解。
-
Java
-
Kotlin
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value="rob",roles="ADMIN")
public @interface WithMockAdmin { }
@Retention(AnnotationRetention.RUNTIME)
@WithMockUser(value = "rob", roles = ["ADMIN"])
annotation class WithMockAdmin
現在我們可以像更詳細的 `@WithMockUser` 一樣使用 `@WithMockAdmin`。
Meta 註解適用於上述任何測試註解。例如,這表示我們也可以為 `@WithUserDetails("admin")` 建立 Meta 註解。