Spring方法安全简介

2023/05/17

1. 概述

简单来说,Spring Security支持方法级别的授权语义。

通常,我们可以通过限制哪些角色能够执行特定方法来保护我们的服务层,并使用专用的方法级安全测试支持对其进行测试。

在本教程中,我们将回顾一些Security注解的使用。然后我们将专注于使用不同的策略测试我们方法安全性。

延伸阅读

Spring表达式语言指南

本文探讨了Spring表达式语言(SpEL),这是一种强大的表达式语言,支持在运行时查询和操作对象图。

阅读更多

使用Spring Security的自定义安全表达式

使用Spring Security创建新的自定义安全表达式,然后将新表达式与Pre和Post授权注解一起使用的指南。

阅读更多

2. 启用方法级安全

首先,要使用Spring方法安全性,我们需要添加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注解
  • secureEnabled属性确定是否应启用@Secured注解
  • jsr250Enabled属性允许我们使用@RoleAllowed注解

我们将在下一节中探讨有关这些注解的更多信息。

3. 应用方法安全

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_VIEWER或ROLE_EDITOR角色,则该用户可以调用isValidUsername方法。

@Secured注解不支持Spring表达式语言(SpEL)

3.2 使用@RolesAllowed注解

@RolesAllowed注解是JSR-250中与@Secured注解等效的注解

基本上,我们可以以与@Secured类似的方式使用@RolesAllowed注解。

这样,我们可以重新定义getUsername和isValidUsername方法以使用@RolesAllowed注解:

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

同样,只有具有角色ROLE_VIEWER的用户才能执行getUsername2。

同样,只有当用户至少具有ROLE_VIEWER或ROLE_EDITOR其中一种角色时,他才能调用isValidUsername2。

3.3 使用@PreAuthorize和@PostAuthorize注解

@PreAuthorize和@PostAuthorize注解都提供基于表达式的访问控制。因此,可以使用SpEL编写谓词。

@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的值与当前主体的用户名相同时,用户才能调用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表达式。对于更复杂的场景,我们可以创建自定义安全表达式

3.4 使用@PreFilter和@PostFilter注解

Spring Security提供了@PreFilter注解,用于在执行方法之前过滤集合参数

@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
    return String.join(";", usernames);
}

在此示例中,我们拼接给定usernames集合中的所有字符串,但经过身份验证的username除外。

在我们的表达式中,我们使用名称filterObject来表示集合中的当前对象

但是,如果该方法有多个集合类型的参数,我们需要使用filterTarget属性来指定要过滤的参数:

@PreFilter(value = "filterObject != authentication.principal.username", filterTarget = "usernames")
public String joinUsernamesAndRoles(List<String> usernames, List<String> roles) {
    return String.join(";", usernames) + ":" + String.join(";", roles);
}

此外,我们还可以使用@PostFilter注解过滤方法返回的集合

@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
    return userRoleRepository.getAllUsernames();
}

在这种情况下,filterObject指的是方法返回的集合中的当前对象。

使用该配置,Spring Security将遍历返回的列表并删除与主体用户名匹配的任何值。

我们的Spring Security–@PreFilter和@PostFilter文章更详细地描述了这两个注解。

3.5 方法安全元注解

我们通常会面临使用相同的安全配置保护不同的方法的情况,这样很容易造成冗余。

在这种情况下,我们可以定义一个安全元注解:

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

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

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

使用自定义的元注解是个不错的选择,因为它们可以添加更多的安全配置语义,并将我们的业务逻辑与框架分离

3.6 类级别的安全注解

如果我们发现自己对一个类中的每个方法使用相同的安全注解,我们可以考虑将该注解放在类级别上:

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

    public String getSystemYear() {
        return "2022";
    }

    public String getSystemDate() {
        return "2022-06-12";
    }
}

在上面的示例中,安全规则hasRole(‘ROLE_ADMIN’)将同时作用于getSystemYear和getSystemDate方法。

3.7 方法上的多个安全注解

我们还可以在一个方法上使用多个安全注解:

@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

这样,Spring将在secureLoadUserDetail方法之前和之后验证授权。

4. 重要注意事项

关于方法安全性,我们需要强调两点:

  • 默认情况下,Spring AOP代理用于应用方法安全。如果同一类中的另一个方法调用了受保护的方法A,则A中的安全性将被完全忽略。这意味着方法A将在没有任何安全检查的情况下执行。这同样适用于私有方法。
  • Spring SecurityContext是线程绑定的。默认情况下,安全上下文不会传播到子线程。有关更多信息,请参阅我们的Spring Security Context Propagation文章。

5. 测试方法安全

5.1 配置

要使用JUnit测试Spring Security,我们需要spring-security-test依赖项

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
</dependency>

我们不需要指定依赖版本,因为我们使用的是Spring Boot插件。我们可以在Maven Central上找到这个依赖的最新版本。

