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上找到本教程的所有示例。