Spring Security 方法安全(Method Security)简介

1、概览

简而言之,Spring Security 支持方法级别的授权语义。可以通过限制哪些角色可以执行特定方法等方式来确保 Service 层的安全。

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.1、使用 @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_VIEWERROLE_EDITOR 角色,则该用户可以调用 isValidUsername 方法。

@Secured 注解不支持 Spring Expression Language(SpEL)。

3.2、使用 @RolesAllowed 注解

@RolesAllowed 注解与 JSR-250 的 @Secured 注解相当。基本上,可以以类似 @Secured 的方式使用 @RolesAllowed 注解。

重新定义 getUsernameisValidUsername 方法:

@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
    //...
}

@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
    //...
}

只有角色为 ROLE_VIEWER 的用户才能执行 getUsername2

只有当用户拥有至少一个 ROLE_VIEWERROLER_EDITOR 角色时,才能调用 isValidUsername2

3.3、使用 @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);
}

如上,只有当返回的 CustomUserusername 等于当前认证的用户的 nickname 时,loadUserDetail 方法才会成功执行。

本节中使用的是简单的 Spring 表达式。对于更复杂的情况,可以创建自定义的 Security 表达式。

3.4、使用 @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 元注解

我们通常会使用相同的安全配置来保护不同的方法。

这种情况下,可以定义 Security 元注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('VIEWER')")
public @interface IsViewer {
}

接下来,可以直接使用 @IsViewer 注解来保护方法:

@IsViewer
public String getUsername4() {
    //...
}

Security 元注解增加了更多语义,并使业务逻辑与 Security 框架解耦。

3.6、类级 Security 注解

如果一个类中的每个方法都使用了相同的 Security 注解,可以考虑将该注解置于类级别:

@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {

    public String getSystemYear(){
        //...
    }
 
    public String getSystemDate(){
        //...
    }
}

如上,安全规则 hasRole('ROLE_ADMIN') 将同时应用于 getSystemYeargetSystemDate 方法。

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、需要注意的地方

关于 Method Security,有两点需要注意:

  • 默认情况下,Spring 使用 AOP 代理来应用 Method Security。如果一个被安全保护的方法 A 被同一类中的另一个方法直接调用,方法 A 的安全性将被完全忽略。这意味着方法 A 将在没有任何安全检查的情况下执行。同样的情况也适用于私有方法。
  • Spring SecurityContext 是线程绑定的。默认情况下,Security Context 不会传播给子线程。

5、测试 Method Security

5.1、配置

添加 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、测试用户名和角色

测试使用 @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 进行测试

对于大多数应用来说,使用自定义类作为认证用户是很常见的。在这种情况下,自定义类需要实现 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);
}

如上,只有当返回的 CustomUserusername 等于当前认证的用户的 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、用元注解进行测试

如果经常在各种测试中反复使用同一个用户/角色,可以创建元注解。

根据前面的例子 @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、总结

本文介绍了如何使用 Spring Security 中的各种 Method Security 注解来保护方法,以及如何在测试中模拟和重用用户。


Ref:https://www.baeldung.com/spring-security-method-security