Spring Security in Action 第十六章 SpringSecurity全局方法安全:预先和事后授权

本专栏将从基础开始,循序渐进,以实战为线索,逐步深入SpringSecurity相关知识相关知识,打造完整的SpringSecurity学习步骤,提升工程化编码能力和思维能力,写出高质量代码。希望大家都能够从中有所收获,也请大家多多支持。
专栏地址:SpringSecurity专栏
本文涉及的代码都已放在gitee上:gitee地址
如果文章知识点有错误的地方,请指正!大家一起学习,一起进步。
专栏汇总:专栏汇总


本章包括

  • Spring应用程序中的全局方法安全
  • 基于授权、角色和权限的方法的预授权
  • 基于授权、角色和权限的方法的后授权

到现在为止,我们讨论了配置认证的各种方法。我们在第2章中从最直接的方法–HTTP Basic开始,然后我在第5章中向你展示了如何设置表单登录。我们在第12章到第15章中讨论了OAuth 2。但在授权方面,我们只讨论了api层面的配置。假设你的应用不是Web应用–你不能也使用Spring Security进行认证和授权吗?Spring Security很适合那些不通过HTTP api使用你的应用程序的场景。在本章中,你将学习如何在方法层面配置授权。我们将使用这种方法来配置Web和非Web应用程序中的授权,我们将称之为全局方法安全(图16.1)。

image-20230130224257981

图16.1 全局方法安全使你能够在应用程序的任何一层应用授权规则。这种方法允许你更加细化,在具体选择的层面上应用授权规则。

对于非网络应用,全局方法安全提供了一个机会,即使我们没有api,也可以实施授权规则。在Web应用程序中,这种方法使我们能够灵活地在应用程序的不同层面上应用授权规则,而不仅仅是在api层面。让我们深入了解这一章,并学习如何在方法层面应用全局方法安全的授权。

16.1 启用全局方法安全

在本节中,你将学习如何在方法级别上启用授权,以及Spring Security提供的应用各种授权规则的不同选项。这种方法为你提供了应用授权的更大灵活性。这是一项重要的技能,可以让你解决那些根本无法在api级别配置授权的情况。

默认情况下,全局方法安全是禁用的,所以如果你想使用这个功能,你首先需要启用它。另外,全局方法安全为应用授权提供了多种方法。我们讨论了这些方法,然后在本章的下面几节和第17章的例子中实现它们。简而言之,你可以用全局方法安全做两件大事。

  • Call authorization—决定某人是否可以根据一些已实现的权限规则调用一个方法(预授权),或者某人是否可以在方法执行后访问该方法返回的内容(后授权)。
  • Filtering—决定一个方法可以通过它的参数接收什么(prefiltering),以及方法执行后调用者可以从方法中接收什么(postfiltering)。我们将在第17章中讨论并实现过滤功能。

16.1.1 了解Call authorization

配置授权规则的方法之一是Call authorization,你可以在全局方法安全中使用。Call authorization方法是指应用授权规则来决定一个方法是否可以被调用,或者允许方法被调用,然后决定调用者是否可以访问该方法返回的值。通常,我们需要根据所提供的参数或其结果来决定某人是否可以访问一段逻辑。因此,让我们讨论一下调用授权,然后将其应用于一些例子。

全局方法安全是如何运作的?应用授权规则背后的机制是什么?当我们在应用程序中启用全局方法安全时,我们实际上启用了一个Spring切面。这个切面拦截我们应用了授权规则的方法的调用,并根据这些授权规则决定是否将调用转发给被拦截的方法(图16.2)。

image-20230131092749322

图16.2 当我们启用全局方法安全时,一个方面会拦截对受保护方法的调用。如果给定的授权规则没有得到遵守,该aspect就不会委托调用受保护的方法。

Spring框架中的许多实现都依赖于面向切面编程(AOP)。全局方法安全只是Spring应用程序中依赖方面的众多组件之一。

如果你需要复习一下aspect和AOP,我推荐你阅读Clarence Ho等人写的《Pro Spring 5: An In-Depth Guide to the Spring Frame- work and Its Tools》(Apress,2017)的第五章。简而言之,我们将调用授权分类为:

  • Preauthorization—该框架在方法调用前检查授权规则。
  • Postauthorization—该框架在方法执行后检查授权规则。

让我们采取这两种方法,详细说明,并通过一些例子来实现它们。

使用PREAUTHORIZATION以确保获得方法

