本文涵盖的内容:
- Spring 应用程序中的全局方法安全
- 基于授权,角色和权限对方法进行执行前的授权判断
- 基于授权,角色和权限对方法进行执行后的授权判断
到目前为止,我们讨论了配置身份认证的各种方法。我们从最简单的方法 HTTP Basic 开始,然后向您展示了如何设置表单登录。我们又讨论了 OAuth 2。但是在授权方面,我们只讨论了端点级别的配置。假设你的应用程序不是一个 web 应用程序——你不能同时使用 Spring Security 进行身份认证和授权吗? Spring Security 非常适合应用程序不通过 HTTP 端点使用的场景。在本文中,您将学习如何在方法级别配置授权。我们将使用这种方法在 web 和非 web 应用程序中配置授权,并将其称为全局方法安全 ( 图 1)。
图 1 全局方法安全使您能够在应用程序的任何层应用授权规则。这种方法允许您更细粒度地应用授权规则,并在特定选择的级别上应用授权规则。
对于非 web 应用程序,全局方法安全提供了实现授权规则的机会,即使我们没有端点。在 web 应用程序中,这种方法使我们能够灵活地在应用程序的不同层上应用授权规则,而不仅仅是在端点层。让我们深入本文,学习如何在具有全局方法安全的方法级应用授权。
1 启用全局方法安全
在本节中,您将了解如何在方法级别启用授权,以及 Spring Security 提供的用于应用各种授权规则的不同选项。这种方法为您应用授权提供了更大的灵活性。这是一项基本技能,可以帮助您解决不能仅在端点级别配置授权的情况。
默认情况下,全局方法安全是禁用的,所以如果您想要使用此功能,首先需要启用它。此外,全局方法安全提供了应用授权的多种方法。我们将在本文接下来的章节和《全局方法安全:执行前过滤和执行后过滤》中讨论这些方法,并通过示例实现它们。简单地说,你可以用全局方法安全做两件主要的事情:
调用授权 —— 决定某人是否可以根据某些已实现的权限规则(预授权)调用方法,或者某人是否可以访问方法执行后返回的内容(后授权)。
过滤 —— 确定方法可以通过其参数接收的内容(预过滤)以及调用者可以在方法执行后从方法接收的内容(后过滤)。 我们将在《全局方法安全:执行前过滤和执行后过滤》中讨论并实现过滤。
1.1 理解调用授权
配置用于全局方法安全的授权规则的一种方法是调用授权。调用授权方法指的是应用授权规则,这些授权规则决定是否可以调用某个方法,或者允许调用该方法,然后决定调用方是否可以访问该方法返回的值。我们经常需要决定是否有人可以根据提供的参数或其结果来访问逻辑片段。因此,让我们讨论调用授权,然后将其应用到一些示例中。
全局方法安全是如何工作的 ? 应用授权规则背后的机制是什么 ? 当我们在应用程序中启用全局方法安全时,我们实际上启用了 Spring 切面。这个切面拦截对我们应用授权规则的方法的调用,并基于这些授权规则决定是否将调用转发给被拦截的方法 ( 图 2 )。
图 2 当我们启用全局方法安全时,一个切面会拦截对受保护方法的调用。如果不遵守给定的授权规则,则切面不会将调用委托给受保护的方法。
Spring 框架中的许多实现都依赖于面向切面编程 ( AOP )。全局方法安全只是 Spring 应用程序中依赖切面的众多组件之一。如果你需要关于切面和 AOP 的复习,我推荐你阅读Clarence Ho 等人的《Pro Spring 5: Spring框架及其工具的深入指南》(Apress, 2017) 的第 5 章。简单地说,我们将调用授权分类为:
Preauthorization ( 预授权 ) —— 框架在方法调用之前检查授权规则。
Postauthorization ( 后授权 ) —— 框架在方法执行后检查授权规则。
让我们采用这两种方法,详细说明它们,并通过一些示例实现它们。
使用预授权来确保对方法的安全访问
假设我们有一个方法 findDocumentsByUser(String username),它返回特定用户的调用者文档。调用者通过方法的参数提供该方法检索文档的用户名。假设您需要确保经过身份认证的用户只能获得他们自己的文档。我们是否可以将规则应用于此方法,以便只允许接收已认证用户的用户名作为参数的方法调用》是的!这是我们预授权。
当我们应用授权规则完全禁止任何人在特定情况下调用方法时,我们称之为预授权 ( 图 3 )。这种方法意味着框架在执行方法之前验证授权条件。如果根据我们定义的授权规则,调用方没有权限,框架就不会将调用委托给方法。相反,框架抛出一个异常。这是迄今为止最常用的全局方法安全方法。
图 3 使用预授权,在进一步委托方法调用之前验证授权规则。如果不遵守授权规则,框架将不会委托调用,而是将异常抛出给方法调用者。
通常,如果不满足某些条件,我们根本不想执行某个功能。您可以根据已认证的用户应用条件,也可以引用该方法通过其参数接收到的值。
使用后授权保护方法调用
当我们应用授权规则,允许某人调用一个方法,但不一定获得该方法返回的结果时,我们使用的是后授权 ( 图 4 )。使用后授权,Spring Security 在方法执行后检查授权规则。可以使用这种授权在特定条件下限制对方法返回的访问。因为后授权发生在方法执行之后,所以可以对方法返回的结果应用授权规则。
图 4 使用后授权后,切面将调用委托给受保护的方法。在受保护的方法完成执行之后,切面将检查授权规则。如果不遵守规则,则切面会抛出异常,而不是将结果返回给调用者。
通常,我们使用后授权来根据方法执行后返回的内容应用授权规则。但是要小心后授权 !如果方法在执行期间发生了变化,那么无论最终授权是否成功,都将发生更改。
注意
即使使用 @Transactional 注解,如果后授权失败,更改也不会回滚。由后授权功能抛出的异常在事务管理器提交事务之后发生。
1.2 在项目中启用全局方法安全
在本节中,我们将研究一个应用全局方法安全提供的预授权和后授权特性的项目。在 Spring security 项目中,默认情况下不启用全局方法安全。要使用它,首先需要启用它。然而,启用这个功能是很简单的。只需在配置类上使用 @EnableGlobalMethodSecurity 注解就可以做到这一点。
我为这个例子创建了一个新项目。对于这个项目,我编写了一个 ProjectConfig 配置类,如清单 1 所示。在配置类上,我们添加了 @EnableGobalMethodSecurity 注解。全局方法安全为我们提供了三种方法来定义本文将要讨论的授权规则:
- preauthorization 和 postauthorization 注解
- JSR 250 注解 @RolesAllowed
- @Secured 注解
因为在几乎所有情况下,pre-/postauthorization 注解都是唯一使用的方法,所以我们将在本文中讨论这种方法。要启用这种方法,我们使用 @EnableGlobalMethodSecurity 注解的 prePostEnabled 属性。我们将在本文的最后对前面提到的另外两个选项进行简短的概述。
清单 1 启用全局方法安全
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
}
您可以将全局方法安全与任何身份认证方法一起使用,从 HTTP Basic 身份验证到 OAuth 2。为了保持简单并允许您关注新的细节,我们提供了 HTTP Basic 身份认证的全局方法安全。因此,本文中项目的 pom.xml 文件只需要 web 和 Spring Security 依赖项,如下面的代码片段所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
2 应用权限和角色预授权
在本节中,我们将实现一个预授权示例。对于我们的示例,我们继续使用 第 1 节中开始的项目。正如我们在第 1 节中讨论的,预授权意味着在调用特定方法之前定义 Spring Security 应用的授权规则。如果规则不被遵守,框架就不会调用该方法。
我们在本节中实现的应用程序有一个简单的场景。它公开一个端点 /hello,该端点返回字符串 "Hello, " 后面跟着一个名称。为了获得名称,控制器调用一个服务方法 ( 图 5 )。此方法应用预授权规则来验证用户是否具有写权限。
图 5 要调用 NameService 的 getName() 方法,经过身份认证的用户需要具有 write 权限。如果用户没有此权限,框架将不允许该调用并抛出异常。
我添加了一个 UserDetailsService 和一个 PasswordEncoder,以确保我有一些用户需要认证。为了验证我们的解决方案,我们需要两个用户:一个具有写权限,另一个没有写权限。我们证明第一个用户可以成功地调用端点,而对于第二个用户,应用程序在试图调用该方法时抛出一个授权异常。下面的清单显示了配置类的完整定义,它定义了 UserDetailsService 和 PasswordEncoder 。
清单 2 UserDetailsService 和 PasswordEncoder 的配置类
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) //为预/后授权启用全局方法安全
public class ProjectConfig {
//将一个 UserDetailsService 添加到带有两个用户用于测试的 Spring 上下文
@Bean
public UserDetailsService userDetailsService() {
var service = new InMemoryUserDetailsManager();
var u1 = User.withUsername("zhangsan")
.password("12345")
.authorities("read")
.build();
var u2 = User.withUsername("xiaohua")
.password("12345")
.authorities("write")
.build();
service.createUser(u1);
service.createUser(u2);
return service;
}
//向 Spring 上下文添加一个 PasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
为了定义此方法的授权规则,我们使用 @PreAuthorize 注解。@PreAuthorize 注解接收描述授权规则的 Spring Expression Language (SpEL) 表达式作为值。在这个例子中,我们应用了一个简单的规则。
可以使用 hasAuthority() 方法根据用户的权限为其定义限制。您已经在《配置授权:限制访问》中了解了 hasAuthority() 方法,其中我们讨论了在端点级别应用授权。下面的清单定义了服务类。
清单 3 服务类定义了方法的预授权规则
@Service
public class NameService {
//定义授权规则。只有具有写权限的用户才能调用该方法。
@PreAuthorize("hasAuthority('write')")
public String getName() {
return "Fantastico";
}
}
我们在下面的清单中定义了控制器类。它使用 NameService 作为一个依赖项。
清单 4 实现端点并使用服务的控制器类
@RestController
public class HelloController {
//从上下文注入服务
@Autowired
private NameService nameService;
@GetMapping("/hello")
public String hello() {
//调用我们为其应用预授权规则的方法
return "Hello, " + nameService.getName();
}
}
现在可以启动应用程序并测试其行为。我们希望只有用户 xiaohua 被授权调用端点,因为她有写授权。下一个代码片段显示了我们的两个用户 xiaohua 和 zhangsan 对端点的调用。要调用 /hello 端点并使用用户 xiaohua 进行身份认证,可以使用 cURL 命令:
curl -u xiaohua:12345 http://localhost:8080/hello
响应体:
Hello, Fantastico
要调用 /hello 端点并使用 zhangsan 用户进行身份验证,可以使用 cURL 命令:
curl -u zhangsan:12345 http://localhost:8080/hello
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello"
}
类似地,您可以使用我们在《配置授权:限制访问》中讨论的任何其他表达式来进行端点身份认证。下面是他们的一个简短总结:
- hasAnyAuthority() — 指定多个权限。用户必须至少拥有其中一个权限才能调用该方法。
- hasRole() — 指定用户必须有调用该方法的角色。
- hasAnyRole() — 指定多个角色。用户必须至少拥有其中一个才能调用该方法。
让我们扩展我们的示例,以证明如何使用方法参数的值来定义授权规则 ( 图 6 )。
图 6 在实现预授权时,可以使用授权规则中方法参数的值。在我们的示例中,只有经过身份认证的用户才能检索有关其秘钥名称的信息。
对于这个项目,我定义了与第一个示例相同的 ProjectConfig 类,以便我们可以继续与两个用户 xiaohua 和 zhangsan 一起工作。端点现在通过一个路径变量获取一个值,并调用一个服务类来获取给定用户名的 “secret names”。当然,在这种情况下,secret name 只是我的发明,指的是用户的一个特征,这不是每个人都能看到的。我在下一个清单中定义了控制器类。
清单 5 定义测试端点的控制器类
@RestController
public class HelloController {
//从上下文中注入定义受保护方法的服务类的实例
@Autowired
private NameService nameService;
//定义一个从路径变量中获取值的端点
@GetMapping("/secret/names/{name}")
public List<String> names(@PathVariable String name) {
//调用受保护的方法获取用户的 secret name
return nameService.getSecretNames(name);
}
}
现在让我们看看如何实现清单 6 中的 NameService 类。我们现在使用的授权表达式是 #name == authentication.principal.username 。在这个表达式中,我们使用 #name 来引用名为 name 的 getSecretNames() 方法参数的值,并且可以直接访问身份认证对象,我们可以使用该对象来引用当前经过身份认证的用户。我们使用的表达式表明,只有当验证用户的用户名与通过方法的参数发送的值相同时,才能调用该方法。换句话说,用户只能检索自己的 secret name。
清单 6 NameService 类定义了受保护的方法
@Service
public class NameService {
private Map<String, List<String>> secretNames =
Map.of(
"zhangsan", List.of("Energico", "Perfecto"),
"xiaohua", List.of("Fantastico"));
@PreAuthorize //使用 #name 表示授权表达式中方法参数的值
("#name == authentication.principal.username")
public List<String> getSecretNames(String name) {
return secretNames.get(name);
}
}
我们启动应用程序并测试它以证明它能按预期工作。下一个代码片段显示了应用程序在调用端点时的行为,提供了等于用户名的路径变量的值:
curl -u xiaohua:12345 http://localhost:8080/secret/names/xiaohua
响应体:
["Fantastico"]
在验证用户 xiaohua 时,我们试图获得 zhangsan 的 secret name。
curl -u xiaohua:12345 http://localhost:8080/secret/names/zhangsan
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/secret/names/zhangsan"
}
然而,用户 zhangsan 可以获得他自己的 secret name。下面的代码片段证明了这一点:
curl -u zhangsan:12345 http://localhost:8080/secret/names/zhangsan
响应体:
["Energico","Perfecto"]
注意
请记住,您可以将全局方法安全应用于应用程序的任何层。在本章给出的示例中,您可以找到应用于服务类方法的授权规则。但是您可以在应用程序的任何部分应用具有全局方法安全性的授权规则:仓储、管理器、代理等等。
3 应用后授权
现在,假设您希望允许对某个方法的调用,但在某些情况下,您希望确保调用者没有收到返回的值。当我们想应用在方法调用之后验证的授权规则时,我们使用后授权。一开始可能听起来有点尴尬:为什么有人能够执行代码,但却不能得到结果 ?它不是关于方法本身,而是想象这个方法从一个数据源检索一些数据,比如一个web服务或一个数据库。您可以确信您的方法可以做什么,但是您不能把赌注押在您的方法调用的第三方身上。因此,您允许该方法执行,但您验证它返回的内容,如果它不满足条件,则不让调用者访问返回值。
要在 Spring Security 中应用后授权规则,我们使用 @PostAuthorize 注解,它类似于第 2 节中讨论的 @PreAuthorize 。该注解将被定义授权规则的 SpEL 作为值接收。我们继续使用一个示例,在该示例中您将学习如何使用 @PostAuthorize 注解并为方法定义后授权规则 ( 图 7 )。
图 7 使用后授权,我们不保护方法不被调用,但是如果定义的授权规则不被遵守,我们保护返回值不被公开。
在我们的示例中,该场景定义了一个对象 Employee。我们的Employee 有一个名称,书本列表,权限列表。我们将每个 Employee 关联到应用程序的一个用户。为了与本章中的其他示例保持一致,我们定义了相同的用户,Xiaohua 和 Zhangsan。我们希望确保只有当员工具有读取权限时,方法的调用方才能获得该员工的详细信息。因为在检索记录之前,我们不知道与员工记录关联的权限,所以我们需要在方法执行之后应用授权规则。因此,我们使用 @PostAuthorize 注解。
配置类与我们在前面的示例中使用的相同。但是,为了您的方便,我在下一个清单中重复它。
清单 7 启用全局方法安全并定义用户
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService() {
var service = new InMemoryUserDetailsManager();
var u1 = User.withUsername("zhangsan")
.password("12345")
.authorities("read")
.build();
var u2 = User.withUsername("xiaohua")
.password("12345")
.authorities("write")
.build();
service.createUser(u1);
service.createUser(u2);
return service;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
我们还需要声明一个类来表示 Employee 对象及其名称、图书列表和角色列表。下面的清单定义了 Employee 类。
清单 8 Employee 类的定义
public class Employee {
private String name;
private List<String> books;
private List<String> roles;
// Omitted constructor, getters, and setters
}
我们可能从数据库中获取员工的详细信息。为了使我们的示例更简短,我使用了一个 Map,其中包含一些我们认为是数据源的记录。在清单 9 中,您可以找到 BookService 类的定义。BookService 类还包含我们为其应用授权规则的方法。注意,我们在 @PostAuthorize 注解中使用的表达式引用了方法 returnObject 返回的值。后授权表达式可以使用该方法返回的值,该值在该方法执行后可用。
清单 9 定义授权方法的 BookService 类
@Service
public class BookService {
private Map<String, Employee> records =
Map.of("xiaohua",
new Employee("Xiaohua Thompson",
List.of("Karamazov Brothers"),
List.of("accountant", "reader")),
"zhangsan",
new Employee("Zhangsan Parker",
List.of("Beautiful Paris"),
List.of("researcher"))
);
//定义后授权的表达式
@PostAuthorize("returnObject.roles.contains('reader')")
public Employee getBookDetails(String name) {
return records.get(name);
}
}
让我们还编写一个控制器并实现一个端点来调用我们为其应用授权规则的方法。下面的清单展示了这个控制器类。
清单 10 实现端点的控制器类
@RestController
public class BookController {
@Autowired
private BookService bookService;
@GetMapping("/book/details/{name}")
public Employee getDetails(@PathVariable String name) {
return bookService.getBookDetails(name);
}
}
现在可以启动应用程序并调用端点来观察应用程序的行为。在下面的代码片段中,您将看到调用端点的示例。任何用户都可以访问 Xiaohua 的详细信息,因为返回的角色列表包含字符串 “reader”,但没有用户可以获得 Zhangsan 的详细信息。调用端点以获取 Xiaohua 的详细信息并与用户 Xiaohua 进行身份认证,我们使用以下命令:
curl -u xiaohua:12345 http://localhost:8080/book/details/xiaohua
响应体:
{
"name":"Xiaohua Thompson",
"books":["Karamazov Brothers"],
"roles":["accountant","reader"]
}
调用端点以获取 Xiaohua 的详细信息并与 Zhangsan 用户进行身份认证,我们使用以下命令:
curl -u zhangsan:12345 http://localhost:8080/book/details/xiaohua
响应体:
{
"name":"Xiaohua Thompson",
"books":["Karamazov Brothers"],
"roles":["accountant","reader"]
}
调用端点以获取 Zhangsan 的详细信息并与用户 Xiaohua 进行身份认证,我们使用以下命令:
curl -u xiaohua:12345 http://localhost:8080/book/details/zhangsan
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/book/details/zhangsan"
}
调用端点以获取 Zhangsan 的详细信息并与 Zhangsan 用户进行身份验证,我们使用以下命令:
curl -u zhangsan:12345 http://localhost:8080/book/details/zhangsan
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/book/details/natalie"
}
注意
如果您的需求需要同时具有预授权和后授权,则可以在同一方法上同时使用 @PreAuthorize 和 @PostAuthorize。
4 为方法实现权限
到目前为止,您已经学习了如何用简单的表达式定义用于预授权和后授权的规则。现在,让我们假设授权逻辑更加复杂,您不能在一行中编写它。编写巨大的 SpEL 表达式绝对不舒服。我从不建议在任何情况下使用长 SpEL 表达式,无论它是否是授权规则。它只是创建了难以阅读的代码,这影响了应用程序的可维护性。当您需要实现复杂的授权规则时,与其编写冗长的 SpEL 表达式,不如将逻辑放在单独的类中。Spring Security 提供了权限的概念,这使得在单独的类中编写授权规则变得容易,从而使您的应用程序更易于阅读和理解。
在本节中,我们将在项目中使用权限应用授权规则。 在这个场景中,您有一个管理文档的应用程序。任何文档都有一个所有者,即创建文档的用户。要获得现有文档的详细信息,用户要么是管理员,要么是文档的所有者。我们实现了一个权限评估器来解决这个需求。下面的清单定义了 Document,它只是一个普通的 Java 对象。
清单 11 Document 类
public class Document {
private String owner;
// Omitted constructor, getters, and setters
}
为了模拟数据库并简化示例,我创建了一个仓储类,用于管理 Map 中的几个文档实例。在下一个清单中可以找到这个类。
清单 12 管理一些 Document 实例的 DocumentRepository 类
@Repository
public class DocumentRepository {
//用唯一的码标识每个文档并命名所有者
private Map<String, Document> documents =
Map.of("abc123", new Document("zhangsan"),
"qwe123", new Document("zhangsan"),
"asd555", new Document("xiaohua"));
//使用文档的唯一标识码获取文档
public Document findDocument(String code) {
return documents.get(code);
}
}
服务类定义了一个方法,该方法使用仓储通过其标识码获取文档。服务类中的方法是我们应用授权规则的方法。这个类的逻辑很简单。它定义了一个方法,该方法通过其唯一的标识码返回 Document。我们使用 @PostAuthorize 来注解这个方法,并使用 hasPermission() SpEL 表达式。此方法允许我们引用在本例中进一步实现的外部授权表达式。同时,请注意我们提供给 hasPermission() 方法的参数是 returnObject,它代表该方法返回的值,以及我们允许访问的角色的名称,它是 'ROLE_admin'。您可以在下面的清单中找到这个类的定义。
清单 13 实现受保护方法的 DocumentService 类
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
@PostAuthorize
("hasPermission(returnObject, 'ROLE_admin')") //使用 hasPermission() 表达式引用授权表达式
public Document getDocument(String code) {
return documentRepository.findDocument(code);
}
}
实现权限逻辑是我们的职责。我们通过编写一个实现 PermissionEvaluator 接口的对象来做到这一点。 PermissionEvaluator 接口提供了两种方法来实现权限逻辑:
- 通过对象和权限 -- 在当前示例中,它假定权限求值器接收两个对象:一个受授权规则约束,另一个提供实现权限逻辑所需的额外详细信息。
- 通过对象ID、对象类型和权限 -- 假设权限求值器接收到一个对象ID,它可以使用该ID来检索所需的对象。它还接收一个对象类型,如果相同的权限求值器应用于多个对象类型,则可以使用该对象类型,并且它需要一个对象来提供评估权限的额外详细信息。
在下一个清单中,您会发现 PermissionEvaluator 接口有两个方法。
清单 14 PermissionEvaluator 接口定义
public interface PermissionEvaluator {
boolean hasPermission(
Authentication a,
Object subject,
Object permission);
boolean hasPermission(
Authentication a,
Serializable id,
String type,
Object permission);
}
对于当前的示例,使用第一种方法就足够了。我们已经有了主体 ( subject ),在我们的例子中,它是方法返回的值。我们还发送角色名 ‘ROLE_admin’,正如示例场景中定义的那样,它可以访问任何文档。当然,在我们的示例中,我们可以直接使用权限求值器类中的角色名,并避免将其作为 hasPermission() 对象的值发送。在这里,为了便于示例,我们只做前者。在实际场景中(可能更复杂),您有多个方法,授权过程中所需的参数可能各不相同。因此,您有一个参数,可以从方法级别发送在授权逻辑中使用所需的详细信息。
为了让您了解并避免混淆,我还想指出,您不必传递 Authentication 对象。Spring Security 在调用 hasPermission() 方法时自动提供这个参数值。框架知道身份认证实例的值,因为它已经在 SecurityContext 中了。在清单 15 中,您可以找到 DocumentsPermissionEvaluator 类,它在我们的示例中实现了 PermissionEvaluator 接口来定义自定义授权规则。
清单 15 实现授权规则
@Component
public class DocumentsPermissionEvaluator
implements PermissionEvaluator { //实现 PermissionEvaluator 接口
@Override
public boolean hasPermission(
Authentication authentication,
Object target,
Object permission) {
//将目标对象强制转换为 Document
Document document = (Document) target;
//在本例中,权限对象是角色名,因此我们将其转换为 String。
String p = (String) permission;
//检查身份认证用户是否具有作为参数获得的角色
boolean admin =
authentication.getAuthorities()
.stream()
.anyMatch(a -> a.getAuthority().equals(p));
//如果 admin 或经过验证的用户是文档的所有者,则授予该权限
return admin ||
document.getOwner()
.equals(authentication.getName());
}
@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
//我们不需要实现第二种方法,因为我们不使用它。
return false;
}
}
为了让 Spring Security 意识到我们新的 PermissionEvaluator 实现,我们必须在配置类中定义一个 MethodSecurityExpressionHandler 。下面的清单展示了如何定义 MethodSecurityExpressionHandler 来使自定义 PermissionEvaluator 让人知道。
清单 16 在配置类中配置 PermissionEvaluator
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig
extends GlobalMethodSecurityConfiguration {
@Autowired
private DocumentsPermissionEvaluator evaluator;
//重写 createExpressionHandler() 方法
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
var expressionHandler = //定义默认安全表达式处理程序以设置自定义权限求值器
new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(
evaluator);//设置自定义权限求值器
// 返回自定义表达式处理程序
return expressionHandler;
}
// Omitted definition of the UserDetailsService and PasswordEncoder beans
}
注意
我们在这里使用 Spring Security 提供的名为 DefaultMethodSecurityExpressionHandler 的 MethodSecurityExpressionHandler 的实现。 您也可以实现自定义 MethodSecurityExpressionHandler 来定义用于应用授权规则的自定义 SpEL 表达式。 在实际情况下,您几乎不需要这样做,因此,在示例中,我们将不会实现这样的自定义对象。 我只是想让您知道这是可能的。
我将 UserDetailsService 和 PasswordEncoder 的定义分开,以使您仅关注新代码。 在清单 17 中,您可以找到其余的配置类。 关于用户的唯一重要的注意事项是他们的角色。 用户 Zhangsan 是 admin ,可以访问任何文档。 用户 Xiaohua 是 manager ,只能访问自己的文档。
清单 17 配置类的完整定义
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig
extends GlobalMethodSecurityConfiguration {
@Autowired
private DocumentsPermissionEvaluator evaluator;
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
var expressionHandler =
new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(evaluator);
return expressionHandler;
}
@Bean
public UserDetailsService userDetailsService() {
var service = new InMemoryUserDetailsManager();
var u1 = User.withUsername("zhangsan")
.password("12345")
.roles("admin")
.build();
var u2 = User.withUsername("xiaohua")
.password("12345")
.roles("manager")
.build();
service.createUser(u1);
service.createUser(u2);
return service;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
为了测试应用程序,我们定义了一个端点。 以下清单显示了此定义。
清单 18 定义控制器类并实现端点
@RestController
public class DocumentController {
@Autowired
private DocumentService documentService;
@GetMapping("/documents/{code}")
public Document getDetails(@PathVariable String code) {
return documentService.getDocument(code);
}
}
让我们运行应用程序并调用端点来观察其行为。Zhangsan 用户可以访问这些文件,不管它们的所有者是谁。用户 Xiaohua 只能访问她拥有的文件。调用属于 Zhangsan 的文档的端点,并使用用户 “Zhangsan ” 进行身份认证,我们使用以下命令:
curl -u zhangsan:12345 http://localhost:8080/documents/abc123
响应体:
{
"owner":"zhangsan"
}
调用属于 Xiaohua 的文档的端点,并使用用户 “zhangsan ” 进行身份认证,我们使用以下命令:
curl -u zhangsan:12345 http://localhost:8080/documents/asd555
响应体:
{
"owner":"xiaohua"
}
调用属于Xiaohua 的文档端点,并使用用户“Xiaohua ”进行身认验证,我们使用以下命令:
curl -u xiaohua:12345 http://localhost:8080/documents/asd555
响应体:
{
"owner":"xiaohua"
}
调用属于 Zhangsan 的文档的端点,并使用用户 “xiaohua” 进行身份认证,我们使用以下命令:
curl -u xiaohua:12345 http://localhost:8080/documents/abc123
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/documents/abc123"
}
以类似的方式,您可以使用第二个 PermissionEvaluator 方法来编写您的授权表达式。第二个方法指的是使用标识符和主题 ( subject ) 类型而不是对象本身。例如,假设我们希望更改当前示例,以便在执行方法之前应用授权规则,使用 @PreAuthorize。在本例中,我们还没有返回的对象。但不是对象本身,我们有文档的代码,这是它的唯一标识符。清单 19 向您展示了如何更改权限求值器类来实现此场景。
清单 19 DocumentsPermissionEvaluator 类的更改
@Component
public class DocumentsPermissionEvaluator
implements PermissionEvaluator {
@Autowired
private DocumentRepository documentRepository;
@Override
public boolean hasPermission(Authentication authentication,
Object target,
Object permission) {
//不再通过第一个方法定义授权规则。
return false;
}
@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
//我们拥有的不是对象,而是它的 ID,我们使用 ID 获取对象。
String code = targetId.toString();
Document document = documentRepository.findDocument(code);
String p = (String) permission;
//检查该用户是否为 admin
boolean admin =
authentication.getAuthorities()
.stream()
.anyMatch(a -> a.getAuthority().equals(p));
//如果用户是文档的 admin 或所有者,则用户可以访问文档。
return admin ||
document.getOwner().equals(
authentication.getName());
}
}
当然,我们还需要使用 @PreAuthorize 注解正确调用权限求值器。在下面的清单中,您将看到我在 DocumentService 类中所做的更改,以便用新方法应用授权规则。
清单 20 DocumentService 类
@Service
public class DocumentService {
@Autowired
private DocumentRepository documentRepository;
//通过使用权限求值器的第二种方法应用预授权规则
@PreAuthorize
("hasPermission(#code, 'document', 'ROLE_admin')")
public Document getDocument(String code) {
return documentRepository.findDocument(code);
}
}
您可以重新运行应用程序并检查端点的行为。您应该看到与使用权限求值器的第一个方法实现授权规则的情况相同的结果。用户 Zhangsan 是管理员,可以访问任何文档的详细信息,而用户 Xiaohua 只能访问她拥有的文档。调用属于 Zhangsan 的文档的端点,并使用用户“Zhangsan ”进行身份认证,我们发出以下命令:
curl -u zhangsan:12345 http://localhost:8080/documents/abc123
响应体:
{
"owner":"zhangsan"
}
调用属于Xiaohua 的文档的端点,并使用用户“Zhangsan ”进行身认验证,我们发出以下命令:
curl -u zhangsan:12345 http://localhost:8080/documents/asd555
响应体:
{
"owner":"xiaohua"
}
调用属于Xiaohua 的文档的端点,并使用用户“Xiaohua ”进行身认验证,我们发出以下命令:
curl -u xiaohua:12345 http://localhost:8080/documents/asd555
响应体:
{
"owner":"xiaohua"
}
调用属于Zhangsan 的文档的端点,并使用用户“Xiaohua ”进行身份验证,我们发出以下命令:
curl -u xiaohua:12345 http://localhost:8080/documents/abc123
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/documents/abc123"
}
使用 @Secured 和 @RolesAllowed 注解
在本章中,我们讨论了如何应用具有全局方法安全的授权规则。我们首先了解了这个功能在默认情况下是禁用的,您可以在配置类上使用 @EnableGlobalMethodSecurity 注解来启用它。此外,必须使用 @EnableGlobalMethodSecurity 注解的属性指定应用授权规则的特定方式。我们这样使用注解:
@EnableGlobalMethodSecurity(prePostEnabled = true)
prePostEnabled 属性启用 @PreAuthorize 和 @PostAuthorize 注解来指定授权规则。 @EnableGlobalMethodSecurity 注解提供了另外两个类似的属性,您可以使用它们来启用不同的注解。使用 jsr250Enabled 属性来启用 @RolesAllowed 注解,使用 securedEnabled 属性来启用 @Secured 注解。使用 @Secured和 @RolesAllowed 这两种注解的功能不如使用 @PreAuthorize 和 @PostAuthorize 强大,在实际场景中使用它们的可能性很小。尽管如此,我还是想让你们了解这两种情况,但我不想在细节上花太多时间。
通过将 @EnableGlobalMethodSecurity 的属性设置为 true,可以使用与预授权和后授权相同的方式使用这些注解。您可以启用表示使用一种注解 ( @Secure或 @RolesAllowed) 的属性。你可以在下一个代码片段中找到如何做到这一点的示例:
@EnableGlobalMethodSecurity(
jsr250Enabled = true,
securedEnabled = true
)
一旦启用了这些属性,就可以使用 @RolesAllowed 或 @Secured 注解来指定登录用户需要调用某个方法的哪些角色或权限。下面的代码片段展示了如何使用 @RolesAllowed 注解来指定只有拥有 ADMIN 角色的用户才能调用 getName() 方法:
@Service
public class NameService {
@RolesAllowed("ROLE_ADMIN")
public String getName() {
return "Fantastico";
}
}
类似地,您可以使用 @Secured 注解而不是 @RolesAllowed 注解,如下面的代码片段所示:
@Service
public class NameService {
@Secured("ROLE_ADMIN")
public String getName() {
return "Fantastico";
}
}
现在可以测试示例了。下面的代码片段展示了如何做到这一点:
curl -u xiaohua:12345 http://localhost:8080/hello
响应体:
Hello, Fantastico
要调用端点并与用户 Zhangsan 进行身份认证,使用以下命令:
curl -u zhangsan:12345 http://localhost:8080/hello
响应体:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello"
}
总结
- Spring Security 允许您为应用程序的任何层应用授权规则,而不仅仅是在端点层。为此,您可以启用全局方法安全功能。
- 默认情况下,全局方法安全功能是禁用的。要启用它,可以在应用程序的配置类上使用@EnableGlobalMethodSecurity 注解。
- 您可以应用应用程序在调用方法之前检查的授权规则。如果不遵循这些授权规则,框架就不允许执行该方法。当我们在方法调用之前测试授权规则时,我们使用的是预授权。
- 要实现预授权,可以使用带有定义授权规则的 SpEL 表达式值的 @PreAuthorize 注解。
- 如果我们只想在方法调用之后决定调用者是否可以使用返回值,执行流是否可以继续,那么我们可以使用后授权。
- 要实现后授权,我们使用带有 SpEL 表达式值的 @PostAuthorize 注解,该表达式表示授权规则。
- 在实现复杂的授权逻辑时,应该将此逻辑分离到另一个类中,以使代码更易于阅读。在 Spring Security 中,一种常见的方法是实现 PermissionEvaluator。
- Spring Security 提供了与旧规范的兼容性,比如 @RolesAllowed 和 @Secured 注解。您可以使用这些注解,但它们的功能不如 @PreAuthorize 和 @PostAuthorize 强大,并且您在现实场景中发现这些注解与 Spring 一起使用的可能性非常低。