接下来,让我们通过指定Extension和ApplicationContext配置来配置一个简单的Spring集成测试:

@ExtendWith(SpringExtension.class)
@ContextConfiguration
class MethodSecurityIntegrationTest {

    @Autowired
    UserRoleService userRoleService;

    @Configuration
    @ComponentScan("cn.tuyucheng.taketoday.methodsecurity.*")
    public static class SpringConfig {
    }
}

5.2 测试用户名和角色

现在我们的配置已经准备就绪,让我们尝试测试使用@Secured(“ROLE_VIEWER”)注解保护的getUsername方法:

@Secured("ROLE_VIEWER")
public String getUsername() {
    SecurityContext securityContext = SecurityContextHolder.getContext();
    return securityContext.getAuthentication().getName();
}

由于我们在这里使用了@Secured注解,因此需要对用户进行身份验证才能调用该方法。否则,我们将得到AuthenticationCredentialsNotFoundException。

因此,我们需要提供一个用户来测试我们的安全方法

为了实现这一点,我们使用@WithMockUser注解修饰测试方法,并提供username和roles

@Test
@WithMockUser(username = "john", roles = {"VIEWER"})
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 getUsernameInLowerCase(){
    return getUsername().toLowerCase();
}

我们可以使用权限进行测试:

@Test
@WithMockUser(username = "john", authorities = {"SYS_ADMIN"})
void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
    String username = userRoleService.getUsernameLC();
    assertEquals("john", username);
}

方便的是,如果我们想对多个测试用例使用同一个用户,我们可以在测试类上声明@WithMockUser注解

@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = {"VIEWER"})
class MethodSecurityIntegrationTest {
    // ...
}

如果我们想以匿名用户身份运行测试,我们可以使用@WithAnonymousUser注解

@Test
@WithAnonymousUser
void givenAnonymousUser_whenCallGetUsername_thenAccessDenied() {
    assertThrows(AccessDeniedException.class, userRoleService::getUsername);
}

在上面的示例中,我们期望测试抛出AccessDeniedException,因为匿名用户未被授予角色ROLE_VIEWER或权限SYS_ADMIN。

5.3 使用自定义UserDetailsService进行测试

对于大多数应用程序,通常使用自定义类作为身份验证主体(Principal)。在这种情况下,自定义类需要实现org.springframework.security.core.userdetails.UserDetails接口。

在本文中,我们声明一个CustomUser类,该类扩展了UserDetails的现有实现,即org.springframework.security.core.userdetails.User:

public class CustomUser extends User {
    private String nickName;

    public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }

    public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities, String nickName) {
        super(username, password, authorities);
        this.nickName = nickName;
    }
    // getter and setter ...
}

让我们回顾一下第3节中带有@PostAuthorize注解的示例:

@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

在这种情况下,只有当返回的CustomUser的username等于当前身份验证主体的nickName时,该方法才会成功执行。

如果我们想测试该方法,我们可以提供一个UserDetailsService的实现,它可以根据username加载我们的CustomUser

@ExtendWith(SpringExtension.class)
@ContextConfiguration
class UserDetailsIntegrationTest {

    @Autowired
    UserRoleService userService;

    @Configuration
    @ComponentScan("cn.tuyucheng.taketoday.methodsecurity.*")
    public static class SpringConfig {

    }

    @Test
    @WithUserDetails(value = "john", userDetailsServiceBeanName = "userDetailService")
    void whenJohn_callLoadUserDetail_thenOK() {
        CustomUser user = userService.loadUserDetail("jane");
        assertEquals("jane", user.getNickName());
    }
}

这里的@WithUserDetails注解表明我们将使用UserDetailsService来初始化经过身份验证的用户。userDetailService由userDetailsServiceBeanName属性引用。此UserDetailsService可以是真实的具体实现,也可以是用于测试目的而伪造的。

此外,userDetailService将使用注解value属性的值作为用户名来加载UserDetails。

方便的是,我们还可以在类级别使用@WithUserDetails注解进行修饰,类似于我们使用@WithMockUser一样。

5.4 使用元注解进行测试

我们经常面临着在各种测试中反复使用相同的用户/角色。对于这些情况,我们可以向之前那样创建自己的元注解。

再看前面的例子@WithMockUser(username=”john”, roles={“VIEWER”}),我们可以声明一个元注解:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer {
}

然后我们可以简单地在测试中使用@WithMockJohnViewer:

@Test
@WithMockJohnViewer
void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();
    assertEquals("john", userName);
}

同样,我们可以使用元注解来使用@WithUserDetails创建特定于域的用户。

6. 总结

在本文中,我们探讨了在Spring Security中使用方法安全性的各种选项。

我们还针对Spring Security中的测试进行了介绍,并学习了如何在不同的测试中重用模拟用户。

与往常一样,本教程的完整源代码可在GitHub 上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章