1、概览 {#1概览}
简而言之,Spring Security 支持方法级别的授权语义。可以通过限制哪些角色可以执行特定方法等方式来确保 Service 层的安全。
2、启用 Method Security {#2启用-method-security}
要使用 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 中找到。
接下来,需要启用全局 Method Security:
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class MethodSecurityConfig
extends GlobalMethodSecurityConfiguration {
}
prePostEnabled
属性可启用 Spring Security Pre/Post 注解。securedEnabled
属性决定是否启用@Secured
注解。jsr250Enabled
属性允许使用@RoleAllowed
注解。
3、应用 Method Security {#3应用-method-security}
3.1、使用 @Secured 注解 {#31使用-secured-注解}
@Secured
注解用于指定方法的角色列表。因此,用户只有在至少拥有一个指定角色的情况下才能访问该方法。
定义 getUsername
方法:
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}
这里的 @Secured("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、使用 @RolesAllowed 注解 {#32使用-rolesallowed-注解}
@RolesAllowed
注解与 JSR-250 的 @Secured
注解相当。基本上,可以以类似 @Secured
的方式使用 @RolesAllowed
注解。
重新定义 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 注解 {#33使用-preauthorize-和-postauthorize-注解}
@PreAuthorize
和 @PostAuthorize
注解都提供了基于表达式的访问控制。因此,可以使用 SpEL(Spring 表达式语言)编写谓词(Predicate)。
@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') or hasRole('ROLE_EDITOR')")
:
@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
//...
}
此外,还可以将方法参数作为表达式的一部分:
@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
//...
}
如上,只有当参数 username
的值与当前 Principal 的用户名相同时,用户才能调用 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
的 username
等于当前认证的用户的 nickname
时,loadUserDetail
方法才会成功执行。
本节中使用的是简单的 Spring 表达式。对于更复杂的情况,可以创建自定义的 Security 表达式。
3.4、使用 @PreFilter 和 @PostFilter 注解 {#34使用-prefilter-和-postfilter-注解}
Spring Security 提供了 @PreFilter
注解,用于在执行方法之前过滤集合参数:
@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
return usernames.stream().collect(Collectors.joining(";"));
}
在上例中,joinUsernames
方法会连接所有用户名(username),但已通过身份认证的用户名除外。
在表达式中,使用 filterObject
表示集合中的当前对象。
但是,如果方法有一个以上的 Collection
类型参数,就需要使用 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 元注解 {#35method-security-元注解}
我们通常会使用相同的安全配置来保护不同的方法。
这种情况下,可以定义 Security 元注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}
接下来,可以直接使用 @IsViewer
注解来保护方法:
@IsViewer
public String getUsername4() {
//...
}
Security 元注解增加了更多语义,并使业务逻辑与 Security 框架解耦。
3.6、类级 Security 注解 {#36类级-security-注解}
如果一个类中的每个方法都使用了相同的 Security 注解,可以考虑将该注解置于类级别:
@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {
public String getSystemYear(){
//...
}
public String getSystemDate(){
//...
}
}
如上,安全规则 hasRole('ROLE_ADMIN')
将同时应用于 getSystemYear
和 getSystemDate
方法。
3.7、方法上的多个 Security 注解 {#37方法上的多个-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、需要注意的地方 {#4需要注意的地方}
关于 Method Security,有两点需要注意:
- 默认情况下,Spring 使用 AOP 代理来应用 Method Security。如果一个被安全保护的方法 A 被同一类中的另一个方法直接调用,方法 A 的安全性将被完全忽略。这意味着方法 A 将在没有任何安全检查的情况下执行。同样的情况也适用于私有方法。
- Spring
SecurityContext
是线程绑定的。默认情况下,Security Context 不会传播给子线程。
5、测试 Method Security {#5测试-method-security}
5.1、配置 {#51配置}
添加 spring-security-test
依赖,通过 JUnit 测试 Spring Security。
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
如果使用的是 Spring Boot,则不需要指定依赖版本。可以在 Maven Central 上找到该依赖的最新版本。
接下来,配置一个简单的 Spring Integration 测试:
@RunWith(SpringRunner.class)
@ContextConfiguration
public class MethodSecurityIntegrationTest {
// ...
}
5.2、测试用户名和角色 {#52测试用户名和角色}
测试使用 @Secured("ROLE_VIEWER")
注解保护的 getUsername
方法:
@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 会自动添加该前缀。
如果不想用这个前缀,可以考虑用 authority
代替 role
。
例如,声明一个 getUsernameInLowerCase
方法:
@PreAuthorize("hasAuthority('SYS_ADMIN')")
public String getUsernameLC(){
return getUsername().toLowerCase();
}
使用 authorities
属性来测试:
@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 MockUserAtClassLevelIntegrationTest {
//...
}
如果想以匿名用户身份运行测试,可以使用 @WithAnonymousUser
注解:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
在上面的示例中,由于匿名用户未被授予 ROLE_VIEWER
角色或 SYS_ADMIN
权限,所以会出现 AccessDeniedException
异常。
5.3、通过 UserDetailsService 进行测试 {#53通过-userdetailsservice-进行测试}
对于大多数应用来说,使用自定义类作为认证用户是很常见的。在这种情况下,自定义类需要实现 org.springframework.security.core.userdetails.UserDetails
接口。
声明一个 CustomUser
类,该类继承了 UserDetails
的现有实现 User
:
public class CustomUser extends User {
private String nickName;
// get、Set
}
回顾一下第 3 节中使用 @PostAuthorize
注解的示例:
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}
如上,只有当返回的 CustomUser
的 username
等于当前认证的用户的 nickname
时,该方法才能成功执行。
如果想测试该方法,可以提供一个 UserDetailsService
的实现,它可以根据 username
加载 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
可能是真实的实现,也可能是用于测试目的的伪造实现。
此外,Service 将使用属性 value
的值作为 username
来加载 UserDetails
。
方便的是,还可以在类级别使用 @WithUserDetails
注解进行装饰,就像使用 @WithMockUser
注解那样。
5.4、用元注解进行测试 {#54用元注解进行测试}
如果经常在各种测试中反复使用同一个用户/角色,可以创建元注解。
根据前面的例子 @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
来使用元注解创建特定的用户。
6、总结 {#6总结}
本文介绍了如何使用 Spring Security 中的各种 Method Security 注解来保护方法,以及如何在测试中模拟和重用用户。
Ref:https://www.baeldung.com/spring-security-method-security