假设我们有一个方法findDocumentsByUser(String username),它返回给调用者一个特定用户的文档。调用者通过该方法的参数提供用户的名字,该方法为其检索文档。假设你需要确保被认证的用户只能获得他们自己的文档。我们是否可以对这个方法应用一个规则,使其只允许接收认证用户的用户名作为参数的方法调用?是的!这就是我们在处理文件时要做的事情。这就是我们在预授权中所做的一些事情。

当我们应用授权规则,完全禁止任何人在特定情况下调用某个方法时,我们称之为preauthorization(图16.3)。这种方法意味着框架在执行该方法之前会验证授权条件。如果调用者不具备我们定义的授权规则所规定的权限,框架就不会将调用委托给该方法。相反,框架会抛出一个异常。这是迄今为止最常使用的全局方法安全方法。

image-20230131093723131

图16.3 通过预授权,在进一步委托方法调用之前,会对授权规则进行验证。如果授权规则没有得到遵守,框架就不会委托调用,而是向方法调用者抛出一个异常。

通常情况下,如果某些条件没有得到满足,我们根本不希望一个功能被执行。你可以根据认证的用户来应用条件,你也可以参考方法通过其参数收到的值。

使用POSTAUTHORIZATION来保证方法调用的安全

当我们应用授权规则,允许某人调用一个方法,但不一定要获得该方法返回的结果时,我们就会使用后授权(图16.4)。通过后授权,Spring Security在方法执行后检查授权规则。你可以使用这种授权来限制在某些条件下对方法返回的访问。因为后授权发生在方法执行之后,你可以在方法返回的结果上应用授权规则。

image-20230131094320661

图 16.4 通过后授权,方面委托调用受保护的方法。在受保护的方法执行完毕后,该切面会检查授权规则。如果规则没有被遵守,方面不会把结果返回给调用者,而是抛出一个异常。

通常情况下,我们使用POSTAUTHORIZATION来根据方法执行后返回的内容应用授权规则。

即使有@Transactional注解,如果后授权失败,更改也不会被回滚。后授权功能抛出的异常发生在事务管理器提交行为之后。

16.1.2 在你的项目中启用全局方法安全

在本节中,我们将在一个项目中应用全局方法安全所提供的预授权和后授权功能。在Spring Security项目中,全局方法安全默认是不启用的。要使用它,你需要首先启用它。然而,启用这一功能是很简单的。你只需在配置类上使用@EnableGlobalMethodSecurity注解就可以做到这一点。

我为这个例子创建了一个新的项目,对于这个项目,我写了一个ProjectConfig配置类,如清单16.1所示。在这个配置类上,我们添加了@EnableGobalMethodSecurity注解。全局方法安全为我们提供了三种方法来定义本章中讨论的授权规则:

  • 授权前/授权后的注解
  • JSR 250注解,@RolesAllowed
  • @Secured注解

代码清单 16.1 Enabling global method security

因为在几乎所有的情况下,授权前/授权后注解是唯一使用的方法,所以我们在本章讨论这种方法。为了启用这种方法,我们使用@EnableGlobalMethodSecurity注解的prePostEnabled属性。 我们在本章末尾对之前提到的另外两种方法进行了简短的概述。

package com.laurentiuspilca.ssia.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
    
    

}

你可以在任何认证方式下使用全局方法安全,从HTTP Basic认证到OAuth 2。为了保持简单,让你专注于新的细节,我们提供HTTP Basic认证的全局方法安全。出于这个原因,本章项目的pom.xml文件只需要web和Spring Security的依赖,正如下一个代码片段所呈现的那样。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.12.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.laurentiuspilca</groupId>
    <artifactId>ssia-ch16-ex1</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ssia-ch16-ex1</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <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>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

16.2 对权限和角色适用预授权

在本节中,我们实现了一个预授权的例子。正如我们在第16.1节中所讨论的,预授权意味着定义授权规则,Spring Security会在调用特定方法之前应用这些规则。如果这些规则没有被遵守,框架就不会调用该方法。

我们在本节中实现的应用程序有一个简单的场景。它暴露了一个api,/hello,它返回字符串 "Hello, " 后面跟着一个名字。为了获得这个名字,控制器调用一个service方法(图16.5)。这个方法应用一个预授权规则来验证用户是否有写入权限。

image-20230131100511198

图16.5 要调用NameService的getName()方法,被认证的用户需要有写入权限。如果用户没有这个权限,框架将不允许调用并抛出一个异常。

