測試方法安全性

本節示範如何使用 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 註解。