測試方法安全
本節演示如何使用 Spring Security 的測試支援來測試基於方法的安全性。我們首先介紹一個 MessageService,它要求使用者經過身份驗證才能訪問它
-
Java
-
Kotlin
public class HelloMessageService implements MessageService {
@Override
@PreAuthorize("isAuthenticated()")
public String getMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello " + authentication;
}
@Override
@PreAuthorize("isAuthenticated()")
public String getJsrMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello JSR " + authentication;
}
}
class HelloMessageService : MessageService {
@PreAuthorize("isAuthenticated()")
override fun getMessage(): String {
val authentication: Authentication? = SecurityContextHolder.getContext().authentication
return "Hello $authentication"
}
@PreAuthorize("isAuthenticated()")
override fun getJsrMessage(): String {
val authentication = SecurityContextHolder.getContext().authentication
return "Hello JSR $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)
class WithMockUserTests {
// ...
}
@ExtendWith(SpringExtension::class) (1)
@ContextConfiguration (2)
class WithMockUserTests {
}
| 1 | @ExtendWith 指示 spring-test 模組它應該建立一個 ApplicationContext。有關更多資訊,請參閱 Spring 參考。 |
| 2 | @ContextConfiguration 指示 spring-test 用於建立 ApplicationContext 的配置。由於未指定配置,因此將嘗試預設配置位置。這與使用現有 Spring Test 支援沒有什麼不同。有關更多資訊,請參閱 Spring 參考。 |
|
Spring Security 透過 |
請記住,我們向 HelloMessageService 添加了 @PreAuthorize 註解,因此它需要經過身份驗證的使用者才能呼叫它。如果執行測試,我們預計以下測試將透過
-
Java
-
Kotlin
@Test
void getMessageUnauthenticated() {
assertThatExceptionOfType(AuthenticationCredentialsNotFoundException.class)
.isThrownBy(() -> messageService.getMessage());
}
@Test
fun getMessageUnauthenticated() {
assertThatExceptionOfType(AuthenticationCredentialsNotFoundException::class.java)
.isThrownBy { messageService.getMessage() }
}
@WithMockUser
問題是“我們如何最容易地以特定使用者身份執行測試?”答案是使用 @WithMockUser。以下測試將以使用者名稱為“user”、密碼為“password”和角色為“ROLE_USER”的使用者身份執行。
-
Java
-
Kotlin
@Test
@WithMockUser
void getMessageWithMockUser() {
String message = messageService.getMessage();
assertThat(message).contains("user");
}
@Test
@WithMockUser
fun getMessageWithMockUser() {
val message = messageService.message
assertThat(message).contains("user")
}
具體來說,以下是真實的
-
使用者名稱為
user的使用者不必存在,因為我們模擬了使用者物件。 -
填充到
SecurityContext中的Authentication型別為UsernamePasswordAuthenticationToken。 -
Authentication上的主體是 Spring Security 的User物件。 -
User的使用者名稱為user。 -
User的密碼為password。 -
使用了單個名為
ROLE_USER的GrantedAuthority。
前面的示例很方便,因為它允許我們使用許多預設值。如果我們想使用不同的使用者名稱執行測試怎麼辦?以下測試將以使用者名稱 customUser 執行(同樣,使用者不需要實際存在)
-
Java
-
Kotlin
@Test
@WithMockUser("customUser")
void getMessageWithMockUserCustomUsername() {
String message = messageService.getMessage();
assertThat(message).contains("customUser");
}
@Test
@WithMockUser("customUser")
fun getMessageWithMockUserCustomUsername() {
val message = messageService.message
assertThat(message).contains("customUser")
}
我們還可以輕鬆自定義角色。例如,以下測試以 admin 使用者名稱和 ROLE_USER 和 ROLE_ADMIN 角色呼叫。
-
Java
-
Kotlin
@Test
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
void getMessageWithMockUserCustomRoles() {
String message = messageService.getMessage();
assertThat(message)
.contains("admin")
.contains("ROLE_ADMIN")
.contains("ROLE_USER");
}
@Test
@WithMockUser(username = "admin", roles = ["USER", "ADMIN"])
fun getMessageWithMockUserCustomRoles() {
val message = messageService.message
assertThat(message)
.contains("admin")
.contains("ROLE_ADMIN")
.contains("ROLE_USER")
}
如果我們不希望該值自動以 ROLE_ 為字首,我們可以使用 authorities 屬性。例如,以下測試以 admin 使用者名稱和 USER 和 ADMIN 許可權呼叫。
-
Java
-
Kotlin
@Test
@WithMockUser(username = "admin", authorities = {"ADMIN", "USER"})
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
assertThat(message)
.contains("admin")
.contains("ADMIN")
.contains("USER")
.doesNotContain("ROLE_");
}
@Test
@WithMockUser(username = "admin", authorities = ["ADMIN", "USER"])
fun getMessageWithMockUserCustomAuthorities() {
val message = messageService.message
assertThat(message)
.contains("admin")
.contains("ADMIN")
.contains("USER")
.doesNotContain("ROLE_")
}
將註解放在每個測試方法上可能有點繁瑣。相反,我們可以將註解放在類級別。然後每個測試都使用指定的使用者。以下示例使用使用者名稱為 admin、密碼為 password 且具有 ROLE_USER 和 ROLE_ADMIN 角色的使用者執行每個測試
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
class WithMockUserClassTests {
// ...
}
@ExtendWith(SpringExtension::class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = ["USER", "ADMIN"])
class WithMockUserClassTests {
// ...
}
如果您使用 JUnit 5 的 @Nested 測試支援,您也可以將註解放在封閉類上以應用於所有巢狀類。以下示例使用使用者名稱為 admin、密碼為 password 且具有 ROLE_USER 和 ROLE_ADMIN 角色的使用者執行兩個測試方法的所有測試。
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
class WithMockUserNestedTests {
@Nested
class TestSuite1 {
// ... all test methods use admin user
}
@Nested
class TestSuite2 {
// ... all test methods use admin user
}
}
@ExtendWith(SpringExtension::class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = ["USER", "ADMIN"])
class WithMockUserNestedTests {
@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
-
Java
-
Kotlin
@ExtendWith(SpringExtension.class)
@WithMockUser
class WithUserClassLevelAuthenticationTests {
@Test
void withMockUser1() {
}
@Test
void withMockUser2() {
}
@Test
@WithAnonymousUser
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 主體是特定型別。這樣做是為了應用程式可以將主體稱為自定義型別並減少對 Spring Security 的耦合。
自定義主體通常由自定義 UserDetailsService 返回,該服務返回一個同時實現 UserDetails 和自定義型別的物件。對於這種情況,使用自定義 UserDetailsService 建立測試使用者非常有用。這正是 @WithUserDetails 所做的。
假設我們有一個作為 bean 公開的 UserDetailsService,以下測試將使用型別為 UsernamePasswordAuthenticationToken 的 Authentication 和從 UserDetailsService 返回的使用者名稱為 user 的主體呼叫
-
Java
-
Kotlin
@Test
@WithUserDetails
void getMessageWithUserDetails() {
String message = messageService.getMessage();
assertThat(message).contains("user");
}
@Test
@WithUserDetails
fun getMessageWithUserDetails() {
val message: String = messageService.message
assertThat(message).contains("user")
}
我們還可以自定義用於從 UserDetailsService 查詢使用者的使用者名稱。例如,此測試可以與從 UserDetailsService 返回的使用者名稱為 customUsername 的主體一起執行
-
Java
-
Kotlin
@Test
@WithUserDetails("customUsername")
void getMessageWithUserDetailsCustomUsername() {
String message = messageService.getMessage();
assertThat(message).contains("customUsername");
}
@Test
@WithUserDetails("customUsername")
fun getMessageWithUserDetailsCustomUsername() {
val message: String = messageService.message
assertThat(message).contains("customUsername")
}
我們還可以提供一個顯式的 bean 名稱來查詢 UserDetailsService。以下測試使用 bean 名稱為 myUserDetailsService 的 UserDetailsService 查詢使用者名稱為 customUsername 的使用者
-
Java
-
Kotlin
@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
void getMessageWithUserDetailsServiceBeanName() {
String message = messageService.getMessage();
assertThat(message).contains("customUsername");
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
assertThat(principal).isInstanceOf(CustomUserDetails.class);
}
@Test
@WithUserDetails(value = "customUsername", userDetailsServiceBeanName = "myUserDetailsService")
fun getMessageWithUserDetailsServiceBeanName() {
val message: String = messageService.getMessage()
assertThat(message).contains("customUsername");
val principal = SecurityContextHolder.getContext().authentication!!.principal
assertThat(principal).isInstanceOf(CustomUserDetails::class.java)
}
與 @WithMockUser 一樣,我們也可以將註解放在類級別,以便每個測試都使用相同的使用者。但是,與 @WithMockUser 不同,@WithUserDetails 要求使用者存在。
預設情況下,SecurityContext 在 TestExecutionListener.beforeTestMethod 事件期間設定。這相當於發生在 JUnit 的 @Before 之前。您可以將其更改為在 TestExecutionListener.beforeTestExecution 事件期間發生,即在 JUnit 的 @Before 之後但在測試方法呼叫之前發生
@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)
@WithSecurityContext
我們已經看到,如果我們不使用自定義 Authentication 主體,@WithMockUser 是一個絕佳的選擇。接下來,我們發現 @WithUserDetails 允許我們使用自定義 UserDetailsService 來建立我們的 Authentication 主體,但要求使用者存在。現在我們看到一個允許最大靈活性的選項。
我們可以建立自己的註解,使用 @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 final 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={"USER","ADMIN"})
public @interface WithMockAdmin { }
@Retention(AnnotationRetention.RUNTIME)
@WithMockUser(value = "rob", roles = ["USER", "ADMIN"])
annotation class WithMockAdmin
現在我們可以像使用更詳細的 @WithMockUser 一樣使用 @WithMockAdmin。
元註解適用於上述任何測試註解。例如,這意味著我們也可以為 @WithUserDetails("admin") 建立一個元註解。