我添加了一个UserDetailsService和一个PasswordEncoder,以确保我有一些用户可以认证。为了验证我们的解决方案,我们需要两个用户:一个有写入权限的用户和另一个没有写入权限的用户。我们证明第一个用户可以成功调用api,而对于第二个用户,应用程序在试图调用该方法时抛出一个授权异常。下面的列表显示了配置类的完整定义,它定义了UserDetailsService和PasswordEncoder。

清单16.2 UserDetailsService和PasswordEncoder的配置类

package com.laurentiuspilca.ssia.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
//启用全局性的方法安全,进行事前/事后授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
    
    

    //在Spring上下文中添加一个带有两个用户的UserDetailsService进行测试。
    @Bean
    public UserDetailsService userDetailsService() {
    
    
        InMemoryUserDetailsManager service = new InMemoryUserDetailsManager();

        UserDetails u1 = User.withUsername("natalie")
                .password("12345")
                .authorities("read")
                .build();

        UserDetails u2 = User.withUsername("emma")
                .password("12345")
                .authorities("write")
                .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    //在Spring上下文中添加一个密码编码器。
    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return NoOpPasswordEncoder.getInstance();
    }
}

为了定义这个方法的授权规则,我们使用@PreAuthorize注解。@PreAuthorize注解接收一个描述授权规则的Spring Expression Language(SpEL)表达式作为值。在这个例子中,我们应用一个简单的规则。

你可以使用hasAuthority()方法根据用户的权限来定义限制。你在第7章中了解了hasAuthority()方法,在该章中我们讨论了在api级别应用授权。下面的列表定义了服务类,它提供了名称的值。

清单16.3 service类定义了方法上的预授权规则

package com.laurentiuspilca.ssia.services;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class NameService {
    
    

    //定义了授权规则,只有具有写入权限的用户才能调用该方法。
    @PreAuthorize("hasAuthority('write')")
    public String getName() {
    
    
        return "Fantastico";
    }
}

我们在下面的列表中定义了Controller类。它使用NameService作为依赖关系。

清单16.4 实现api和使用服务的控制器类

package com.laurentiuspilca.ssia.controllers;

import com.laurentiuspilca.ssia.services.NameService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    
    

    //从上下文中注入服务
    @Autowired
    private NameService nameService;

    @GetMapping("/hello")
    public String hello() {
    
    
        //调用我们适用预授权规则的方法
        return "Hello, " + nameService.getName();
    }
}

现在你可以启动应用程序并测试其行为。我们希望只有用户Emma被授权调用api,因为她有写入授权。下面的代码片段展示了我们的两个用户Emma和Natalie对api的调用。要调用/hello端点并验证用户Emma的身份,使用这个cURL命令。

curl -u emma:12345 http://localhost:8080/hello

响应为

Hello, Fantastico

要调用/hello端点并使用用户Natalie进行认证,请使用这个cURL 命令:

curl -u natalie:12345 http://localhost:8080/hello

响应为

{
    
    "timestamp":"2023-01-31T02:15:01.760+00:00","status":403,"error":"Forbidden","message":"","path":"/hello"}

同样,你可以使用我们在第7章中讨论的任何其他表达式来进行api认证。下面是对它们的简短回顾:

  • hasAnyAuthority()—指定多个授权。用户必须至少拥有其中一个权限才能调用该方法。
  • hasRole()—指定一个用户必须拥有的角色来调用该方法。
  • hasAnyRole()—指定多个角色。用户必须至少拥有其中一个角色才能调用该方法。

让我们扩展我们的例子,证明你如何使用方法参数的值来定义授权规则(图16.6)。

image-20230131101855752

图16.6 在实现预授权时,我们可以在授权规则中使用方法参数的值。在我们的例子中,只有经过认证的用户才能检索到他们的名字的信息。

对于这个项目,我定义了与第一个例子相同的ProjectConfig类,这样我们就可以继续使用我们的两个用户,Emma和Natalie。api现在通过一个路径变量接受一个值,并调用一个服务类来获得给定用户名的 “秘密名称”。当然,在这种情况下,秘密名称只是我的一个发明,指的是用户的一个特征,这不是每个人都能看到的。我定义的控制器类在下一个列表中呈现。

清单16.5 定义测试用api的控制器类

package com.laurentiuspilca.ssia.controllers;

import com.laurentiuspilca.ssia.services.NameService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class HelloController {
    
    

    //从上下文中,注入一个定义保护方法的服务类的实例
    @Autowired
    private NameService nameService;

    //定义了一个从路径变量取值的api
    @GetMapping("/secret/names/{name}")
    public List<String> names(@PathVariable String name) {
    
    
        //调用受保护的方法来获得用户的秘密名称
        return nameService.getSecretNames(name);
    }
}

