1. 概述
簡單來説,Spring Security 支持在方法級別上的權限語義。
通常,我們可以通過限制特定角色執行特定方法的權限來安全我們的服務層,並使用專門的方法級安全測試支持進行測試。
在本教程中,我們將回顧一些安全註解的使用。然後我們將專注於使用不同的策略測試我們的方法安全。
2. 啓用方法安全
首先,要使用 Spring Method Security,我們需要添加 spring-security-config 依賴項:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
可以在 Maven Central 找到最新版本。
如果使用 Spring Boot,可以使用 spring-boot-starter-security 依賴項,其中包含 spring-security-config:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
再次,可以在 Maven Central 找到最新版本。
接下來,我們需要啓用全局方法安全:
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class MethodSecurityConfig
extends GlobalMethodSecurityConfiguration {
}
- prePostEnabled 屬性啓用 Spring Security pre/post 註解。
- securedEnabled 屬性確定 @Secured 註解是否啓用。
- jsr250Enabled 屬性允許我們使用 @RoleAllowed 註解。
我們在下一部分將更深入地探討這些註解。
3. Applying Method Security
3.1. Using Annotation
The annotation is used to specify a list of roles on a method. So, a user only can access that method if she has at least one of the specified roles.
Let’s define a getUsername method:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
Here the annotation defines that only users who have the role ROLE_VIEWER@ are able to execute the getUsername@ method.
Besides, we can define a list of roles in a annotation:
@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername(String username) {
return userRoleRepository.isValidUsername(username);
}
In this case, the configuration states that if a user has either ROLE_VIEWER @ or ROLE_EDITOR @, that user can invoke the isValidUsername@ method.
The annotation doesn’t support Spring Expression Language (SpEL).
3.2. Using Annotation
The annotation is the JSR-250’s equivalent annotation of the annotation.
Basically, we can use the annotation in a similar way as .
This way, we could redefine getUsername and isValidUsername methods:
@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
//...
}
@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
//...
}
Similarly, only the user who has role ROLE_VIEWER @ can execute getUsername2@.
Again, a user is able to invoke isValidUsername2@ only if she has at least one of the ROLE_VIEWER @ or ROLE_EDITOR @ roles.
3.3. Using and Annotations
Both and annotations provide expression-based access control. So, predicates can be written using SpEL (Spring Expression Language).
The annotation checks the given expression before entering the method, whereas the annotation verifies it after the execution of the method and could alter the result.
Now let’s declare a getUsernameInUpperCase method as below:
@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
return getUsername().toUpperCase();
}
The has the same meaning as , which we used in the previous section. Feel free to discover more security expressions details in previous articles.
Consequently, the annotation can be replaced with or :
@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
//...
}
Moreover, we can actually use the method argument as part of the expression:
@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
//...
}
Here a user can invoke the getMyRoles@ method only if the value of the argument username@ is the same as current principal’s username.
It’s worth noting that expressions can be replaced by ones.
Let’s rewrite getMyRoles@:
@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
//...
}
In the previous example, however, the authorization would get delayed after the execution of the target method.
Additionally, the annotation provides the ability to access the method result.
@PostAuthorize
("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
Here the loadUserDetail@ method would only execute successfully if the username@ of the returned CustomUser@ is equal to the current authentication principal’s nickname@.
In this section, we mostly use simple Spring expressions. For more complex scenarios, we could create custom security expressions.
3.4. Using and Annotations
Spring Security provides the annotation to filter a collection argument before executing the method:
@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
return usernames.stream().collect(Collectors.joining(";"));
}
In this example, we’re joining all usernames except for the one that is authenticated.
Here, in our expression, we use the name to represent the current object in the collection.
However, if the method has more than one argument that is a collection type, we need to use the property to specify which argument we want to filter:
@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(";"));
}
Additionally, we can also filter the returned collection of a method by using the annotation:
@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
return userRoleRepository.getAllUsernames();
}
In this case, the name refers to the current object in the returned collection.
With that configuration, Spring Security will iterate through the returned list and remove any value matching the principal’s username.
Our Spring Security – @PreFilter and @PostFilter article describes both annotations in greater detail.
3.5. Method Security Meta-Annotation
We typically find ourselves in a situation where we protect different methods using the same security configuration.
In this case, we can define a security meta-annotation:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}
Next, we can directly use the @IsViewer annotation to secure our method:
@IsViewer
public String getUsername4() {
//...
}
Security meta-annotations are a great idea because they add more semantics and decouple our business logic from the security framework.
3.6. Security Annotation at the Class Level
If we find ourselves using the same security annotation for every method within one class, we can consider putting that annotation at class level:
@Service
@PreAuthorize("hasRole('ADMIN')")
public class SystemService {
public String getSystemYear(){
//...
}
public String getSystemDate(){
//...
}
}
In above example, the security rule hasRole(‘ADMIN’) will be applied to both getSystemYear@ and getSystemDate@ methods.
3.7. Multiple Security Annotations on a Method
We can also use multiple security annotations on one method:
@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
This way, Spring will verify authorization both before and after the execution of the securedLoadUserDetail@ method.
4. 重要注意事項
我們想提醒您以下兩點關於方法安全:
- 默認情況下,Spring AOP 代理用於應用方法安全。如果由同一類中另一個方法調用受保護的方法 A,則方法 A 中的安全檢查將被完全忽略。這意味着方法 A 將在沒有任何安全檢查的情況下執行。同樣,這適用於私有方法。
- Spring SecurityContext 是線程綁定的。默認情況下,安全上下文未傳遞到子線程。有關更多信息,請參閲我們的 Spring Security Context Propagation 文章。
5. Testing Method Security
5.1. Configuration
To test Spring Security with JUnit, we need the spring-security-test dependency:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
We don’t need to specify the dependency version because we’re using the Spring Boot plugin. We can find the latest version of this dependency on Maven Central.
Next, let’s configure a simple Spring Integration test by specifying the runner and the ApplicationContext configuration:
@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
// ...
}
5.2. Testing Username and Roles
Now that our configuration is ready, let’s try to test our getUsername method that we secured with the @Secured("ROLE_VIEWER") annotation:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
Since we use the @Secured annotation here, it requires a user to be authenticated to invoke the method. Otherwise, we’ll get an AuthenticationCredentialsNotFoundException.
So, we need to provide a user to test our secured method.
To achieve this, we decorate the test method with @WithMockUser and provide a user and roles:
@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
We’ve provided an authenticated user whose username is john and whose role is ROLE_VIEWER. If we don’t specify the username or role, the default username is user and default role is ROLE_USER.
Note that it isn’t necessary to add the ROLE_ prefix here because Spring Security will add that prefix automatically.
If we don’t want to have that prefix, we can consider using authority instead of role.
For example, let’s declare a getUsernameInLowerCase method:
@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
return getUsername().toLowerCase();
}
We could test that using authorities:
@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
String username = userRoleService.getUsernameInLowerCase();
assertEquals("john", username);
}
Conveniently, if we want to use the same user for many test cases, we can declare the @WithMockUser annotation at test class:
@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class MockUserAtClassLevelIntegrationTest {
//...
}
If we wanted to run our test as an anonymous user, we could use the @WithAnonymousUser annotation:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
In the example above, we expect an AccessDeniedException
because the anonymous user isn’t granted the role ROLE_VIEWER or the authority SYS_ADMIN.
5.3. Testing With a Custom UserDetailsService
For most applications, it’s common to use a custom class as authentication principal. In this case, the custom class needs to implement the UserDetails interface.
In this article, we declare a CustomUser class that extends the existing implementation of UserDetails, which is org.springframework.security.core.userdetails.User:
public class CustomUser extends User {
private String nickName;
// getter and setter
}
Let’s look back at the example with the @PostAuthorize annotation in Section 3:
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
In this case, the method would only execute successfully if the username of the returned CustomUser is equal to the current authentication principal’s nickname.
If we wanted to test that method, we could provide an implementation of UserDetailsService that could load our CustomUser based on the username:
@Test
@WithUserDetails(
value = "john",
userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
CustomUser user = userService.loadUserDetail("jane");
assertEquals("jane", user.getNickName());
}
Here the @WithUserDetails annotation states that we’ll use a UserDetailsService to initialize our authenticated user. The service is referred by the userDetailsServiceBeanName property. This UserDetailsService might be a real implementation or a fake for testing purposes.
Additionally, the service will use the value of the property value as the username to load UserDetails.
5.4. Testing With Meta Annotations
We often find ourselves reusing the same user/roles over and over again in various tests.
For these situations, it’s convenient to create a meta-annotation.
Looking again at the previous example @Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer {}
Then we can simply use @WithMockJohnViewer in our test:
@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
Likewise, we can use meta-annotations to create domain-specific users using @WithUserDetails.
6. 結論
在本文中,我們探討了在 Spring Security 中使用 Method Security 的各種選項。
我們還回顧了輕鬆測試方法安全性和在不同測試中使用模擬用户的一些技術。
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername(String username) {
return userRoleRepository.isValidUsername(username);
}
The annotation is the JSR-250’s equivalent annotation of the annotation.
Basically, we can use the annotation in a similar way as .
This way, we could redefine getUsername and isValidUsername methods:
@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
//...
}
@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
//...
}
Similarly, only the user who has role ROLE_VIEWER @ can execute getUsername2@.
Again, a user is able to invoke isValidUsername2@ only if she has at least one of the ROLE_VIEWER @ or ROLE_EDITOR @ roles.
3.3. Using and Annotations
Both and annotations provide expression-based access control. So, predicates can be written using SpEL (Spring Expression Language).
The annotation checks the given expression before entering the method, whereas the annotation verifies it after the execution of the method and could alter the result.
Now let’s declare a getUsernameInUpperCase method as below:
@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
return getUsername().toUpperCase();
}
The has the same meaning as , which we used in the previous section. Feel free to discover more security expressions details in previous articles.
Consequently, the annotation can be replaced with or :
@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
//...
}
Moreover, we can actually use the method argument as part of the expression:
@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
//...
}
Here a user can invoke the getMyRoles@ method only if the value of the argument username@ is the same as current principal’s username.
It’s worth noting that expressions can be replaced by ones.
Let’s rewrite getMyRoles@:
@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
//...
}
In the previous example, however, the authorization would get delayed after the execution of the target method.
Additionally, the annotation provides the ability to access the method result.
@PostAuthorize
("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
Here the loadUserDetail@ method would only execute successfully if the username@ of the returned CustomUser@ is equal to the current authentication principal’s nickname@.
In this section, we mostly use simple Spring expressions. For more complex scenarios, we could create custom security expressions.
3.4. Using and Annotations
Spring Security provides the annotation to filter a collection argument before executing the method:
@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
return usernames.stream().collect(Collectors.joining(";"));
}
In this example, we’re joining all usernames except for the one that is authenticated.
Here, in our expression, we use the name to represent the current object in the collection.
However, if the method has more than one argument that is a collection type, we need to use the property to specify which argument we want to filter:
@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(";"));
}
Additionally, we can also filter the returned collection of a method by using the annotation:
@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
return userRoleRepository.getAllUsernames();
}
In this case, the name refers to the current object in the returned collection.
With that configuration, Spring Security will iterate through the returned list and remove any value matching the principal’s username.
Our Spring Security – @PreFilter and @PostFilter article describes both annotations in greater detail.
3.5. Method Security Meta-Annotation
We typically find ourselves in a situation where we protect different methods using the same security configuration.
In this case, we can define a security meta-annotation:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}
Next, we can directly use the @IsViewer annotation to secure our method:
@IsViewer
public String getUsername4() {
//...
}
Security meta-annotations are a great idea because they add more semantics and decouple our business logic from the security framework.
3.6. Security Annotation at the Class Level
If we find ourselves using the same security annotation for every method within one class, we can consider putting that annotation at class level:
@Service
@PreAuthorize("hasRole('ADMIN')")
public class SystemService {
public String getSystemYear(){
//...
}
public String getSystemDate(){
//...
}
}
In above example, the security rule hasRole(‘ADMIN’) will be applied to both getSystemYear@ and getSystemDate@ methods.
3.7. Multiple Security Annotations on a Method
We can also use multiple security annotations on one method:
@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
This way, Spring will verify authorization both before and after the execution of the securedLoadUserDetail@ method.
4. 重要注意事項
我們想提醒您以下兩點關於方法安全:
- 默認情況下,Spring AOP 代理用於應用方法安全。如果由同一類中另一個方法調用受保護的方法 A,則方法 A 中的安全檢查將被完全忽略。這意味着方法 A 將在沒有任何安全檢查的情況下執行。同樣,這適用於私有方法。
- Spring SecurityContext 是線程綁定的。默認情況下,安全上下文未傳遞到子線程。有關更多信息,請參閲我們的 Spring Security Context Propagation 文章。
5. Testing Method Security
5.1. Configuration
To test Spring Security with JUnit, we need the spring-security-test dependency:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
We don’t need to specify the dependency version because we’re using the Spring Boot plugin. We can find the latest version of this dependency on Maven Central.
Next, let’s configure a simple Spring Integration test by specifying the runner and the ApplicationContext configuration:
@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
// ...
}
5.2. Testing Username and Roles
Now that our configuration is ready, let’s try to test our getUsername method that we secured with the @Secured("ROLE_VIEWER") annotation:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
Since we use the @Secured annotation here, it requires a user to be authenticated to invoke the method. Otherwise, we’ll get an AuthenticationCredentialsNotFoundException.
So, we need to provide a user to test our secured method.
To achieve this, we decorate the test method with @WithMockUser and provide a user and roles:
@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
We’ve provided an authenticated user whose username is john and whose role is ROLE_VIEWER. If we don’t specify the username or role, the default username is user and default role is ROLE_USER.
Note that it isn’t necessary to add the ROLE_ prefix here because Spring Security will add that prefix automatically.
If we don’t want to have that prefix, we can consider using authority instead of role.
For example, let’s declare a getUsernameInLowerCase method:
@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
return getUsername().toLowerCase();
}
We could test that using authorities:
@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
String username = userRoleService.getUsernameInLowerCase();
assertEquals("john", username);
}
Conveniently, if we want to use the same user for many test cases, we can declare the @WithMockUser annotation at test class:
@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class MockUserAtClassLevelIntegrationTest {
//...
}
If we wanted to run our test as an anonymous user, we could use the @WithAnonymousUser annotation:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
In the example above, we expect an AccessDeniedException
because the anonymous user isn’t granted the role ROLE_VIEWER or the authority SYS_ADMIN.
5.3. Testing With a Custom UserDetailsService
For most applications, it’s common to use a custom class as authentication principal. In this case, the custom class needs to implement the UserDetails interface.
In this article, we declare a CustomUser class that extends the existing implementation of UserDetails, which is org.springframework.security.core.userdetails.User:
public class CustomUser extends User {
private String nickName;
// getter and setter
}
Let’s look back at the example with the @PostAuthorize annotation in Section 3:
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
In this case, the method would only execute successfully if the username of the returned CustomUser is equal to the current authentication principal’s nickname.
If we wanted to test that method, we could provide an implementation of UserDetailsService that could load our CustomUser based on the username:
@Test
@WithUserDetails(
value = "john",
userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
CustomUser user = userService.loadUserDetail("jane");
assertEquals("jane", user.getNickName());
}
Here the @WithUserDetails annotation states that we’ll use a UserDetailsService to initialize our authenticated user. The service is referred by the userDetailsServiceBeanName property. This UserDetailsService might be a real implementation or a fake for testing purposes.
Additionally, the service will use the value of the property value as the username to load UserDetails.
5.4. Testing With Meta Annotations
We often find ourselves reusing the same user/roles over and over again in various tests.
For these situations, it’s convenient to create a meta-annotation.
Looking again at the previous example @Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer {}
Then we can simply use @WithMockJohnViewer in our test:
@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
Likewise, we can use meta-annotations to create domain-specific users using @WithUserDetails.
6. 結論
在本文中,我們探討了在 Spring Security 中使用 Method Security 的各種選項。
我們還回顧了輕鬆測試方法安全性和在不同測試中使用模擬用户的一些技術。
@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
return getUsername().toUpperCase();
}
@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
//...
}
@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
//...
}
@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
//...
}
@PostAuthorize
("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
Spring Security provides the annotation to filter a collection argument before executing the method:
@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
return usernames.stream().collect(Collectors.joining(";"));
}
In this example, we’re joining all usernames except for the one that is authenticated.
Here, in our expression, we use the name to represent the current object in the collection.
However, if the method has more than one argument that is a collection type, we need to use the property to specify which argument we want to filter:
@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(";"));
}
Additionally, we can also filter the returned collection of a method by using the annotation:
@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
return userRoleRepository.getAllUsernames();
}
In this case, the name refers to the current object in the returned collection.
With that configuration, Spring Security will iterate through the returned list and remove any value matching the principal’s username.
Our Spring Security – @PreFilter and @PostFilter article describes both annotations in greater detail.
3.5. Method Security Meta-Annotation
We typically find ourselves in a situation where we protect different methods using the same security configuration.
In this case, we can define a security meta-annotation:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}
Next, we can directly use the @IsViewer annotation to secure our method:
@IsViewer
public String getUsername4() {
//...
}
Security meta-annotations are a great idea because they add more semantics and decouple our business logic from the security framework.
3.6. Security Annotation at the Class Level
If we find ourselves using the same security annotation for every method within one class, we can consider putting that annotation at class level:
@Service
@PreAuthorize("hasRole('ADMIN')")
public class SystemService {
public String getSystemYear(){
//...
}
public String getSystemDate(){
//...
}
}
In above example, the security rule hasRole(‘ADMIN’) will be applied to both getSystemYear@ and getSystemDate@ methods.
3.7. Multiple Security Annotations on a Method
We can also use multiple security annotations on one method:
@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
This way, Spring will verify authorization both before and after the execution of the securedLoadUserDetail@ method.
4. 重要注意事項
我們想提醒您以下兩點關於方法安全:
- 默認情況下,Spring AOP 代理用於應用方法安全。如果由同一類中另一個方法調用受保護的方法 A,則方法 A 中的安全檢查將被完全忽略。這意味着方法 A 將在沒有任何安全檢查的情況下執行。同樣,這適用於私有方法。
- Spring SecurityContext 是線程綁定的。默認情況下,安全上下文未傳遞到子線程。有關更多信息,請參閲我們的 Spring Security Context Propagation 文章。
5. Testing Method Security
5.1. Configuration
To test Spring Security with JUnit, we need the spring-security-test dependency:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
We don’t need to specify the dependency version because we’re using the Spring Boot plugin. We can find the latest version of this dependency on Maven Central.
Next, let’s configure a simple Spring Integration test by specifying the runner and the ApplicationContext configuration:
@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
// ...
}
5.2. Testing Username and Roles
Now that our configuration is ready, let’s try to test our getUsername method that we secured with the @Secured("ROLE_VIEWER") annotation:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
Since we use the @Secured annotation here, it requires a user to be authenticated to invoke the method. Otherwise, we’ll get an AuthenticationCredentialsNotFoundException.
So, we need to provide a user to test our secured method.
To achieve this, we decorate the test method with @WithMockUser and provide a user and roles:
@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
We’ve provided an authenticated user whose username is john and whose role is ROLE_VIEWER. If we don’t specify the username or role, the default username is user and default role is ROLE_USER.
Note that it isn’t necessary to add the ROLE_ prefix here because Spring Security will add that prefix automatically.
If we don’t want to have that prefix, we can consider using authority instead of role.
For example, let’s declare a getUsernameInLowerCase method:
@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
return getUsername().toLowerCase();
}
We could test that using authorities:
@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
String username = userRoleService.getUsernameInLowerCase();
assertEquals("john", username);
}
Conveniently, if we want to use the same user for many test cases, we can declare the @WithMockUser annotation at test class:
@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class MockUserAtClassLevelIntegrationTest {
//...
}
If we wanted to run our test as an anonymous user, we could use the @WithAnonymousUser annotation:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
In the example above, we expect an AccessDeniedException
because the anonymous user isn’t granted the role ROLE_VIEWER or the authority SYS_ADMIN.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}
@IsViewer
public String getUsername4() {
//...
}
If we find ourselves using the same security annotation for every method within one class, we can consider putting that annotation at class level:
@Service
@PreAuthorize("hasRole('ADMIN')")
public class SystemService {
public String getSystemYear(){
//...
}
public String getSystemDate(){
//...
}
}
In above example, the security rule hasRole(‘ADMIN’) will be applied to both getSystemYear@ and getSystemDate@ methods.
3.7. Multiple Security Annotations on a Method
We can also use multiple security annotations on one method:
@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
This way, Spring will verify authorization both before and after the execution of the securedLoadUserDetail@ method.
4. 重要注意事項
我們想提醒您以下兩點關於方法安全:
- 默認情況下,Spring AOP 代理用於應用方法安全。如果由同一類中另一個方法調用受保護的方法 A,則方法 A 中的安全檢查將被完全忽略。這意味着方法 A 將在沒有任何安全檢查的情況下執行。同樣,這適用於私有方法。
- Spring SecurityContext 是線程綁定的。默認情況下,安全上下文未傳遞到子線程。有關更多信息,請參閲我們的 Spring Security Context Propagation 文章。
5. Testing Method Security
5.1. Configuration
To test Spring Security with JUnit, we need the spring-security-test dependency:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
We don’t need to specify the dependency version because we’re using the Spring Boot plugin. We can find the latest version of this dependency on Maven Central.
Next, let’s configure a simple Spring Integration test by specifying the runner and the ApplicationContext configuration:
@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
// ...
}
5.2. Testing Username and Roles
Now that our configuration is ready, let’s try to test our getUsername method that we secured with the @Secured("ROLE_VIEWER") annotation:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
Since we use the @Secured annotation here, it requires a user to be authenticated to invoke the method. Otherwise, we’ll get an AuthenticationCredentialsNotFoundException.
So, we need to provide a user to test our secured method.
To achieve this, we decorate the test method with @WithMockUser and provide a user and roles:
@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
We’ve provided an authenticated user whose username is john and whose role is ROLE_VIEWER. If we don’t specify the username or role, the default username is user and default role is ROLE_USER.
Note that it isn’t necessary to add the ROLE_ prefix here because Spring Security will add that prefix automatically.
If we don’t want to have that prefix, we can consider using authority instead of role.
For example, let’s declare a getUsernameInLowerCase method:
@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
return getUsername().toLowerCase();
}
We could test that using authorities:
@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
String username = userRoleService.getUsernameInLowerCase();
assertEquals("john", username);
}
Conveniently, if we want to use the same user for many test cases, we can declare the @WithMockUser annotation at test class:
@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class MockUserAtClassLevelIntegrationTest {
//...
}
If we wanted to run our test as an anonymous user, we could use the @WithAnonymousUser annotation:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
In the example above, we expect an AccessDeniedException
@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
// ...
}@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}@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);
}@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class MockUserAtClassLevelIntegrationTest {
//...
}@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}5.3. Testing With a Custom UserDetailsService
For most applications, it’s common to use a custom class as authentication principal. In this case, the custom class needs to implement the UserDetails interface.
In this article, we declare a CustomUser class that extends the existing implementation of UserDetails, which is org.springframework.security.core.userdetails.User:
public class CustomUser extends User {
private String nickName;
// getter and setter
}
Let’s look back at the example with the @PostAuthorize annotation in Section 3:
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
In this case, the method would only execute successfully if the username of the returned CustomUser is equal to the current authentication principal’s nickname.
If we wanted to test that method, we could provide an implementation of UserDetailsService that could load our CustomUser based on the username:
@Test
@WithUserDetails(
value = "john",
userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
CustomUser user = userService.loadUserDetail("jane");
assertEquals("jane", user.getNickName());
}
Here the @WithUserDetails annotation states that we’ll use a UserDetailsService to initialize our authenticated user. The service is referred by the userDetailsServiceBeanName property. This UserDetailsService might be a real implementation or a fake for testing purposes.
Additionally, the service will use the value of the property value as the username to load UserDetails.
5.4. Testing With Meta Annotations
We often find ourselves reusing the same user/roles over and over again in various tests.
For these situations, it’s convenient to create a meta-annotation.
Looking again at the previous example @Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer {}
Then we can simply use @WithMockJohnViewer in our test: Likewise, we can use meta-annotations to create domain-specific users using @WithUserDetails. 在本文中,我們探討了在 Spring Security 中使用 Method Security 的各種選項。 我們還回顧了輕鬆測試方法安全性和在不同測試中使用模擬用户的一些技術。@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}6. 結論