測試方法安全

本節演示如何使用 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 透過 WithSecurityContextTestExecutionListener 連線到 Spring Test 支援,它確保我們的測試以正確的使用者身份執行。它透過在執行測試之前填充 SecurityContextHolder 來實現這一點。如果您使用響應式方法安全,您還需要 ReactorContextTestExecutionListener,它會填充 ReactiveSecurityContextHolder。測試完成後,它會清除 SecurityContextHolder。如果您只需要 Spring Security 相關的支援,可以用 @SecurityTestExecutionListeners 替換 @ContextConfiguration

請記住,我們在 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_USERGrantedAuthority

前面的例子很方便,因為它允許我們使用許多預設值。如果我們想用不同的使用者名稱執行測試怎麼辦?下面的測試將以使用者名稱 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_USERROLE_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 屬性。例如,下面的測試將以使用者名稱 adminUSERADMIN 許可權呼叫。

  • 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_USERROLE_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_USERROLE_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
    }
}

預設情況下,SecurityContextTestExecutionListener.beforeTestMethod 事件期間設定。這相當於在 JUnit 的 @Before 之前發生。您可以將其更改為在 TestExecutionListener.beforeTestExecution 事件期間發生,即在 JUnit 的 @Before 之後但在測試方法呼叫之前

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithAnonymousUser

使用 @WithAnonymousUser 可以以匿名使用者身份執行。當您希望大多數測試以特定使用者身份執行,但少數測試以匿名使用者身份執行時,這尤其方便。下面的示例使用 @WithMockUser 執行 withMockUser1withMockUser2,並以匿名使用者身份執行 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
    }
}

預設情況下,SecurityContextTestExecutionListener.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,下面的測試將以型別為 UsernamePasswordAuthenticationTokenAuthentication 和由 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 名稱為 myUserDetailsServiceUserDetailsService 查詢使用者名稱 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 要求使用者必須存在。

預設情況下,SecurityContextTestExecutionListener.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
    }
}

預設情況下,SecurityContextTestExecutionListener.beforeTestMethod 事件期間設定。這相當於在 JUnit 的 @Before 之前發生。您可以將其更改為在 TestExecutionListener.beforeTestExecution 事件期間發生,即在 JUnit 的 @Before 之後但在測試方法呼叫之前

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

測試元註解

如果您經常在測試中重複使用同一使用者,則反覆指定屬性並非理想做法。例如,如果您有許多與使用者名稱為 admin、角色為 ROLE_USERROLE_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") 建立一個元註解。