现在让我们来看看如何实现清单16.6中的NameService类。我们现在用于授权的表达式是#name == authentication.principlepal.username。在这个表达式中,我们用#name来指代名为name的getSecretNames()方法参数的值,我们可以直接访问认证对象,我们可以用它来指代当前认证的用户。我们使用的表达式表明,只有当认证用户的用户名与通过该方法的参数发送的值相同时,该方法才能被调用。换句话说,一个用户只能检索它自己的秘密名称。

清单16.6 NameService类定义了受保护的方法

package com.laurentiuspilca.ssia.services;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class NameService {
    
    
    

    使用#name来表示授权表达式中方法参数的值
    @PreAuthorize("#name == authentication.principal.username")
    public List<String> getSecretNames(String name) {
    
    
        Map<String,List<String>> secretNames = new HashMap<>();

        List<String> list1 = new ArrayList();
        list1.add("Energico");
        list1.add("Perfecto");
        List<String> list2 = new ArrayList();
        list2.add("Fantastico");

        secretNames.put("natalie",list1);
        secretNames.put("emma",list2);

        return secretNames.get(name);
    }
}

我们启动应用程序,并对其进行测试,以证明它能如愿工作。下一个代码片断向你展示了应用程序在调用api时的行为,提供的路径变量的值等于用户的名字。

curl -u emma:12345 http://localhost:8080/secret/names/emma

响应为

["Fantastico"]

在对用户Emma进行认证时,我们试图获得Natalie的秘密名字。这个访问被拒绝:

curl -u emma:12345 http://localhost:8080/secret/names/natalie

响应为

{
    
    "timestamp":"2023-01-31T02:30:46.132+00:00","status":403,"error":"Forbidden","message":"","path":"/secret/names/natalie"}

然而,用户Natalie可以获得她自己的秘密名字。下面的代码片段证明了这一点。

curl -u natalie:12345 http://localhost:8080/secret/names/natalie

响应为

["Energico","Perfecto"]

记住,你可以将全局方法的安全性应用到你的应用程序的任何一层。在本章介绍的例子中,你会发现授权规则适用于服务类的方法。但是你可以在应用程序的任何部分应用全局方法安全的授权规则:存储库、管理器、代理,等等。

16.3 应用授权后(根据返回值决定是否将值返回给用户)

现在,假设你想允许对一个方法的调用,但在某些情况下,你想确保调用者不会收到返回值。当我们想应用一个在方法调用后进行验证的授权规则时,我们就使用后授权。 这在开始时可能听起来有点别扭:为什么有人能够退出代码但却得不到结果呢?好吧,这与方法本身无关,但想象一下这个方法从一个数据源检索一些数据,比如说一个网络服务或数据库。你可以对你的方法所做的事情充满信心,但你不能对你的方法所调用的第三方打赌。所以你允许这个方法执行,但是你要验证它的返回值,如果它不符合标准,你就不让调用者访问返回值。

为了在Spring Security中应用后授权规则,我们使用@PostAuthorize注解,它与第16.2节中讨论的@PreAuthorize类似。该注解接收定义授权规则的SpEL作为一个值。我们将继续通过一个例子来学习如何使用@PostAuthorize注解并为一个方法定义后授权规则(图16.7)。

image-20230131103635892

图16.7 通过后授权,我们不保护方法不被调用,但我们保护返回值在定义的授权规则没有被遵守时不被暴露。

我们这个例子的场景,我定义了一个对象Employee。我们的Employee有一个名字,一个书籍列表,以及一个权限列表。我们将每个Employee与应用程序的一个用户联系起来。为了与本章中的其他例子保持一致,我们定义了相同的用户,Emma和Natalie。我们要确保方法的调用者只有在雇员有阅读权限的情况下才能得到雇员的详细信息。因为在检索记录之前,我们不知道与雇员记录相关的作者,所以我们需要在方法执行后应用授权规则。出于这个原因,我们使用了@PostAuthorize注解。

  • ProjectConfig
package com.laurentiuspilca.ssia.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
    
    

    @Bean
    public UserDetailsService userDetailsService() {
    
    
        InMemoryUserDetailsManager service = new InMemoryUserDetailsManager();

        UserDetails u1 = User.withUsername("natalie")
                    .password("12345")
                    .authorities("read")
                    .build();

        UserDetails u2 = User.withUsername("emma")
                    .password("12345")
                    .authorities("write")
                    .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return NoOpPasswordEncoder.getInstance();
    }
}

