測試方法安全
本節演示如何使用 Spring Security 的測試支援來測試基於方法(method-based)的安全性。我們首先介紹一個 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 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 透過 |
請記住,我們在 HelloMessageService
中添加了 @PreAuthorize
註解,因此它需要一個經過認證的使用者來呼叫它。如果我們執行測試,我們期望以下測試會透過
-
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
返回,該服務返回一個同時實現了 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
,根據我們的 @WithMockCustomUser
註解來建立一個新的 SecurityContext
。下面的列表顯示了我們的 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)
測試元註解
如果您經常在測試中重複使用同一使用者,則反覆指定屬性並非理想做法。例如,如果您有許多與使用者名稱為 admin
、角色為 ROLE_USER
和 ROLE_ADMIN
的管理使用者相關的測試,您必須寫
-
Java
-
Kotlin
@WithMockUser(username="admin",roles={"USER","ADMIN"})
@WithMockUser(username="admin",roles=["USER","ADMIN"])
與其在各處重複,我們可以使用元註解。例如,我們可以建立一個名為 WithMockAdmin
的元註解
-
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
。
元註解與上述任何測試註解都相容。例如,這意味著我們也可以為 @WithUserDetails("admin")
建立一個元註解。