測試方法安全

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

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

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

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

}

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

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithAnonymousUser

使用 @WithAnonymousUser 允許以匿名使用者身份執行。當您希望以特定使用者身份執行大部分測試,但又希望以匿名使用者身份執行少量測試時,這尤其方便。以下示例透過使用 @WithMockUser 以匿名使用者身份執行 withMockUser1withMockUser2

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

}

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

@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

@WithUserDetails

雖然 @WithMockUser 是一種方便的入門方法,但它可能並非適用於所有情況。例如,某些應用程式期望 Authentication 主體是特定型別。這樣做是為了應用程式可以將主體稱為自定義型別並減少對 Spring Security 的耦合。

自定義主體通常由自定義 UserDetailsService 返回,該服務返回一個同時實現 UserDetails 和自定義型別的物件。對於這種情況,使用自定義 UserDetailsService 建立測試使用者非常有用。這正是 @WithUserDetails 所做的。

假設我們有一個作為 bean 公開的 UserDetailsService,以下測試將使用型別為 UsernamePasswordAuthenticationTokenAuthentication 和從 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 名稱為 myUserDetailsServiceUserDetailsService 查詢使用者名稱為 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 要求使用者存在。

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

}

預設情況下,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={"USER","ADMIN"})
public @interface WithMockAdmin { }
@Retention(AnnotationRetention.RUNTIME)
@WithMockUser(value = "rob", roles = ["USER", "ADMIN"])
annotation class WithMockAdmin

現在我們可以像使用更詳細的 @WithMockUser 一樣使用 @WithMockAdmin

元註解適用於上述任何測試註解。例如,這意味著我們也可以為 @WithUserDetails("admin") 建立一個元註解。

© . This site is unofficial and not affiliated with VMware.