我们还需要声明一个类来表示Employee对象,包括它的名字、书籍列表和角色列表。下面的定义了Employee类。

  • Employee
package com.laurentiuspilca.ssia.model;

import java.util.List;
import java.util.Objects;

public class Employee {
    
    

    private String name;
    private List<String> books;
    private List<String> roles;

    public Employee(String name, List<String> books, List<String> roles) {
    
    
        this.name = name;
        this.books = books;
        this.roles = roles;
    }

    public String getName() {
    
    
        return name;
    }

    public void setName(String name) {
    
    
        this.name = name;
    }

    public List<String> getBooks() {
    
    
        return books;
    }

    public void setBooks(List<String> books) {
    
    
        this.books = books;
    }

    public List<String> getRoles() {
    
    
        return roles;
    }

    public void setRoles(List<String> roles) {
    
    
        this.roles = roles;
    }

    @Override
    public boolean equals(Object o) {
    
    
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return Objects.equals(name, employee.name) &&
                Objects.equals(books, employee.books) &&
                Objects.equals(roles, employee.roles);
    }

    @Override
    public int hashCode() {
    
    
        return Objects.hash(name, books, roles);
    }
}

我们可能从一个数据库中得到我们的雇员信息。为了使我们的例子更简短,我使用了一个有几条记录的Map,我们认为它是我们的数据源。在列表16.9中,你可以找到BookService类的定义。BookService类还包含了我们应用授权规则的方法。请注意,我们使用@PostAuthorize注解的表达式指的是方法returnObject返回的值。后授权表达式可以使用该方法返回的值,该值在该方法执行后可用。

package com.laurentiuspilca.ssia.services;

import com.laurentiuspilca.ssia.model.Employee;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class BookService {
    
    
    

    //定义了授权后的表达方式
    @PostAuthorize("returnObject.roles.contains('reader')")
    public Employee getBookDetails(String name) {
    
    

        HashMap<String, Employee> records = new HashMap<>();
        ArrayList<String> list1 = new ArrayList<>();
        list1.add("Karamazov Brothers");
        ArrayList<String> list2 = new ArrayList<>();
        list2.add("accountant");
        list2.add("reader");
        records.put("emma",new Employee("Karamazov Brothers",list1,list2));

        ArrayList<String> list3 = new ArrayList<>();
        list3.add("Beautiful Paris");
        ArrayList<String> list4 = new ArrayList<>();
        list4.add("researcher");
        records.put("emman",new Employee("Natalie Parker",list1,list2));

        return records.get(name);
    }
}

你现在可以启动应用程序并调用api来观察应用程序的行为。 在接下来的代码片断中,你可以找到调用api的例子。任何用户都可以访问Emma的详细信息,因为返回的角色列表中包含字符串 “读者”,但没有用户可以获得Natalie的详细信息。调用api来获取Emma的详细信息,并对用户Emma进行身份验证,我们使用这个命令。

curl -u emma:12345 http://localhost:8080/book/details/emma

响应为

{
    
    "name":"Karamazov Brothers","books":["Karamazov Brothers"],"roles":["accountant","reader"]}

调用api来获取Emma的详细信息,并通过用户Natalie进行认证,我们使用这个命令。

curl -u natalie:12345 http://localhost:8080/book/details/emma

响应为

{
    
    "name":"Karamazov Brothers","books":["Karamazov Brothers"],"roles":["accountant","reader"]}

调用api以获得Natalie的详细信息,并对用户Emma进行认证,我们使用这个命令。

curl -u emma:12345 http://localhost:8080/book/details/natalie

响应为

{
    
    
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/book/details/natalie"
}

调用api以获得Natalie的详细信息,并对用户Natalie进行认证,我们使用这个命令。

curl -u natalie:12345 http://localhost:8080/book/details/natalie

响应为

{
    
    "timestamp":"2023-01-31T02:55:20.465+00:00","status":403,"error":"Forbidden","message":"","path":"/book/details/natalie"}

如果你的需求需要有预授权和后授权,你可以在同一个方法上同时使用@PreAuthorize和@PostAuthorize。

16.4 实现方法的权限

到目前为止,你学会了如何用简单的表达式来定义预授权和后授权的规则。现在,让我们假设授权逻辑更加复杂,而且你不能用一行来写它。写巨大的SpEL表达式肯定不舒服。我从不建议在任何情况下使用长的SpEL表达式,不管它是否是授权规则。这只会产生难以阅读的代码,而这也会影响到应用程序的可维护性。

