1.簡介
簡而言之,Spring Security支持方法級別的授權語義。
通常,我們可以通過限制哪些角色能夠執行特定方法來保護我們的服務層 - 並使用專用的方法級安全測試支持對其進行測試。
在本文中,我們將首先回顧一些安全注釋的使用。然后,我們將專注於使用不同的策略測試我們的方法安全性。
2.啟用方法級別的安全授權配置
首先,要使用Spring Method Security,我們需要添加spring-security-config依賴項:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
如果我們想使用Spring Boot,我們可以使用包含spring-security-config的spring-boot-starter-security依賴項:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
接下來,我們需要啟用全局方法級別授權安全性:
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class MethodSecurityConfig
extends GlobalMethodSecurityConfiguration {
}
- prePostEnabled屬性啟用Spring Security前/后注釋
- securedEnabled屬性確定是否應啟用@Secured注釋
- jsr250Enabled屬性允許我們使用@RoleAllowed注釋
我們將在下一節中詳細探討這些注釋。
3.應用方法級別安全性
3.1。使用@Secured Annotation
@Secured注釋用於指定方法上的角色列表。因此,如果用戶至少具有一個指定的角色,則用戶能訪問該方法。
我們定義一個getUsername方法:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
這里,@ Secure(“ROLE_VIEWER”)注釋定義只有具有ROLE_VIEWER角色的用戶才能執行getUsername方法。
此外,我們可以在@Secured注釋中定義角色列表:
@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername(String username) {
return userRoleRepository.isValidUsername(username);
}
在這種情況下,配置指出如果用戶具有ROLE_VIEWER或ROLE_EDITOR,則該用戶可以調用isValidUsername方法。
@Secured注釋不支持Spring Expression Language(SpEL)。
3.2。使用@RoleAllowed注釋
@RoleAllowed注釋是JSR-250對@Secured注釋的等效注釋。
基本上,我們可以像@Secured一樣使用@RoleAllowed注釋。因此,我們可以重新定義getUsername和isValidUsername方法:
@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
//...
}
@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
//...
}
同樣,只有具有角色ROLE_VIEWER的用戶才能執行getUsername2。
同樣,只有當用戶至少具有ROLE_VIEWER或ROLER_EDITOR角色之一時,用戶才能調用isValidUsername2。
3.3。使用@PreAuthorize和@PostAuthorize注釋
@PreAuthorize和@PostAuthorize注釋都提供基於表達式的訪問控制。因此,可以使用SpEL(Spring Expression Language)編寫。
@PreAuthorize注釋在進入方法之前檢查給定的表達式,而@PostAuthorize注釋在執行方法后驗證它並且可能改變結果。
現在,讓我們聲明一個getUsernameInUpperCase方法,如下所示:
@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
return getUsername().toUpperCase();
}
@PreAuthorize(“hasRole('ROLE_VIEWER')”)與我們在上一節中使用的@Secured(“ROLE_VIEWER”)具有相同的含義。您可以在以前的文章中發現更多安全表達式詳細信息。
因此,注釋@Secured({“ROLE_VIEWER”,“ROLE_EDITOR”})可以替換為@PreAuthorize(“hasRole('ROLE_VIEWER')或hasRole('ROLE_EDITOR')”):
@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
//...
}
而且,我們實際上可以使用method參數作為表達式的一部分:
@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
//...
}
這里,只有當參數username的值與當前主體的用戶名相同時,用戶才能調用getMyRoles方法。
值得注意的是,@ PreAuthorize表達式可以替換為@PostAuthorize表達式。
讓我們重寫getMyRoles:
@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
//...
}
但是,在上一個示例中,授權將在執行目標方法后延遲。
此外,@ PostAuthorize注釋提供了訪問方法結果的能力:
@PostAuthorize
("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
在此示例中,如果返回的CustomUser的用戶名等於當前身份驗證主體的昵稱,則loadUserDetail方法會成功執行。
3.4。使用@PreFilter和@PostFilter注釋
Spring Security提供了@PreFilter注釋來在執行方法之前過濾集合參數:
@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
return usernames.stream().collect(Collectors.joining(";"));
}
在此示例中,我們將過濾除經過身份驗證的用戶名以外的所有用戶名。
這里,我們的表達式使用名稱filterObject來表示集合中的當前對象。
但是,如果該方法有多個參數是集合類型,我們需要使用filterTarget屬性來指定我們要過濾的參數:
@PreFilter
(value = "filterObject != authentication.principal.username",
filterTarget = "usernames")
public String joinUsernamesAndRoles(
List<String> usernames, List<String> roles) {
return usernames.stream().collect(Collectors.joining(";"))
+ ":" + roles.stream().collect(Collectors.joining(";"));
}
此外,我們還可以使用@PostFilter注釋過濾返回的方法集合:
@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
return userRoleRepository.getAllUsernames();
}
在這種情況下,名稱filterObject引用返回集合中的當前對象。
使用該配置,Spring Security將遍歷返回的列表並刪除與主體用戶名匹配的任何值。
3.5。Method Security元注釋
我們發現經常有使用相同安全配置保護不同方法的情況。
在這種情況下,我們可以定義一個Security元注釋:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}
接下來,我們可以直接使用@IsViewer注釋來保護我們的方法:
Security元注釋是一個好主意,因為它們添加了更多語義並將我們的業務邏輯與安全框架分離。
3.6。類級別Security注釋
如果我們發現對一個類中的每個方法使用相同的Security注釋,我們可以考慮將該注釋放在類級別:
@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {
public String getSystemYear(){
//...
}
public String getSystemDate(){
//...
}
}
在上面的示例中,安全規則hasRole('ROLE_ADMIN')將應用於getSystemYear和getSystemDate方法。
3.7。方法上有的多重Security注釋
我們還可以在一個方法上使用多個Security注釋:
@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
因此,Spring將在執行securedLoadUserDetail方法之前和之后驗證授權。
4.重要考慮因素
我們想提醒兩點方法Security:
- 默認情況下,Spring AOP代理用於應用方法安全性 - 如果安全方法A由同一類中的另一個方法調用,則A中的安全性將被完全忽略。這意味着方法A將在沒有任何安全檢查的情況下執行,這同樣適用於私有方法
- Spring SecurityContext是線程綁定的 - 默認情況下,安全上下文不會傳播到子線程
5.測試方法Security
5.1。配置
要使用JUnit測試Spring Security,我們需要spring-security-test依賴項:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
我們不需要指定依賴版本,因為我們使用的是Spring Boot插件。
接下來,讓我們通過指定runner和ApplicationContext配置來配置一個簡單的Spring Integration測試:
@RunWith(SpringRunner.class)
@ContextConfiguration
public class TestMethodSecurity {
// ...
}
5.2。測試用戶名和角色
現在我們的配置准備好了,讓我們嘗試測試我的getUsername方法,該方法由注釋@Secured(“ROLE_VIEWER”)保護:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
由於我們在這里使用@Secured注釋,因此需要對用戶進行身份驗證以調用該方法。否則,我們將獲得AuthenticationCredentialsNotFoundException。
因此,我們需要為用戶提供測試我們的安全方法。為此,我們使用@WithMockUser修飾測試方法並提供用戶和角色:
@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
我們提供了一個經過身份驗證的用戶,其用戶名是john,其角色是ROLE_VIEWER。如果我們不指定用戶名或角色,則默認用戶名為user,默認角色為ROLE_USER。
請注意,此處不必添加ROLE_前綴,Spring Security將自動添加該前綴。
如果我們不想擁有該前綴,我們可以考慮使用權限而不是角色。
例如,讓我們聲明一個getUsernameInLowerCase方法:
@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
return getUsername().toLowerCase();
}
我們可以使用權限測試:
@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
String username = userRoleService.getUsernameInLowerCase();
assertEquals("john", username);
}
如果我們想在許多測試用例中使用相同的用戶,我們可以在測試類中聲明@WithMockUser注釋:
@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class TestWithMockUserAtClassLevel {
//...
}
如果我們想以匿名用戶身份運行我們的測試,我們可以使用@WithAnonymousUser注釋:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
在上面的示例中,我們期望AccessDeniedException,因為匿名用戶未被授予角色ROLE_VIEWER或權限SYS_ADMIN。
5.3。使用Custom UserDetailsService進行測試
對於大多數應用程序,通常使用自定義類作為身份驗證主體。在這種情況下,自定義類需要實現org.springframework.security.core.userdetails.UserDetails接口。
在本文中,我們聲明了一個CustomUser類,它擴展了UserDetails的現有實現,即org.springframework.security.core.userdetails.User:
public class CustomUser extends User {
private String nickName;
// getter and setter
}
讓我們在第3節中使用@PostAuthorize注釋取回示例:
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
在這種情況下,只有返回的CustomUser的用戶名等於當前身份驗證主體的昵稱時,該方法才會成功執行。
如果我們想測試該方法,我們可以提供UserDetailsService的實現,它可以根據用戶名加載我們的CustomUser:
@Test
@WithUserDetails(
value = "john",
userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
CustomUser user = userService.loadUserDetail("jane");
assertEquals("jane", user.getNickName());
}
這里,@ WithUserDetails注釋聲明我們將使用UserDetailsService來初始化我們經過身份驗證的用戶。該服務由userDetailsServiceBeanName屬性引用。這個UserDetailsService可能是一個真正的實現,或者用於測試目的。
此外,該服務將使用屬性值的值作為加載UserDetails的用戶名。
方便的是,我們也可以在類級別使用@WithUserDetails注釋進行修飾,類似於我們對@WithMockUser注釋所做的操作。
5.4。使用Meta注釋進行測試
我們經常發現自己在各種測試中一遍又一遍地重復使用相同的用戶/角色。
對於這些情況,創建元注釋很方便。
修改前面的示例@WithMockUser(username =“john”,roles = {“VIEWER”}),我們可以將元注釋聲明為:
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer { }
然后我們可以在測試中簡單地使用@WithMockJohnViewer:
@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
同樣,我們可以使用元注釋來使用@WithUserDetails創建特定於域的用戶。
六,結論
在本教程中,我們探討了在Spring Security中使用Method Security的各種選項。
我們還經歷了一些技術來輕松測試方法安全性,並學習如何在不同的測試中重用模擬用戶。
可以在Github上找到本教程的所有示例。