Spring 方法安全介绍
最后更新:2024 年 1 月 23 日
1. 概述
简单来说,Spring Security 支持方法级别的授权语义。
通常,我们可以通过例如限制哪些角色能够执行特定方法来保护我们的服务层——并使用专门的方法级别安全测试支持进行测试。
在本教程中,我们将回顾一些安全注解的使用。然后我们将专注于使用不同的策略测试我们的方法安全。
更多阅读
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 注解。
- securedEnabled 属性确定是否应启用 @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("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 表达式语言) 编写谓词。
@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) {
//...
}
此外,我们实际上可以使用方法参数作为表达式的一部分
@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);
}
这里,loadUserDetail 方法只有当返回的 CustomUser 的 username 等于当前身份验证主体的 nickname 时才能成功执行。
在本节中,我们主要使用简单的 Spring 表达式。 对于更复杂的场景,我们可以创建 自定义安全表达式。
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 将迭代返回的列表并删除与主体用户名匹配的任何值。
我们的 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(){
//...
}
public String getSystemDate(){
//...
}
}
在上面的例子中,安全规则 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 将在 securedLoadUserDetail 方法执行之前和之后都验证授权。
4. 重要注意事项
关于方法安全,我们想回顾两点
- 默认情况下,Spring AOP 代理用于应用方法安全。 如果一个受保护的方法 A 被同一类中的另一个方法调用,则 A 中的安全性将完全被忽略。 这意味着方法 A 将在没有任何安全检查的情况下执行。 同样适用于私有方法。
- Spring SecurityContext 是线程绑定的。 默认情况下,安全上下文不会传播到子线程。有关更多信息,请参阅我们的 Spring Security 上下文传播 文章。
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 上找到此依赖项的最新版本。
接下来,我们通过指定 runner 和 ApplicationContext 配置来配置一个简单的 Spring 集成测试
@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。如果未指定 username 或 role,则默认 username 为 user,默认 role 为 ROLE_USER。
请注意,这里不需要添加 ROLE_ 前缀,因为 Spring Security 会自动添加该前缀。
如果我们不想使用该前缀,可以考虑使用 authority 代替 role。
例如,让我们声明一个 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 MockUserAtClassLevelIntegrationTest {
//...
}
如果我们要以匿名用户身份运行测试,可以使用 @WithAnonymousUser 注解:
@Test(expected = AccessDeniedException.class)
@WithAnonymousUser
public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
userRoleService.getUsername();
}
在上面的示例中,我们预计会收到 AccessDeniedException ,因为匿名用户未被授予 ROLE_VIEWER 角色或 SYS_ADMIN 权限。
5.3. 使用自定义 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 的 username 等于当前身份验证主体的 nickname 时才会成功执行。
如果我们要测试该方法,我们可以提供一个 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 可能是真实实现或用于测试目的的模拟实现。
此外,该服务将使用属性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
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
String userName = userRoleService.getUsername();
assertEquals("john", userName);
}
同样,我们可以使用元注解来创建特定于领域的用户,使用@WithUserDetails。
6. 结论
在本文中,我们探讨了在 Spring Security 中使用方法安全性的各种选项。
我们还介绍了一些易于测试方法安全性的技术,并学习了如何在不同的测试中重用模拟用户。
支持本文的代码可在 GitHub 上获取。 一旦你以 Baeldung Pro 会员 身份登录,就开始学习并在项目上进行编码。