当你需要实现复杂的授权规则时,与其写长长的SpEL表达式,不如把逻辑拿出来放在一个单独的类中。Spring Security提供了权限的概念,这使得你可以很容易地在一个单独的类中编写授权规则,从而使你的应用程序更容易阅读和理解。

在这一节中,我们在一个项目中使用权限来应用授权规则。在这种情况下,你有一个管理文档的应用程序。任何文档都有一个所有者,也就是创建该文档的用户。要获得一个现有文档的详细信息,用户必须是管理员,或者他们必须是文档的所有者。我们实现一个权限评估器来解决这个要求。下面的列表定义了代码,它只是一个普通的Java对象。

package com.laurentiuspilca.ssia.model;

import java.util.Objects;

public class Document {
    
    

    private String owner;

    public Document(String owner) {
    
    
        this.owner = owner;
    }

    public String getOwner() {
    
    
        return owner;
    }

    public void setOwner(String owner) {
    
    
        this.owner = owner;
    }

    @Override
    public boolean equals(Object o) {
    
    
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Document document = (Document) o;
        return Objects.equals(owner, document.owner);
    }

    @Override
    public int hashCode() {
    
    
        return Objects.hash(owner);
    }
}

为了模拟数据库并使我们的例子更简短,使你感到舒服,我创建了一个资源库类,在Map中管理一些文档实例。你可以在接下来的列表中找到这个类。

package com.laurentiuspilca.ssia.repositories;

import com.laurentiuspilca.ssia.model.Document;
import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.Map;

@Repository
public class DocumentRepository {
    
    
    


    public Document findDocument(String code) {
    
    
        HashMap<String, Document> documents = new HashMap<>();
        documents.put("abc123", new Document("natalie"));
        documents.put("qwe123", new Document("natalie"));
        documents.put("asd555", new Document("emma"));


        return documents.get(code);
    }
}

一个服务类定义了一个使用资源库的方法,通过它的代码来获取一个文件。服务类中的方法是我们应用授权规则的方法。该类的逻辑很简单。它定义了一个方法,通过其独特的代码返回文档。我们用@PostAuthorize来注释这个方法,并使用hasPermission() SpEL表达式。这个方法允许我们引用一个外部授权表达式,我们将在本例中进一步实现。同时,注意到我们提供给hasPermission()方法的参数是returnObject,它代表方法返回的值,以及我们允许访问的角色名称,即’ROLLE_admin’。你可以在下面的列表中找到这个类的定义。

package com.laurentiuspilca.ssia.services;

import com.laurentiuspilca.ssia.model.Document;
import com.laurentiuspilca.ssia.repositories.DocumentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.stereotype.Service;

@Service
public class DocumentService {
    
    

    @Autowired
    private DocumentRepository documentRepository;

    //使用hasPermission()表达式来引用一个授权表达式
    @PostAuthorize("hasPermission(returnObject, 'ROLE_admin')")
    public Document getDocument(String code) {
    
    
        return documentRepository.findDocument(code);
    }
}

我们的任务是实现许可逻辑。我们通过编写一个实现PermissionEvaluator接口的对象来完成这个任务。PermissionEvaluator合约提供了两种实现权限逻辑的方法。

  • 按对象和许可 在目前的例子中,它假设权限评估器收到两个对象:一个是受授权规则约束的对象,一个是提供实现权限逻辑所需的额外细节。

  • 按对象ID、对象类型和权限 假设权限评估器收到一个对象ID, 它可以用来检索需要的对象.它还收到一个对象的类型, 如果同一个权限评估器适用于多个对象类型, 它需要一个提供额外细节的对象来评估权限, 可以使用这个对象。

在下一个代码清单中,你发现PermissionEvaluator接口有两个方法。

public interface PermissionEvaluator {
    
    
    boolean hasPermission(
            Authentication a,
            Object subject,
            Object permission);
    boolean hasPermission(
            Authentication a,
            Serializable id,
            String type,
            Object permission);
}

在当前的例子中,使用第一个方法就足够了。我们已经有了子对象,在我们的例子中,它是由方法返回的值。我们还发送了角色名称’ROLLE_admin’,根据这个例子的场景定义,它可以访问任何文档。当然,在我们的例子中,我们可以直接使用权限评估器类中的角色名称,避免将其作为hasPermission()对象的值发送。在这里,为了举例说明,我们只做了前者。 在现实世界中,可能会更复杂,你有多个方法,在授权过程中需要的细节可能会在每个方法之间有所不同。 出于这个原因,你有一个参数,你可以从方法层面发送需要的细节,用于授权逻辑。

为了提高你的认识并避免混淆,我还想提一下,你不需要传递认证对象。在调用hasPermission()方法时,Spring Security会自动提供这个参数值。框架知道认证实例的值,因为它已经在Security Context中了。在清单16.15中,你可以找到DocumentsPermissionEvaluator类,在我们的例子中,它实现了PermissionEvaluator契约,以定义自定义授权规则。

package com.laurentiuspilca.ssia.security;

import com.laurentiuspilca.ssia.model.Document;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.io.Serializable;

//实现PermissionEvaluator接口。
@Component
public class DocumentsPermissionEvaluator
        implements PermissionEvaluator {
    
    

    @Override
    public boolean hasPermission(Authentication authentication,
                                 Object target,
                                 Object permission) {
    
    
        //将目标对象转化为Document
        Document document = (Document) target;
        //在我们的例子中,权限对象是角色名称,所以我们把它转变成一个字符串。
        String p = (String) permission;

        //检查认证用户是否具有我们作为参数得到的角色
        boolean admin =
           authentication.getAuthorities()
           .stream()
           .anyMatch(a -> a.getAuthority().equals(p));

        //如果管理员或被认证的用户是文件的所有者,则授予该权限
        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。下面的列表介绍了如何定义MethodSecurityExpression- Handler以使自定义的PermissionEvaluator为人所知。

package com.laurentiuspilca.ssia.config;

import com.laurentiuspilca.ssia.security.DocumentsPermissionEvaluator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

//重述createExpressionHandler()接口
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig extends GlobalMethodSecurityConfiguration {
    
    

    @Autowired
    private DocumentsPermissionEvaluator evaluator;

    //定义了一个默认的安全表达式处理程序来设置自定义权限评估器
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
    
    
        DefaultMethodSecurityExpressionHandler expressionHandler =
                new DefaultMethodSecurityExpressionHandler();
        //设置自定义权限评估器
        expressionHandler.setPermissionEvaluator(evaluator);

        //返回自定义表达式处理程序
        return expressionHandler;
    }

    @Bean
    public UserDetailsService userDetailsService() {
    
    
        InMemoryUserDetailsManager service = new InMemoryUserDetailsManager();

        UserDetails u1 = User.withUsername("natalie")
                    .password("12345")
                    .roles("admin")
                .build();

        UserDetails u2 = User.withUsername("emma")
                .password("12345")
                .roles("manager")
                .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return NoOpPasswordEncoder.getInstance();
    }
}

我们在这里使用Spring Security提供的名为DefaultMethodSecurityExpressionHandler的MethodSecurityExpression Handler的实现。你也可以实现一个自定义的Method-SecurityExpressionHandler来定义你用来应用授权规则的自定义SpEL表达式。在现实世界中,你很少需要这样做,因此,我们不会在我们的例子中实现这样一个自定义对象。我只是想让你知道这是有可能的。

关于用户,唯一需要注意的是他们的角色。用户Natalie是一个管理员,可以访问任何文件。用户Emma是一个经理,只能访问她自己的文档。

为了测试应用程序,我们定义一个controller。下面列出了这个定义。

package com.laurentiuspilca.ssia.controllers;

import com.laurentiuspilca.ssia.model.Document;
import com.laurentiuspilca.ssia.services.DocumentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;


@RestController
public class DocumentController {
    
    

    @Autowired
    private DocumentService documentService;

    @GetMapping("/documents/{code}")
    public Document getDetails(@PathVariable String code) {
    
    
        return documentService.getDocument(code);
    }
}

让我们运行该应用程序并调用api来观察其行为。用户Natalie可以访问文件,无论其所有者是谁。用户Emma只能访问她拥有的文档。调用属于Natalie的文档的api,并以用户 "natalie "进行认证,我们使用这个命令。

curl -u natalie:12345 http://localhost:8080/documents/abc123

响应为

{
    
    "owner":"natalie"}

调用属于Natalie的文件的api,并以用户 "emma "进行认证,我们使用这个命令。

curl -u emma:12345 http://localhost:8080/docu

响应为

{
    
    "timestamp":"2023-01-31T04:27:57.533+00:00","status":404,"error":"Not Found","message":"","path":"/docu"}

以类似的方式,你可以使用第二个PermissionEvaluator方法来编写你的授权表达。第二种方法指的是使用标识符和主体类型,而不是对象本身。例如,假设我们想改变当前的例子,在方法执行前应用授权规则,使用@PreAuthorize。在这种情况下,我们还没有返回的对象。但是我们没有对象本身,而是有文档的代码,也就是它的唯一标识符。 清单16.19告诉你如何改变权限评估器类来实现这种情况。

清单16.19 DocumentsPermissionEvaluator类中的变化

package com.laurentiuspilca.ssia.security;

import com.laurentiuspilca.ssia.model.Document;
import com.laurentiuspilca.ssia.repositories.DocumentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.io.Serializable;

@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) {
    
    
        String code = targetId.toString();
        //我们没有对象,而是有它的ID,我们使用ID来获得对象。
        Document document = documentRepository.findDocument(code);

        String p = (String) permission;


        //检查用户是否是管理员
        boolean admin =
                authentication.getAuthorities()
                        .stream()
                        .anyMatch(a -> a.getAuthority().equals(p));

        //如果该用户是管理员或文件的所有者,则该用户可以访问该文件。
        return admin || document.getOwner().equals(authentication.getName());
    }
}

当然,我们也需要使用@PreAuthorize注解对权限评估器进行适当的调用。在下面的列表中,你可以看到我在DocumentService类中所做的改变,即用新方法来应用授权规则。

package com.laurentiuspilca.ssia.services;

import com.laurentiuspilca.ssia.model.Document;
import com.laurentiuspilca.ssia.repositories.DocumentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class DocumentService {
    
    

    @Autowired
    private DocumentRepository documentRepository;

    //通过使用许可评估器的第二种方法适用预授权规则
    @PreAuthorize("hasPermission(#code, 'document', 'ROLE_admin')")
    public Document getDocument(String code) {
    
    
        return documentRepository.findDocument(code);
    }
}

你可以重新运行应用程序并检查端点的行为。你应该看到与我们使用权限评估器的第一个方法来实现授权规则的情况相同的结果。用户Natalie是管理员,可以访问任何文档的细节,而用户Emma只能访问她拥有的文档。调用属于Natalie的文档的端点,用用户 "natalie "进行授权,我们发出这样的命令:

curl -u natalie:12345 http://localhost:8080/documents/abc123

响应为

{
    
    
"owner":"natalie"
}

调用属于Emma的文件的端点,并以用户 "natalie "进行认证,我们发出这个命令。

curl -u natalie:12345 http://localhost:8080/documents/asd555

响应为

{
    
    
"owner":"emma"
}

调用属于Emma的文件的api,并以用户 "emma "进行认证,我们发出这个命令。

curl -u emma:12345 http://localhost:8080/documents/asd555

响应为

{
    
    
"owner":"emma"
}

调用属于Natalie的文件的端点,并以用户 "emma "进行认证,我们发出这个命令。

curl -u emma: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 emma:12345 http://localhost:8080/hello

响应是

Hello, Fantastico

要调用api并通过用户Natalie进行认证,请使用此命令。

curl -u natalie:12345 http://localhost:8080/hello

响应是

{
“status”:403,
“error”:“Forbidden”,
“message”:“Forbidden”,
“path”:“/hello”
}

总结

  • Spring Security允许你在应用的任何一层应用授权规则,而不仅仅是在pi层面。要做到这一点,你要启用全局方法安全功能。
  • 全局方法安全功能在默认情况下是禁用的。要启用它,你需要在你的应用程序的配置类上使用@EnableGlobalMethodSecurity注解。
  • 你可以应用授权规则,应用程序在调用一个方法之前进行检查。如果这些授权规则没有被遵守,框架就不允许该方法执行。当我们在调用方法之前测试授权规则时,我们就在使用预授权。
  • 为了实现预授权,你使用@PreAuthorize注解和定义授权规则的SpEL表达式的值。
  • 如果我们只想在方法调用后决定调用者是否可以使用返回的值,以及执行流程是否可以继续,我们就使用后授权。
  • 为了实现后授权,我们使用@PostAuthorize注解,用SpEL表达式的值来表示授权规则。
  • 当实现复杂的授权逻辑时,你应该把这个逻辑分离到另一个类中,使你的代码更容易阅读。在Spring Security中,一个常见的方法是通过实现一个PermissionEvaluator。
  • Spring Security提供了与旧规范的兼容性,如@Roles- Allowed和@Secured注解。你可以使用这些注解,但它们没有@PreAuthorize和@PostAuthorize那么强大,而且你在现实世界中发现Spring使用这些注解的机会非常小。

猜你喜欢

转载自blog.csdn.net/Learning_xzj/article/details/128998813