SpringCloud与微服务-第8章-Nacos分布式配置中心

在微服务架构中,当系统从一个单体应用,被拆分成分布式系统上一个个服务节点后,配置文件也必须跟着迁移(分割)。在系统架构中,配置中心是整个微服务基础架构体系中的一个组件,它的功能看上去并不起眼,无非就是配置的管理和存取,但它是整个微服务架构中不可或缺的一环。


应用程序在启动和运行的时候往往需要读取一些配置信息,配置基本上伴随着应用程序的整个生命周期,应用在启动时通过读取配置来初始化,在运行时根据配置调整行为。同一份程序在不同的环境(开发、测试、生产)、不同的集群(如不同的数据中心)经常需有不同的配置,所以需要有完善的环境、集群配置管理。


Spring Cloud Alibaba Nacos 的一大优势是整合了注册中心、配置中心功能,部署和操作更加直观简单,它简化了架构复杂度,并减轻运维及部署工作。在之前的章节中,我们已经使用 Nacos 作为注册中心,本章节我们将详细介绍 Nacos 配置中心的功能。


目标

在本章中,您将学习:

  • Nacos 配置管理
  • 配置拉取
  • 配置热更新
  • 多环境配置共享
  • Nacos 集群搭建

快速入门

当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置是一件效率非常低的事情,而且很容易出错。我们需要一种统一配置管理方案,可以集中管理所有实例的配置。下面我们就通过一个小案例来感受一下 Spring Cloud Alibaba Nacos 配置中心的强大功能吧。


在 Nacos 中创建配置

我们以 userservice 服务为例,在 Nacos 配置中心创建一份配置文件。进入 Nacos 的管理界面,然后选择配置管理菜单,点击配置列表选项,点击右侧"+"符号,如下图所示:


bg fit


点击之后,填写弹出的表单页,最后点击发布,我们就创建好一份 userservice 服务的配置,如下图所示:


image-20220731211155187


发布成功之后,我们在控制台的配置列表中可以看到新增配置,并且可以进行再次编辑,查看详情,删除等操作。


image-20220731211354245


此时,在 Nacos 配置中心的工作暂时告一段落,我们已经创建好一份 userservice 服务的配置文件,那么怎么样才能让 userservice 服务读取到这份配置呢?


读取 Nacos 中配置

userservice 服务要读取 Nacos 中管理的配置,并且与本地的 application.yml 配置合并,才能完成项目启动。但是此时有一个问题必须解决: 尚未读取 application.yml,又如何得知 nacos 地址呢?


为解决此问题,Spring 引入了一种新的配置文件:bootstrap.yaml 文件,该配置文件的优先级很高,会在 application.yml 之前被读取,流程如下:


image-20220731213028366


首先,我们要给 userservice 引入 Nacos 配置中心的 Maven 依赖:

<!--nacos配置管理依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

其次,在 resources 目录下创建 bootstrap.yml 文件,并进行配置:

spring:
  application:
    name: userservice # 服务名称
  profiles:
    active: dev #开发环境,这里是dev
  cloud:
    nacos:
      server-addr: localhost:8848 # Nacos地址
      config:
        file-extension: yaml # 文件后缀名

此时,同学们会发现,在 bootstrap.yml 中配置的这些信息,正好可以满足我们的需求: 去 nacos 中查找一个名字为 userservice-dev.yaml 文件。这些信息正是${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}的值拼接而成。


image-20220731213648989


这也是我们在 nacos 中创建配置文件时的命名规则的含义所在。


测试验证

我们在 userservice 中新增一个控制器类,进行测试:

@Slf4j
@RestController
@RequestMapping("/config")
public class NacosConfigTestController {
    @Value("${myconfig.msg}")
    private String msg;

    @RequestMapping("/msg")
    public String getMsg(){
        return this.msg;
    }
}

修改完成之后,我们重启 userservice 服务,访问: http://localhost:8081/config/msg ,结果如图所示:

image-20220731214102597

我们通过简单的几个步骤,成功的使用 Nacos 配置中心的功能,实现了在 Nacos 控制台对配置文件的简单管理。


现在如果尝试修改 Nacos 配置中心的config.msg的值,然后再次访问 http://localhost:8081/config/msg ,会发现返回的结果并没有发生变化,
要想使新的配置生效仍需重启服务.


配置热更新

如果每次线上修改修改了配置文件,都需要重启服务才能生效,这样的话,就会造成服务的中断,这是我们不希望看到的。那么,有没有一种方式,可以实现在线上修改配置文件,而不需要重启服务呢?

答案是可以的, 我们可以通过为 Nacos 的配置热更新来实现此类需求。

所谓的热更新是指,在 Nacos 中的配置文件变更后,对应的微服务无需重启就可以感知到最新的配置。


所谓的 Nacos 配置热更新,就是指 Nacos 中的线上配置变更后, 无需重启服务就可以感知到最新的配置变化。

在微服务中,可以通过 SpringBoot 的方式进行配置的注入 :

  • 通过 @Value 注解进行注入单个配置
  • 通过 @AutoWired 注解进行注入包含多个配置属性的对象
  • 在需要注入配置的类上添加 @RefreshScope 注解,使得注入的配置可以和线上配置保持同步

使用@Value 注解进行注入

@Value 注入的变量所在类上添加注解@RefreshScope,:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope // 添加此注解, 使得配置文件可以热更新
public class NacosConfigTestController {
    @Value("${myconfig.msg}") // 通过@Value 注解进行注入响应的配置
    private String msg;

    @RequestMapping("/msg")
    public String getMsg(){
        return this.msg;
    }
}

通过查看@RefreshScope 的注解发现,其底层是重新创建了实例,并进行了依赖注入。

/**
 * 添加了@RefreshScope的Bean 可以在运行时刷新
 * 任何使用它们的组件都将在下一次方法调用时获得一个新实例,完全初始化并注入所有依赖项。
 * @author Dave Syer
 */
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Scope("refresh")
@Documented
public @interface RefreshScope {
   ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}

使用@Autowire 注解进行注入

@Value 可以进行简单值的属性注入,如果我们想要在 Bean 中注入一个对象,往往使用@Autowire 注解进行注入。针对这种注入方式,我们可以使用@ConfigurationProperties 注解进行配置读取。


我们仍然以myconfig.msg配置为例,使用@ConfigurationProperties 进行配置读取。​ 首先,编写一个 Java Bean:

@Data
@Component
@ConfigurationProperties(prefix = "myconfig")
public class UserNacosConfig {
    private String msg;
    private String chapter;
}

在编写 JavaBean 时,@ConfigurationProperties 注解的 prefix 属性和 Nacos 中配置的属性前缀保持一致,成员变量的名字和 Nacos 中配置的属性的 Key 保持一致。 Nacos 中配置我们修改如下:

w:35em

修改完 Nacos 中配置之后,一定要记得点击发布按钮进行发布。


最后修改 NacosConfigTestController,注入我们编写的 JavaBean:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope
public class NacosConfigTestController {
    @Value("${myconfig.msg}")
    private String msg;

    @Autowired
    private UserNacosConfig userNacosConfig;

    @RequestMapping("/msg")
    public String getMsg() {
        return this.msg;
    }

    @RequestMapping("/message")
    public String getMessage() {
        return userNacosConfig.getMsg() + userNacosConfig.getChapter();
    }
}

重启 userservice 应用,访问:http://localhost:8081/config/message ,结果如图所示:

image-20220801112128380

此时是因为我们重启了服务,所以能够读取到最新的配置,这是可以预知的结果。


现在,我们继续修改配置,而不重启服务,观察是否能够得到最新的配置内容。

最新配置更改如下:

w:35em


点击发布之后,我们不用重启 userservice 服务,直接再次访问:http://localhost:8081/config/message , 结果如图所示:

w:35em

可以看到,我们已经可以实现配置的热更新。


加载指定的 Nacos 配置

配置默认的 GroupID 为 DEFAULT_GROUP, 也可以创建自定义的 GroupID, 但是在加载配置时,需要指定 GroupID。


在 Nacos 配置中心, 创建配置如下:

w:24em


pom.xml 加入依赖

  <dependency>
    <groupId>com.alibaba.boot</groupId>
    <artifactId>nacos-config-spring-boot-starter</artifactId>
    <version>0.2.12</version>
  </dependency>

启动类加入注解

// 读取 Nacos 配置中心
@NacosPropertySource(dataId = "retryTimes", groupId = "RiskModule",autoRefreshed = true)
@MapperScan("com.niit.user.mapper")
@SpringBootApplication
public class UserApplication {...}

使用时通过@NacosValue 注解进行注入

    @NacosValue(value = "${readRetryTimes}", autoRefreshed = true)
    private String readRetryTimes;
    @NacosValue(value = "${writeRetryTimes}", autoRefreshed = true)
    private String writeRetryTimes;

此外, 也可以通过 Nacos 的 API 进行配置的设置和读取

发布配置
curl -X POST "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=nacos.cfg.dataId&group=test&content=HelloWorld"

获取配置
curl -X GET "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=nacos.cfg.dataId&group=test"


注意:

  1. 不是所有的配置都适合放到配置中心,维护起来比较麻烦。
  2. 建议将一些关键参数,需要在运行时进行调整的参数放到 Nacos 配置中心,这些参数一般都是自定义配置信息。

多环境配置

微服务系统中, 及存在共通的配置比如应用的名称。而有些配置在不同的环境下,比如开发环境和生产环境下的微服务的配置是不尽相同的, 比如端口号, 数据库的链接,Nacos 的地址。

此时,我们想尽量减少重复性工作,提升开发和维护的效率,那就需要将这些跨环境的那些相同配置提取出来。我们将共同配置提取到一个地方,就可以做到只用设置一次, 所有环境(比如开发.测试以及生产环境)都会读取该配置,这样就达到了配置共享的目的。


配置读取

在微服务启动的时候,会去 Nacos 中读取多个配置:

  • bootstrap.yml 启动配置:
    会优先加载,用于加载一些系统级别的配置,比如连接到配置中心的配置。
  • ${spring.application.name}.yaml
    例如 userservice.yaml。无论微服务的环境是开发环境也好, 是生产环境也好,${spring.application.name}.yaml这个配置文件都会userservice加载。

在 Nacos 中添加共享配置

我们在 nacos 中添加一个 userservice.yaml 文件:

w:22em

注意: 命名规则是 ${spring.application.name}.yaml ,该共享配置与环境无关。


读取新增配置

userservice 服务中,修改配置类,读取新增的author属性:

@Data
@Component
@ConfigurationProperties(prefix = "myconfig")
public class UserNacosConfig {
    private String chapter;
    private String msg;
    private String author;
}

新增一个控制器方法,以便于进行测试验证:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope
public class NacosConfigTestController {

    @Autowired
    private UserNacosConfig userNacosConfig;
    ...
    @RequestMapping("/author")
    public String getAuthor() {
        return userNacosConfig.getAuthor();
    }
}

多环境测试

使用 dev 环境启动一个 userservice 实例,端口号 8081,再用 test 环境启动另一个 userservice 实例,端口号 8082,然后我们分别访问: http://localhost:8081/config/authorhttp://localhost:8082/config/author:


结果如图所示:

w:28em


我们知道,在 bootstrap.yml 文件中指定了应用读取 Nacos 中的 userservice-dev.yaml 文件,并且 Nacos 中并没有 userservice-test.yaml 配置文件,但是我们访问 8082 端口的 userservice 时,仍然能够读取myconfig.author的值,这说明userservice.yaml文件确实是多环境共享的一个配置文件。


配置优先级

我们现在考虑一个问题,如果多个配置文件中都对某个属性进行了定义,那么微服务究竟应该读取那个配置来使用呢?我们不妨来做一个小小的测验。

在每一个配置文件中都定义一个相同的属性,比如myconfig.bookName属性,如下所示:

  • userservice-dev.yaml 中,bookName 的值是 Spring Cloud Dev
  • userservice.yaml 中,bookName 的值是 Spring Cloud Default
  • 在本地 application-dev.yaml 中,bookName 的值是 Spring Cloud Local。

修改配置类,读取 bookName 属性:

@Data
@Component
@ConfigurationProperties(prefix = "myconfig")
public class UserNacosConfig {
    private String chapter;
    private String msg;
    private String author;
    private String bookName;
}

新增控制器方法,读取myconfig.bookName属性的值:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope
public class NacosConfigTestController {
    @Autowired
    private UserNacosConfig userNacosConfig;
    ...
    @RequestMapping("/bookName")
    public String getBookName() {
        return userNacosConfig.getBookName();
    }
}

在 dev 环境下,重启 userservice,端口号 8081,现在我们访问: http://localhost:8081/config/bookName ,结果如图所示:

w:32em


这说明,Nacos 线上配置的带有环境变量的配置文件的优先级是最高的。那么线上的共享配置和本地配置,哪个优先级更高呢?我们暂时删除 Nacos 线上配置的带有环境变量的配置文件中的属性,重新访问: http://localhost:8081/config/bookName ,结果如图所示:

image-20220801174120879


通过这个小小的测验,对于配置优先级问题,我们总结出如下规律:

  • 线上配置优先于本地配置
  • 线上自定义环境配置优先于线上共享配置

bg right fit


这样的优先级规律,也恰恰符合配置中心的设计原则,即线上可配置优先于本地预配置,自定义个性化配置优先于多环境共享配置。详见 Nacos-config 参考文档


搭建 Nacos 高可用集群


在之前的内容中,我们已经学习了 Nacos 的基本用法。不过我们一直是使用的 nacos 单节点服务,Nacos 单节点模式只适用于线下环境,在企业的生产环境中,我们是不能使用 Nacos 单节点进行业务架构部署的。


单节点对于高可用设计来说是远远不够的,因为单节点一旦出先故障,那么所有依赖于该节点的其他微服务,都会出现问题,这样会导致整个业务系统的大崩溃。所以,我们接下来就开始学习如何搭建 Nacos 集群,构建一个高可用的服务治理体系。


Nacos 集群

一个 Nacos 集群,至少要有三个节点。下方是 Spring Cloud Alibaba Nacos 官方给出的最简易的 Nacos 集群架构图:


image-20220802110040457

其中包含 3 个 nacos 节点,然后一个负载均衡器代理 3 个 Nacos。这里负载均衡器可以使用 nginx。接下来,我们以 windows 系统为例,搭建一个最简单的 Nacos 集群进行学习。


注意:

在实际生产环境中,需要给做反向代理的 nginx 服务器设置一个域名,这样后续如果有服务器迁移,nacos 的客户端也无需更改配置。Nacos 的各个节点应该部署到多个不同服务器,做好容灾和隔离。Mysql 应该搭建一个主从高可用的集群。 本案例以单机 windows 系统为例,模拟三个 Nacos 节点,并且使用单机的 Mysql8.0 以上的版本进行集群搭建的演示。


Nacos 集群搭建步骤如下:

  1. 搭建数据库,初始化数据库表结构
  2. 下载 nacos 安装包
  3. 配置 nacos 节点
  4. 启动 nacos 集群
  5. nginx 反向代理
  6. 修改微服务 Nacos 地址

初始化数据库

Nacos 默认数据存储在内嵌数据库 Derby 中,不属于生产可用的数据库。官方推荐的最佳实践是使用带有主从的高可用数据库集群。这里我们以单点的数据库为例来演示。首先新建一个数据库,命名为 nacos,而后导入 Nacos 的 MySQL 数据库的初始化 SQL 文件.


使用 Nacos 项目官方提供的初始化 sql 文件进行初始化操作, 具体位置在 conf 目录下的mysql-schema.sql文件


h:13em

Nacos 的 MySQL 初始化脚本在线地址
https://github.com/alibaba/nacos/tree/develop/config/src/main/resources/META-INF


在本地 Mysql 中执行成功后,nacos 数据库中将会出现下面的表:

image-20220802111857744


下载 Nacos 安装包

本知识点在本书第三章节已经介绍过,不再赘述,我们以 nacos2.1.0 版本为例,进行搭建。

image-20220802112128986


配置 Nacos 节点

我们在本地 windows 系统上配置三个 Nacos 节点,各个节点的 IP 和端口如下表所示:

节点 IP port
nacos1 127.0.0.1 8858
nacos2 127.0.0.1 8868
nacos3 127.0.0.1 8878

注意: 本地 windows 系统上,如果端口被占用,可以修改端口号,但是要保证三个节点的端口号不一样, 且不要相邻(容易冲突)。


我们先配置好一个 nacos 节点,然后再复制成三个节点。

下面以一个节点操作为例:


1.解压缩

将下载的安装包,解压缩至一个没有中文目录的文件夹,命名为 nacos1,解压完成之后,Nacos 目录如图所示:

image-20220802114728043


-目录说明:

  • bin:启动脚本
  • conf:配置文件

2.修改配置

进入 nacos 的 conf 目录,修改配置文件 cluster.conf.example,重命名为 cluster.conf:

image-20220802114924887


编辑此文件,添加 nacos 集群配置:

192.168.65.1:8858
192.168.65.1:8868
192.168.65.1:8878

192.168.65.1 为本机真实的 IP 地址
真实 ip 可以在启动单机版 nacos 后从日志提示中查看


然后修改 application.properties 文件,进行数据库配置:

image-20220802115229225


属性说明:

  • spring.datasource.platform=mysql: 指定数据库平台为 mysql,Nacos 目前只支持 Mysql。
  • db.num=1 : Mysql 的实例个数,本案例中为 1 个 Mysql 实例。
  • db.url.0: 数据库链接信息,本案例中我们使用的是 Mysql8.0 以上的版本,并且创建了 nacos 数据库,所以链接如上所示。
  • db.user.0: 数据库用户名
  • db.password.0: 数据库密码

接着修改应用的端口号,第一个节点是: 8858,如下图所示:

server.port=8858

3.复制节点

修改完上述配置并保存后,我们将该节点复制两份,命名为 nacos2 和 nacos3,并将 nacos1的端口修改为8858, nacos2 的端口修改为 8868,将 nacos3 的端口修改为 8878。完成之后,nacos 三个节点的集群整体如下所示:


启动 Nacos 集群

在每个 Nacos 节点的 bin 目录下,使用 cmd 命令窗口执行: startup.cmd。 Nacos 的该命令,默认就是以集群方式进行启动。启动成功,最后会打印出日志信息:

image-20220802150700225


Nginx 反向代理

经过以上步骤,我们已经成功搭建好具有三个节点的 Nacos 集群,现在我们使用 Nginx 给该集群配置负载均衡和反向代理。


1.下载 Nginx

下载 Nginx,下载地址:https://nginx.org/en/download.html 我们选择最新的稳定版本。

h:12em


2.解压缩

将安装包解压缩到任意的非中文目录下:

image-20220802153243148


3.添加代理配置

修改 conf/nginx.conf 文件,将下面的配置放在配置文件的 http 节点之内:

upstream nacos-cluster {
  server 127.0.0.1:8858;
    server 127.0.0.1:8868;
    server 127.0.0.1:8878;
}

server {
    listen       8848;
    server_name  localhost;

    location /nacos {
        proxy_pass http://nacos-cluster;
    }
}

该代理配置监听 8848 端口,虚拟路径为nacos


4.启动 Nginx

修改完配置并保存之后,回到 Nginx 的安装目录,双击nginx.exe即可启动。此时,我们可以访问: http://localhost/nacos

bg right fit


通过固定访问方式 http://localhost:8848/nacos 访问集群管理 - 节点列表, 如果看到集群中的三个节点都已经启动成功, 且为 UP 状态, 则说明 Nginx 反向代理配置成功。


微服务配置 Nacos 地址

由于我们现在已经搭建了 Nacos 集群,并且使用 Nginx 做了负载均衡和反向代理,所以各个微服务之前配置的 Nacos 地址肯定无法再使用,微服务中的 Nacos 地址配置要与 Nginx 中保持一致。

以 userservice 服务为例,修改其 bootstrap.yaml 文件即可:


spring:
  application:
    name: userservice
  profiles:
    active: dev #环境
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos集群的NGINX反向代理地址
      config:
        file-extension: yaml #配置文件后缀名

现在我们启动三个 userservice 服务的实例进行测试,启动成功之后,我们可以在 Nacos 控制台看到如下信息:
image-20240320190015616

当我们搭建完成 Nacos 集群之后,在使用的时候几乎完全和我们之前所学的单节点 Nacos 的使用方法一致。


活动 8.1:

Nacos 配置中心使用


练习问题


  1. 当使用@Value 注解进行属性注入时,在需要的类上加上什么注解可以实现动态刷新配置。 ()
    a. @Service
    b. @Component
    c. @RestController
    d. @RefreshScope

    查看答案

    正确答案: d


  1. Nacos 配置中心的 artifactId 是下列的哪一个? ()
    a. spring-cloud-starter-alibaba-nacos-config
    b. spring-cloud-starter-alibaba-nacos-discovery
    c. spring-cloud-starter-Netflix-nacos-config
    d. spring-cloud-starter-alibaba-config-config

    查看答案

    正确答案: a


  1. 下列配置文件,哪个是 userservice 服务的多环境共享配置? ()
    a.userservice-dev.yaml
    b.application-dev.yaml
    c.userservice.yaml
    d.userservice-common.yaml

    查看答案

    正确答案: c


  1. 搭建 Nacos 集群,最少需要几个 Nacos 实例节点?
    a. 1
    b. 2
    c. 3
    d. 4

    查看答案

    正确答案: c


小结

在本章中,您学习了:

  • Nacos 配置中心快速入门

  • 配置热更新

    • 使用@Value 注解进行注入
    • 使用@Autowire 注解进行注入
  • 多环境配置共享

    • 配置优先级
  • 搭建 Nacos 集群

    • 初始化数据库
    • 配置 Nacos 节点
    • 启动 Nacos 集群
    • Nginx 反向代理

Views: 22

SpringSecurity-OAuth2+JWT+SpringCloudGateway实现统一鉴权管理

SpringSecurityJava学习笔记

版权 本文为时间海绵原创文章,转载无需和我联系,但请注明来自博客 https://blog.hzchendou.com

一、SpringSecurity 入门

介绍

SpringSecurity 是Spring 全家桶中的安全框架,为了解决“用户身份认证”、“资源访问鉴权”这两个核心问题,SpringSecurity提供了一整套安全框架,基于安全框架,用户可以自定义身份认证、资源鉴权功能,例如:手机验证码登录、基于RDBC鉴权等,本文章主要介绍如何创建基于SpringSecurity项目。

项目创建

项目源码已上传到Gitee: 地址

项目依赖

基于 SpringBoot 创建SpringSecurity 可以实现开箱即用功能,引入依赖项:

- SpringBoot依赖

 <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.0</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>

- Spring MVC 依赖(搭建基于 http 协议的web项目)

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>

- Spring Security 依赖

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

详细 pom 文件可以参见源码:https://gitee.com/hzchendou/spring-security-demo/blob/lesson1/pom.xml

项目模块

创建简单mvc API,代码如下:

/**
 * hello 访问控制器
 * @Date: 2022-05-23 11:27
 * @since: 1.0
 */
@RequestMapping("/anonymity")
@RestController
public class AnonymityController {

    @RequestMapping("/hello")
    public ResultVO test() {
        return ResultVO.success("hello world");
    }
}

项目启动

自此完成项目配置,基于SpringBoot 自动装配功能可以帮助我们完成大部分配置,引入依赖后会帮助创建一个基础运行框架,配置了一些默认配置项,运行项目后看到如下日志:

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.0)

2022-05-23 12:23:13.584  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : Starting Application using Java 1.8.0_211 on hzchendoudeMac-mini.local with PID 8538 (/Users/chendou/repo/hzchendou/learning/springsecurity/target/classes started by chendou in /Users/chendou/repo/hzchendou/learning/springsecurity)
2022-05-23 12:23:13.586  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : No active profile set, falling back to 1 default profile: "default"
2022-05-23 12:23:14.338  INFO 8538 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2022-05-23 12:23:14.344  INFO 8538 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2022-05-23 12:23:14.344  INFO 8538 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.63]
2022-05-23 12:23:14.426  INFO 8538 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2022-05-23 12:23:14.426  INFO 8538 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 806 ms
2022-05-23 12:23:14.666  WARN 8538 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: ab60d0d9-a34b-4aee-ad31-e8881672c6a0

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2022-05-23 12:23:14.742  INFO 8538 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@20eaeaf8, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@748ac6f3, org.springframework.security.web.context.SecurityContextPersistenceFilter@7affc159, org.springframework.security.web.header.HeaderWriterFilter@72eb6200, org.springframework.security.web.csrf.CsrfFilter@52bf7bf6, org.springframework.security.web.authentication.logout.LogoutFilter@66de00f2, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@163042ea, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@479b5066, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@68f6e55d, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@1d8b0500, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@1682c08c, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3fd05b3e, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@6fff46bf, org.springframework.security.web.session.SessionManagementFilter@76ececd, org.springframework.security.web.access.ExceptionTranslationFilter@67e25252, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@52b46d52]
2022-05-23 12:23:14.783  INFO 8538 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-05-23 12:23:14.791  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : Started Application in 1.471 seconds (JVM running for 1.869)

会生成一串用户密码,这是SpringSecurity 帮助学习的默认配置,后续将会讲解,

启动完成在浏览器输入访问地址:http://localhost:8080/anonymity/hello

网页会自动跳转到 http://localhost:8080/login

输入用户名:user

输入密码:在日志中的一串字符串, 这里是 *ab60d0d9-a34b-4aee-ad31-e8881672c6a0*(由程序自动生成,每次生成内容不一样)

登录成功后跳转到指定地址,得到内容如下:

{"code":200,"data":"hello world","message":null}

至此完成SpringSecurity项目搭建,SpringSecurity 提供了默认配置,默认组织匿名访问接口。

总结

  1. SpringSecurity 项目搭建很方便,结合 SpringBoot 进行使用可以快速完成基础框架搭建,同时提供默认配置,不需要任何配置即可完成项目资源保护
  2. SpringSecurity 提供了 用户身份鉴定(用户登录), 以及用户访问权限控制(判断是否拥有权限访问项目接口)

上述内容帮助完成搭建基础项目,当然这样的程序无法满足实际项目需求,我们需要自定义认证(登录方式)以及 鉴权(权限控制)流程,下一篇我们将在此基础上自定义登录方式

特别声明:项目采用最新SpringSecurity版本:5..7.1,版本升级带来了一点新变化,可能与老版本由一点不同,但是核心理念是一致的

二、SpringSecurity 自定义手机验证登录方式

简介

在上一篇文章中,我们介绍了如何搭建一套基于SpringSecuity的项目框架,并且进行了演示,本文将继续扩展项目功能,实现自定义用户登录功能。

项目源码仓库:Gitee

代码分支:lesson2

原理介绍

SpringSecurity 提供了web服务项目相关的安全配置,通常我们使用 Spring MVC进行开发(基于Servlet 容器技术实现,现在 Spring 提供了 WebFlux 技术可以提高系统吞吐量,两者都是基于 HTTP协议开发的web服务,MVC提供的是阻塞I/O,WebFlux 提供非阻塞 I/O),Servlet 容器中提供了两种核心组件:

  1. Filter
  2. Servlet

Filter 简介

Filter 组件可以实现过滤器功能,Http 请求达到时,Filter 优先接收到请求信息,并且可以依据业务逻辑对请求提前进行处理,例如,CORS(浏览器的同源请求策略, 详细信息参见:阮一峰网络日志), 对于非同源的请求,项目方可以按照要求选择拒绝或者接受请求,接口定义如下:

public interface Filter {
    /// 初始化
    public default void init(FilterConfig filterConfig) throws ServletException {}
    /// 过滤
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException;
    /// 销毁
    public default void destroy() {}
}

在 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; 方法中可以获取到http 请求信息,并且可以阻断 Http 请求,防止调用实际的业务逻辑代码,例如 实现基于IP黑名单过滤器,发现请求IP 在系统的 IP黑名单中,可以直接返回错误信息阻止请求继续执行。

Servlet 简介

Servlet 组件用于接收Http请求信息,并依据请求信息进行处理,项目的业务逻辑在 Servlet 中进行处理,接口定义如下:

public interface Servlet {
    /// 初始化方法
    public void init(ServletConfig config) throws ServletException;
    /// 获取配置
    public ServletConfig getServletConfig();
    //// 业务处理
    public void service(ServletRequest req, ServletResponse res)
            throws ServletException, IOException;
    /// 获取基础信息
    public String getServletInfo();
    /// 销毁
    public void destroy();
}

其中最主要的是 public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException; 业务逻辑代码在此处进行调用处理(Spring MVC 中的重要组件 DispatcherServlet 是Servlet 子类,通过 service 方法接收并处理 Http 请求)

SpringSecurity原理

通过上述Servlet 技术简单讲解,我们知道Filter主要用于实现过滤功能,这些功能与业务逻辑关系不大,可以在请求进入业务逻辑之前进行拦截处理,保障系统稳定运行,SpringSecurity正是通过一系列“Filter组件”来实现安全过滤功能(在执行业务逻辑之前对Http请求进行身份校验和权限控制),SpringSecurity 中的两个主要功能分别是:

  • 身份校验:对当前发起请求的用户(可能是真实用户,也可能是网络爬虫或者是恶意攻击者)进行身份识别,主要解决你是谁的问题
  • 权限控制:对当前访问资源进行权限控制(管理后台功能只对管理员开发,普通用户无法访问),主要解决你是否有权限访问资源

通过上述两个功能点可以实现系统的访问控制安全,对于不符合要求的请求,直接返回错误信息,阻止不安全的资源访问。本文重点讲解身份校验,权限控制将在后续进行分析。

用户名密码登录分析

在之前文章中使用“user”用户进行了登录,这是SpringSecurity提供的默认用户密码登录实现:"org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter",这是一个Filter 子类,可以实现Filter过滤功能,核心代码如下:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    /// 默认匹配 POST /login 请求
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
            "POST");

    public UsernamePasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }
    //// 在 doFilter 方法中调用该方法实现过滤
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        /// 判断请求方法是否支持
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        /// 包装成 用户密码Token
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        // Allow subclasses to set the "details" property
        /// 设置请求信息,这是一些额外的信息,例如用户IP地址等信息,与核心校验逻辑关系不大
        setDetails(request, authRequest);
        //// 调用 AuthenticationManager 进行身份验证,成功返回 Authentication 对象,失败抛出异常
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

我们来分析一下 attemptAuthentication 方法,执行的逻辑如下:

  1. 判断Http请求是否为用户密码登录请求(主要看请求路径是否为 /login 并且为 POST方法)
  2. 获取请求中的用户名密码信息(登录参数信息)
  3. 委托给AuthenticationManager组件进行身份验证
  4. 返回成功或者是错误信息

可以理解为Filter中并没有承担核心的身份信息校验责任,主要完成校验请求是否为用户名密码请求,如果是提取出相关参数,委托给AuthenticationManager组件校验身份,如果成功返回Authentication对象。这里有几个关键的类:

  • UsernamePasswordAuthenticationToken:保存用户名密码信息(是Authentication的子类)
  • Authentication:代表待验证信息或者是已验证完成后的身份信息(可以是未验证的信息也可以是已验证的身份信息,通过方法boolean isAuthenticated() 返回值判断是为已验证信息)
  • AuthenticationManager:验证管理器负责对待验证信息内容进行验证,验证成功返回身份信息,失败返回错误信息

UsernamePasswordAuthenticationToken和Authentication都是数据模型类,不存在处理逻辑,AuthenticationManager是主要的验证逻辑处理类,在SpringSecurity 中提供了ProviderManager实现类,核心代码如下:

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean{
   /// 身份校验处理器
   private List<AuthenticationProvider> providers = Collections.emptyList();

   @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        /// 循环使用Provider 来验证身份信息,只要有一个验证通过就算成功
        for (AuthenticationProvider provider : getProviders()) {
            /// 判断Provider是否支持验证Authentication子类类型,例如前面的UsernamepasswordAuthenticationToken
            if (!provider.supports(toTest)) {
                continue;
            }
            try {
                /// 使用具体的验证器进行验证,验证通过返回具体验证信息
                result = provider.authenticate(authentication);
                if (result != null) {
                    break;
                }
            }
            catch (AccountStatusException | InternalAuthenticationServiceException ex) {
                throw ex;
            }
            catch (AuthenticationException ex) {
                lastException = ex;
            }
        }
        /// 如果验证器无法验证,并且存在父级验证器那么使用父级验证器进行验证
        if (result == null && this.parent != null) {
            // Allow the parent to try.
            try {
                parentResult = this.parent.authenticate(authentication);
                result = parentResult;
            }
            catch (ProviderNotFoundException ex) {
            }
            catch (AuthenticationException ex) {
                parentException = ex;
                lastException = ex;
            }
        }
        /// 判断是否存在已验证结果,存在返回验证信息,不存在抛出一样信息
        if (result != null) {
            if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {

                ((CredentialsContainer) result).eraseCredentials();
            }

            if (parentResult == null) {
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        }
        throw lastException;
    }
}

在上述代码中最主要的是private List providers = Collections.emptyList();属性信息,ProviderManager委托该属性循环处理Authentication子类对象,直到验证通过或者是全部不通过, AuthenticationProvider接口定义如下:

public interface AuthenticationProvider {

    ///对待验证信息进行验证
    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    //// 判断当前验证器是否支持对该类型验证信息进行校验处理
    boolean supports(Class<?> authentication);

}

SpringSecurity中提供了对UsernamepasswordAuthenticationToken参数验证的AuthenticationProvider子类DaoAuthenticationProvider,相关接口实现如下:

  • 判断是否支持方法
/// 判断待验证参数authentication是否为UsernamePasswordAuthenticationToken类型或者是其子类
public boolean supports(Class<?> authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
  • 身份验证逻辑
@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = determineUsername(authentication);
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false;
            try {
                /// 依据用户名以及参数信息查找用户信息
                user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException ex) {
                /// 这里为了隐藏用户不存在错误,会对该错误进行包装,抛出新错误
                if (!this.hideUserNotFoundExceptions) {
                    throw ex;
                }
                throw new BadCredentialsException(this.messages
                        .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }
        }
        try {
            //// 信息校验前执行
            this.preAuthenticationChecks.check(user);
            //// 校验用户密码是否正确
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException ex) {
            if (!cacheWasUsed) {
                throw ex;
            }
            //// 如果使用的是缓存,那么进行绕过缓存再次验证防止缓存信息过期
            cacheWasUsed = false;
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            this.preAuthenticationChecks.check(user);
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        /// 校验结束处理
        this.postAuthenticationChecks.check(user);
        if (!cacheWasUsed) {
            this.userCache.putUserInCache(user);
        }
        Object principalToReturn = user;
        if (this.forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }
        /// 验证成功返回成功信息
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

整体流程图如下所示:

img

自定义验证流程

通过上述分析我们可以知道,自定义一个身份验证逻辑需要实现以下三个组件:

  1. 自定义验证参数类型:Authentication
  2. 自定义拦截过滤器:Filter
  3. 自定义特定验证参数类型验证器:AuthenticationProvider

下面我们将实现常用的手机验证码验证登录功能。

自定义验证参数

通过分析UsernamePasswordAuthenticationToken组件,我们知道该Token主要包装验证参数信息,方便后续使用,实现逻辑如下:

public class PhoneCodeAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private Object credentials;
    ///未验证参数构造器
    private PhoneCodeAuthenticationToken(String phone, String code) {
        super(null);
        this.principal = phone;
        this.credentials = code;
        /// 设置是否验证:false-未验证,true-已验证
        super.setAuthenticated(false);
    }
    ///已验证参数构造器
    /// authorities代表取得打权限信息
    private PhoneCodeAuthenticationToken(Object principal,
            Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
         /// 设置是否验证:false-未验证,true-已验证
        super.setAuthenticated(true);
    }

    /// 未验证Token
    public PhoneCodeAuthenticationToken unAuthToken(String phone, String code) {
        return new PhoneCodeAuthenticationToken(phone, code);
    }

    ////已验证Token
    public PhoneCodeAuthenticationToken authToken(Object principal,
            Collection<? extends GrantedAuthority> authorities) {
        return new PhoneCodeAuthenticationToken(principal, authorities);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

自定义拦截器

通过分析UsernamePasswordAuthenticationFilter组件,我们知道拦截器主要完成三个功能:

  1. 拦截特定请求
  2. 解析参数
  3. 委托给验证器进行验证处理

手机验证码登录拦截POST/phone/login请求,解析参数,包装成PhoneCodeAuthenticationToken对象,最后委托给AuthenticationManager组件验证,具体代码参见Gitee仓库:地址

运行验证

程序启动后发起POST请求,参数信息:

  • phone:15000000000
  • code:888888

请求成功后返回用户信息(SpringSecurity默认配置会将登录成功请求跳转到 / 路径):

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

至此完成手机验证码登录功能

我们使用POST请求,SpringSecurity默认提供csrf保护,会拦截 POST请求,因此需要禁用

总结

  • SpringSecurity 使用Servlet容器组件Filter功能进行请求拦截,实现身份校验以及权限控制
  • SpringSecurity 使用AuthenticationManager来实现身份校验功能(实际上你可以在Filter中直接完成身份验证功能,但是这种硬编码方式会增加程序耦合性,后期维护/扩展不方便)
  • SpringSecurity 中的AuthenticationManager委托多个AuthenticationProvider对请求参数进行校验
  • 自定义手机验证码验证流程需要实现三个类:
    • 继承Filter的PhoneCodeAuthenticationFilter, 对手机验证码登录请求进行拦截,并解析处请求参数信息,最后委托给AuthenticationManager进行身份校验
    • 继承Authentication的PhoneCodeAuthenticationToken
    • 登录时存放请求参数信息:手机号和验证码
    • 登录成功后存放用户信息:用户名、手机号、权限等
    • 继承AuthenticationProvider的PhoneCodeAuthenticationProvider,对请求参数进行验证,验证通过返回用户信息

有过SpringSecurity开发经验的同学会发现仓库中的代码使用HttpSecurity进行配置的方式与之前的方式不同,这是SpringSecurity官方在新版中推荐使用的方式,老版本的配置方式将会被遗弃,目前两种方式都可以使用

参考文档

三、SpringSecurity 动态权限访问控制

简介

在先前文章中我们搭建了SpringSecurity项目,并且讲解了自定义登录方式需要做哪些工作,如果你感兴趣可以前往博客阅读文章以及代码,在本文将继续讲解如何实现动态权限控制。

代码仓库:Gitee

代码分支:lesson3

目标

Web项目通常都有前台和后台服务,前台服务面向目标客户,后台服务为项目方提供管理和数据分析服务,因此不同的用户需要赋予不同的角色,例如前台用户角色为USER,后台用户为ADMIN,USER允许访问"/user/hello"接口,ADMIN允许访问"/admin/hello"接口,但是USER不能访问。这是项目必须有的基本功能,同时访问规则也会不断变化,例如: 有一个用户昵称功能,初期只允许会员用户(可以理解为拥有角色VIP的用户)使用,后期产品决定全员都可以使用,这种需求也很常见,如果采用硬编码的方式那么会导致频繁修改代码,测试、发布,增加额外工作量,如果可以动态配置接口访问权限,那么就能减少很多工作量,SpringSecurity框架提供了扩展点,基于这些扩展点可以很方便的实现动态权限控制访问功能,我们再来回顾一下需求:

  • 基于角色进行接口权限控制
  • 访问接口需要的角色可以动态配置

原理分析

通过上一篇文章我们知道SpringSecurity基于Filter实现身份验证和权限控制功能,SpringSecurity提供了实现类FilterSecurityInterceptor对访问路径进行权限控制,核心代码逻辑如下:

public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
        ///此处省略无关逻辑
        /// 在这里执行权限控制逻辑
        InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
}

在访问实际业务逻辑之前调用父级方法beforeInvocation进行权限判断,如果权限不符合要求,直接抛出异常阻止访问实际业务逻辑,核心代码如下:

protected InterceptorStatusToken beforeInvocation(Object object) {
        //此处省略无关代码
        //// 这里获取与访问路径相关的权限信息,例如:/user/hello 对应 ROLE_USER 角色,当然一个路径可能对应多个权限
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
        if (CollectionUtils.isEmpty(attributes)) {
            /// 这里注意如果对应的路径在系统中没有配置权限或者是获取方法没有处理这种请求会导致放行,特别注意
            return null; // no further work post-invocation
        }
        /// 未登录用户直接返回未验证错误
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
                    "An Authentication object was not found in the SecurityContext"), object, attributes);
        }
        //// 获取验证信息
        Authentication authenticated = authenticateIfRequired();
        // Attempt authorization
        /// 判断用户是否拥有访问权限
        attemptAuthorization(object, attributes, authenticated);
        /// 这里实现了类似Linux su 命令,将当前用户暂时赋予另外一个用户运行权限,可以先忽略不看
        // Attempt to run as a different user
        Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
        if (runAs != null) {
            SecurityContext origCtx = SecurityContextHolder.getContext();
            SecurityContext newCtx = SecurityContextHolder.createEmptyContext();
            newCtx.setAuthentication(runAs);
            SecurityContextHolder.setContext(newCtx);

            if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Switched to RunAs authentication %s", runAs));
            }
            // need to revert to token.Authenticated post-invocation
            return new InterceptorStatusToken(origCtx, true, attributes, object);
        }
        this.logger.trace("Did not switch RunAs authentication since RunAsManager returned null");
        // no further work post-invocation
        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);

    }

这里有两个重点内容:

  1. 通过this.obtainSecurityMetadataSource().getAttributes(object);方法来获取访问所需要的权限信息
  2. 通过attemptAuthorization(object, attributes, authenticated)方法对访问所需的权限以及用户身份信息进行决策,判断是否允许访问

路径权限分析

上述的this.obtainSecurityMetadataSource()方法返回SecurityMetadataSource类型对象,该接口核心代码如下:

public interface SecurityMetadataSource extends AopInfrastructureBean {
    /// 依据object获取权限信息,我们可以把ConfigAttribute理解为String类型,在我们系统中可以理解保存着角色信息,例如ROLE_USER
    Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException;
    ///获取系统中配置的所有权限信息,用于后续验证器判断是否支持该类型决策
    Collection<ConfigAttribute> getAllConfigAttributes();
    /// object 类型,用于判断SecurityMetadataSource支持解析的object类型
    boolean supports(Class<?> clazz);
}

可以看出SecurityMetadataSource的主要作用是给出当前访问需要哪些权限,方便后续判断,可以理解为一个数据源,用来获取访问权限列表

访问权限控制分析

这里我们需要重点查看方法attemptAuthorization(object, attributes, authenticated);包含对用户访问控制权限进行判断,核心代码如下:

private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
            Authentication authenticated) {
        try {
            /// 委托accessDecisionManager进行决策判断
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException ex) {
            /// 异常请求直接向上抛出异常信息
            throw ex;
        }
}

这个方法很简单,就是委托accessDecisionManager来进行访问决策,我们来看一下这个接口的核心代码:

public interface AccessDecisionManager {
    //// 对访问进行决策,判断是否有权限
    void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException;
    /// 查看访问控制器是否支持该类型决策
    boolean supports(ConfigAttribute attribute);
    /// 查看访问控制器是否支持特定类型,这个类型就是 上面方法中object对应的类型
    boolean supports(Class<?> clazz);
}

接口也很简单,有点类似AuthenticationProvider接口,调用decide方法,如果允许访问那么不进行任何处理,如果不允许访问就抛出异常信息。

代码实现梳理分析

上述核心逻辑很简单,但是实现逻辑有点绕,不要紧我们画个流程图再来梳理一遍(觉得绕主要是不相关代码对理解造成了困扰,还有可能就是被这种俄罗斯套娃形式绕晕了)

img

通过上述分析我们可以发现,权限控制需要两个核心功能:

  1. 访问路径所需要的权限(实现接口SecurityMetadataSource)
  2. 依据用户权限、路径所需权限进行决策判断是否允许访问(实现接口AccessDecisionManager)

完成上述功能后,将这些功能组装成FilterSecurityInterceptor类型对象,然后放置到SpringSecurity过滤链中实现过滤功能

代码实现

直接动手实现动态权限控制

实现路径权限获取

这里为了更加贴近实际项目,将提供一个RoleService作为数据源,实现代码如下:

@Service
public class RoleService {
    public List<ConfigAttribute> roles = new ArrayList<>();
    private Map<String, List<ConfigAttribute>> urlRoleMaps = new HashMap<>();
    @PostConstruct
    public void init() {
        /// 初始化数据
        roles.addAll(SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN", "ROLE_VIP"));
        urlRoleMaps.put("/", SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN"));
        urlRoleMaps.put("/user/hello", SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN"));
        urlRoleMaps.put("/user/nickname", SecurityConfig.createList("ROLE_VIP"));
        urlRoleMaps.put("/admin/hello", SecurityConfig.createList("ROLE_ADMIN"));
    }
    /// 获取所有角色信息
    public Collection<ConfigAttribute> getAllRoles() {
        return Collections.unmodifiableList(roles);
    }
    ///依据请求路径查询所需权限
    public Collection<ConfigAttribute> getRoleByPath(String path) {
        Collection<ConfigAttribute> roles = urlRoleMaps.get(path);
        if (roles == null) {
            return Collections.EMPTY_LIST;
        }
        return Collections.unmodifiableCollection(roles);
    }
}

代码很简单,就是初始化数据,提供路径与权限对应的数据服务,在实际项目中通常从数据库中获取这些信息。

下面编写RolePermissionMetadataSource接口的实现类,代码如下:

/// FilterInvocationSecurityMetadataSource 是SecurityMetadataSource的子接口,实际上就是 SecurityMetadataSource,没有扩展任何方法
public class RolePermissionMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private RoleService roleService;
    @Override
    public Collection<ConfigAttribute> getAttributes(Object object)
            throws IllegalArgumentException {
        FilterInvocation invocation = (FilterInvocation) object;
        String url = invocation.getRequestUrl();
        /// 通过请求路径获取访问路径所需的权限列表
        Collection<ConfigAttribute> roles = roleService.getRoleByPath(url);
        if (roles != null && roles.size() > 0) {
            return roles;
        }
        //没有匹配上的资源,禁止访问,设置不存在的访问权限
        // 通过之前的分析知道,如果这里返回空,将会直接放行,运行登录用户访问,这是有风险的
        return SecurityConfig.createList(RoleEnums.ROLE_REFUSE.name());
    }
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return roleService.getAllRoles();
    }
    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

实现路径访问控制决策类

继承接口AccessDecisionManager,核心代码如下:

public class PathAccessDecisionManager implements AccessDecisionManager {
    ///拒绝访问权限名称
    private static final String BASE_REFUSE_NAME = RoleEnums.ROLE_REFUSE.name();
    @Override
    public void decide(Authentication authentication, Object object,
            Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException {
        Iterator<ConfigAttribute> iterator = configAttributes.iterator();
        //进行权限匹配,如果用户拥有资源权限那么进行放行操作
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            // 当前请求需要的权限
            String needRole = ca.getAttribute();
            if (RoleEnums.ROLE_ANONYMOUS.name().equalsIgnoreCase(needRole)) {
                return;
            }
            if (BASE_REFUSE_NAME.equalsIgnoreCase(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    //匿名用户
                    throw new AccessDeniedException("资源信息不存在");
                } else {
                    //登录用户
                    throw new AccessDeniedException("权限不足!");
                }
            }
            // 当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equalsIgnoreCase(needRole)) {
                    return;
                }
            }
        }
        //如果当前请求没有验证,返回未验证异常
        if (authentication instanceof AnonymousAuthenticationToken) {
            throw new AccessDeniedException("用户未登录");
        }
        throw new AccessDeniedException("权限不足!");
    }
    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }
    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

组装Filter

我们将上述实现类与FilterSecurityInterceptor进行组装,实现权限动态过滤:

///动态权限控制 Filter, 默认会拦截所有请求进行权限判断
private FilterSecurityInterceptor filterSecurityInterceptor() {
    FilterSecurityInterceptor interceptor = new FilterSecurityInterceptor();
    /// 由于包含Spring Bean,因此需要注入实现,而不是直接new
    interceptor.setSecurityMetadataSource(rolePermissionMetadataSource);
    interceptor.setAccessDecisionManager(new PathAccessDecisionManager());
    return interceptor;
}
///加入到SpringSecurity过滤链中
httpSecurity.addFilterBefore(roleAuthFilter, FilterSecurityInterceptor.class);

运行验证

我们代码里创建了三个用户:

  1. 15000000000, 拥有:ROLE_USER
  2. 15666666666, 拥有:ROLE_USER、ROLE_VIP
  3. 15888888888, 拥有:ROLE_USER、ROLE_ADMIN

程序运行完成后使用15000000000进行手机验证码登录:

- POST http://localhost:8080/phone/login?phone=15000000000&code=888888

- 返回:

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

可以看到拥有ROLE_USER权限,那么我们访问 http://localhost:8080/user/hello, 返回:

{
    "code": 200,
    "data": "Hello User",
    "message": null
}

访问http://localhost:8080/admin/hello, 返回:

{
    "code": 400,
    "message": "请求受限"
}

我们看到以上结果符合预期,同理可以使用15888888888用户进行同样的访问操作,在这里我们就不做过多介绍,大家有兴趣可以下载代码自行运行测试,文章开头有代码地址。

如果要修改路径对应的权限,那么只要修改RoleService中的数据即可实现权限动态配置。

总结

通过上述文章分析,我们已经完成权限动态配置,当然运行中展现的JSON数据是配置了对应处理器处理的结果,细节处理请前往代码仓库下载源码自行查看。

为了完成动态权限我们需要完成三个步骤,实现两个接口,步骤如下:

  1. 实现路径权限数据访问接口(实现SecurityMetadataSource)
  2. 实现访问控制决策接口(实现AccessDecisionManager)
  3. 组装Filter并加入到过滤链中(FilterSecurityInterceptor)

熟练掌握上述步骤,实现动态权限控制将不再是难题。

在SpringSecurity 中不仅提供了FilterSecurityInterceptor实现类来对访问进行权限控制,在SpringSecyrity 5.4版本中还提供了AuthorizationFilter实现类来实现相同功能,具体实现方式自行前往仓库进行查看

参考文档

四、SpringSecurity OAuth2统一授权服务

代码

代码仓库:地址

代码分支:lesson4

简介

在先前文章中我们实战演练了在SpringBoot单体应用中使用SpringSecurity开发自定义登录流程以及动态权限控制,有兴趣的同学可以前往博客阅读SpringSecurity相关文章(所有代码都已上传到Gitee仓库, 每篇文章都有一个专属分支)。随着业务的扩张,单体应用无法满足业务需求,微服务是当前大型商业服务的主流架构,在Java领域中,SpringCloud 全家桶是微服务主流开发框架,Spring Cloud Alibaba 是在Spring Cloud 的基础上进行扩展,目的是为了更好的搭配使用Alibaba生态中的微服务组件(Nacos、Sentinel、Seata等),具体内容可查看文档

微服务框架如下所示:

img

上图中的服务集群代表具体业务服务,微服务下的权限控制是为了实现服务集群的安全访问,每个服务集群包含1~N个服务,如果每个服务都定义一套安全策略,那么后期维护将会是一个大工程,因此需要统一安全策略,实现全局安全访问。

OAuth2

OAuth是一个关于授权(authorization)的开放网络标准,2.0版本在全世界得到广泛应用,网上有很多讲解OAuth2的文档,如果你对OAuth没有概念,可以查看往期文章:理解OAuth2.0OAuth2.0的一个简单解释OAuth2.0的四种调用方式

假设我们是一家公司,公司内部有以下业务部门:

  • 微信:提供聊天、账户系统,同时对外提供账户授权登录服务
  • 电子支付:提供在线支付服务
  • 王者荣耀:提供王者荣耀游戏服务

img

为了实现各个业务系统之间相互调用,需要一套授权系统,对内外系统提供统一的授权访问服务,OAuth协议能够满足上述要求,只需要一套授权服务就能同时满足内外系统的调用要求。

SpringSecurity OAuth

OAuth 涉及四个角色:

  • 用户:实际拥有资源所有权的使用者,例如:张三
  • 客户端:提供应用功能的程序客户端,可以是APP形式、web形式,例如:时间海绵博客()
  • 授权服务:实现OAuth2授权协议,对外提供授权服务,例如:微信开发平台
  • 资源服务:对外提供资源访问服务,例如:我们使用微信的微信扫码登录时为网站提供用户信息的微信服务

下面以微信登录为例:

img

传统的模式中,我们默认web客户端是可信任的,所以没有用户授权的过程,可以访问任何数据。在OAuth协议中,客户端默认是不可信任的,需要进行授权处理。无论是内部客户端还是外部客户端都需要得到授权服务器的认证,但是内部和外部客户端可以使用不同的授权认证方式(比如最严格的授权码方式和最简单的客户端方式)。

授权服务器

使用 spring-security-oauth2 搭建授权服务器,对外提供以下功能:

  • /oauth/authorize 获取授权码
  • /oauth/token 提供客户端(client_credentials)、简化(implicit)、密码(password)、刷新Token(refresh_token),授权码(authorization_code)方式获取access_token
  • /oauth/check_token 依据access_token获取授权信息

同时需要管理客户端信息,客户端信息包含以下属性:

  • clientId 客户端Id
  • clientSecret 客户端密码
  • resourceId 资源Id,用于指定可以访问哪些资源服务
  • authorizedGrantTypes 授权模式,客户端(client_credentials)、简化(implicit)、密码(password)、授权码(authorization_code)
  • scopes 授权范围,可以指定客户端访问权限,这个在协议中没有明确指明作用,各个授权服务可以基于业务自行处理
  • authorities 权限,客户端授权模式下需要配置,其它模式下可以不配置
  • redirectUri 授权结果跳转地址

在先前的文章中提到了UserDetailsService用于查询用户信息,在OAuth中需要提供ClientDetailsService来查询客户端信息,代码如下:

public class AuthClientDetailService implements ClientDetailsService {
    private ClientDetailsService clientDetailsService;
    public AuthClientDetailService() {
        InMemoryClientDetailsServiceBuilder builder = new InMemoryClientDetailsServiceBuilder();
        builder.withClient("blog")// client_id
                .secret(new BCryptPasswordEncoder().encode("blog"))
                .resourceIds("blog", "resource")
                .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型 authorization_code,password,refresh_token,implicit,client_credentials
                .scopes("all", "user")// 允许的授权范围
                .autoApprove(false) //加上验证回调地址
                .authorities("blog")
                .accessTokenValiditySeconds(60 * 60 * 2) // 令牌默认有效期2小时
                .refreshTokenValiditySeconds(60 * 60 * 24 * 3) // 刷新令牌默认有效期3天
                .redirectUris("https://blog.hzchendou.com");
        try {
            this.clientDetailsService = builder.build();
        } catch (Exception e) {
            System.exit(-1);
        }
    }
    @Override
    public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
        return this.clientDetailsService.loadClientByClientId(clientId);
    }
}

同时需要引入AuthorizationServerConfigurer对授权服务进行配置,代码如下:

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private AuthClientDetailService authClientDetailService;
    @Autowired
    private AuthenticationManager authenticationManager;
    /**
     * 配置客户端信息
     *
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(authClientDetailService);
    }
    /**
     * 配置OAuth token相关配置
     *
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)/// 用于密码模式验证时需要提供客户端身份验证
                .reuseRefreshTokens(false);
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.authenticationEntryPoint(new AuthAuthenticationEntryPoint());
        security.tokenKeyAccess("permitAll()")//oauth/token_key是公开
                .checkTokenAccess("permitAll()");//oauth/check_token公开
        /// 配置使用 客户端id 和密码的方式进行登录(使用明文传输,不推荐) 
        AuthClientCredentialsTokenEndpointFilter endpointFilter = new AuthClientCredentialsTokenEndpointFilter(security);
        endpointFilter.afterPropertiesSet();
        endpointFilter.setAuthenticationEntryPoint(new AuthAuthenticationEntryPoint());
        // 客户端认证之前的过滤器
        security.addTokenEndpointAuthenticationFilter(endpointFilter);
    }
}

使用@EnableAuthorizationServer注解配置会自动配置AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint接口,提供OAuth授权服务。

至此完成授权服务搭建

资源服务器搭建

资源服务器也是使用spring-security-oauth2进行搭建,通过上面的介绍,我们知道资源服务器需要识别access_token来获取用户授权的信息内容,配置信息如下:

@EnableResourceServer
@Configuration
public class SpringSecurityResourceServerConfig extends ResourceServerConfigurerAdapter {
    public static final String RESOURCE_ID = "resource";
    @Autowired
    TokenStore tokenStore;
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.formLogin().disable();
        httpSecurity.exceptionHandling()
                .accessDeniedHandler(new PathAccessDeniedHandler())
                .authenticationEntryPoint(new AuthAuthenticationEntryPoint());
        httpSecurity.authorizeRequests()
                .antMatchers("/admin/**").hasAuthority("admin")
                .antMatchers("/user/**").hasAuthority("user")
                .and().csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        //当前资源服务 id,用于校验授权信息是否能够访问
        resources.resourceId(RESOURCE_ID)
                .tokenStore(tokenStore)
                /// 设置Token服务,用于识别access_token授权信息
                .tokenServices(tokenService())//验证令牌的服务
                .stateless(true);
    }

    //资源服务令牌解析服务
    @Bean
    public ResourceServerTokenServices tokenService() {
        //使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
        RemoteTokenServices service = new RemoteTokenServices();
        service.setCheckTokenEndpointUrl("http://localhost:8081/oauth/check_token");
        service.setClientId("blog");
        service.setClientSecret("blog");
        return service;
    }
}

通过配置@EnableResourceServer注解,将会在Filter过滤链中添加OAuth2AuthenticationProcessingFilter过滤器拦截请求,依据access_token解析授权信息, 查看请求头是否有Authorization属性,该属性代表access_token信息。

OAuth服务验证

启动授权服务以及资源服务,访问 POST http://localhost:8081/oauth/token,请求参数如下所示(使用密码模式访问):

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

返回结果:

{
    "access_token": "db9898be-2aef-4f86-9486-b3735db6403e",
    "token_type": "bearer",
    "refresh_token": "44d9aeff-8839-4a9b-b6ea-1ea6310bab5e",
    "expires_in": 6971,
    "scope": "all user"
}

启动资源服务,访问 GET http://localhost:8082/admin/hello,请求头信息如下

Authorization:Bearer db9898be-2aef-4f86-9486-b3735db6403e

返回信息如下:

{
    "code": 200,
    "data": "Hello Admin"
}

说明访问成功

总结

SpringSecurity中的功能都是通过组装Filter链来完成特定功能实现,

  • SpringSecurity OAuth授权服务需要提供对外服务,因此还提供了AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint等接口模块,当然你也可以用自己的实现来替换这些服务。
  • SpringSecurity OAuth 资源服务提供了OAuth2AuthenticationProcessingFilter过滤器来解析access_token授权信息,获得权限信息,后续的权限验证流程与先前的SpringSecurity单体应用是一致的

参考文档

五、SpringSecurity OAuth2扩展自定义授权模式

代码

代码仓库:地址

代码分支:lesson5

简介

在上一篇文章中,我们使用SpringSecurity OAuth2搭建了一套授权服务,对业务系统进行统一授权管理。OAuth提供了四种授权方式:

  • 授权码模式(authorization_code)
  • 简化模式(implicit)
  • 客户端(client_credentials)
  • 密码(password)

在实际业务中上述四种模式不能满足所有要求,例如业务系统接入了短信验证码登录方式,需要进行扩展满足业务需求

手机验证码登录

原理分析

SpringSecurity OAuth在使用@EnableAuthorizationServer注解会自动装配TokenEndpoint对象,这个对象会提供一个POST /oauth/token接口,我们以密码授权模式分析调用流程,如下所示:

img

用户在时间海绵博客发起用户名密码登录请求,时间海绵博客服务器端接收到请求后,调用OAuth协议中的密码授权模式发送请求到OAuth授权服务器,请求信息如下:

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

在ClientCredentialsTokenEndpointFilter过滤器中对客户端信息(client_id和client_secret)进行校验,授权成功后才能访问TokenEndpoint接口(这里要注意,对于OAuth授权服务器来说,过滤链主要完成对客户端信息的校验,用户信息在TokenEndpoint中进行校验,这是因为不同的授权模式关注的用户信息类型不同,需要具体问题具体分析)。

在TokenEndpoint中依据验证的客户端信息以及请求的授权模式进行对比,校验客户端是否有权限进行特定类型授权请求,校验通过后委托给TokenGranter组件进行具体授权模式校验,如上图所示,ResourceOwnerPasswordTokenGranter负责对密码授权模式请求校验,SpringSecurity OAuth还提供了以下实现来校验授权请求:

  • AuthorizationCodeTokenGranter负责校验授权码模式(authorization_code)请求
  • ClientCredentialsTokenGranter负责校验客户端模式(client_credentials)请求
  • ImplicitTokenGranter负责校验简化模式(implicit)请求
  • ResourceOwnerPasswordTokenGranter负责校验密码模式(password)请求

TokenGranter校验成功将返回AccessToken信息,后续客户端可以使用AccessToken信息获取到授权用户信息完成对应操作。

通过上述可以知道,如果要扩展实现短信验证码模式,需要自定义实现TokenGranter组件来校验手机验证码授权请求,TokenGranter定义如下所示:

public interface TokenGranter {
    //// 对授权类型以及请求参数进行处理,如果成功则返回AccessToken信息
    OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest);
}

手机验证码模式代码实现

实现一个继承ToeknGranter接口的类,通过分析已有的TokenGranter子类,我们可以很容易实现,定义一个SmsCodeTokenGranter类,代码实现如下所示:

public class SmsCodeTokenGranter extends AbstractTokenGranter {

    /// 授权模式类型,需要与请求字段grant_type的值相等才会进入处理
    private static final String GRANT_TYPE = "sms_code";
    /// 验证手机与验证码信息是否匹配,这里只是简单的进行匹配处理,判断是否是否为15000000000,验证码是否为:888888
    private final PhoneSmsCodeService phoneSmsCodeService;

    public SmsCodeTokenGranter(AuthorizationServerTokenServices tokenServices,
            ClientDetailsService clientDetailsService,
            OAuth2RequestFactory requestFactory, PhoneSmsCodeService phoneSmsCodeService
    ) {
        super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
        this.phoneSmsCodeService = phoneSmsCodeService;
    }

    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

        Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());

        String mobile = parameters.get("mobile"); // 手机号
        String code = parameters.get("code"); // 短信验证码
        ///对参数进行基础校验
        if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(code)) {
            throw new InvalidGrantException("授权请求参数异常");
        }
        /// 校验手机验证码是否符合要求
        if (!phoneSmsCodeService.checkSmsCode(mobile, code)) {
            throw new InvalidGrantException("授权请求参数异常");
        }

        /// 这里为了简单直接硬编码写入用户信息,通常需要在数据库中取出用户相关信息
        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add( new SimpleGrantedAuthority("user"));
        User user = new User(mobile, "", roles);
        /// 授权模式
        UsernamePasswordAuthenticationToken userAuth = new UsernamePasswordAuthenticationToken(user, null, roles);
        userAuth.setAuthenticated(true);
        OAuth2Request storedOAuth2Request = this.getRequestFactory()
                .createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, userAuth);
    }
}
继承AbstractTokenGranter抽象类(该类继承了TokenGranter接口),我们只需要关注核心逻辑"验证手机验证码信息是否正确",校验正确将返回一个OAuth2Authentication对象,这个对象包含了用户信息以及OAuth2请求信息。
完成这一步后,我们需要将SmsCodeTokenGranter装配到TokenEndpoint组件中对sms_code授权类型进行校验处理,配置逻辑如下:
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)/// 用于密码模式验证时需要提供客户端身份验证信息
                .reuseRefreshTokens(false);
        //// 取出系统中的四种模式
        List<TokenGranter> granters = new ArrayList<>(Arrays.asList(endpoints.getTokenGranter()));
        /// 添加手机验证码的授权模式
        granters.add(new SmsCodeTokenGranter(endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), phoneSmsCodeService));
        /// 这是一个组装模式,实现了TokenGranter接口,循环调用List中的TokenGranter组件进行校验处理,直到返回验证成功信息或者是异常信息
        CompositeTokenGranter compositeTokenGranter = new CompositeTokenGranter(granters);
        endpoints.tokenGranter(compositeTokenGranter);
    }

上述在AuthorizationServerConfigurerAdapter配置类中进行,具体参见代码。

运行校验

需要注意,这是一个新的授权模式,因此需要先授权客户端拥有手机短信验证模式请求权限,配置客户端的authorizedGrantTypes属性包含sms_code权限(这一步参见代码)。

启动服务,发送手机验证码授权请求POST /oauth/token,请求参数:

client_id:blog
client_secret:blog
grant_type:sms_code
mobile:15000000000
code:888888

返回参数信息:

{
    "access_token": "cf4243c1-085e-4f82-b733-03fb38c90a7c",
    "token_type": "bearer",
    "refresh_token": "01474dd1-c5c5-436b-98b1-8f146cde2391",
    "expires_in": 7199,
    "scope": "all user"
}

手机短信验证码授权模式验证成功,具体代码参见代码仓库。

总结

  • 扩展自定义授权模式,需要继承TokenGranter接口,实现具体校验逻辑
  • 将实现的自定义授权TokenGraner类装配到TokenEndpoint组件中
  • 特别注意需要将新的授权模式grantType信息授权给指定的客户端,不然客户端无法发送自定义授权模式请求,例如本案例中的sms_code请求

参考文档

六、SpringSecurity OAuth2 + SpringCloud Gateway实现统一鉴权管理

代码

代码仓库:地址

代码分支:lesson6

简介

在先前文章中,我们使用SpringSecurity OAuth2搭建了一套基于OAuth2协议的授权系统,并扩展了手机验证码授权模式。在微服务架构下,网关承担着流量入口的角色,所有的请求都要先经过网关,然后由网关负责转发到具体的服务,因此可以在网关实现统一鉴权,网关对请求中的权限进行鉴定,然后将权限信息转发到具体的资源服务,在资源服务中只需要简单校验请求中的权限信息即可(查看信息是否有效),整体流程如下所示:

img

统一鉴权

SpringCloud Gateway网关

我们在上一篇的基础上引入网关服务,在这里使用SpringCloud Gateway组件进行搭建,引入依赖:

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

网关在OAuth2授权协议中承担着资源服务的角色,对请求进行身份鉴定和访问权限控制,身份鉴定需要访问OAuth2授权服务,因此需要引入OAuth2资源服务以及客户端依赖:

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

通过之前的文章,我们可以知道SpringSecurity 通过组装一系列的Filter来完成身份验证和权限访问控制功能,但是SpringCloud Gateway使用了新技术框架Reactive Stack(响应式编程),在Spring中提供了Spring WebFlux模块支持响应式编程,传统的Spring MVC都是基于阻塞I/O编程,而Spring WebFlux是基于非阻塞I/O,我们不再这里讨论这两个的区别,只需要知道WebFlux特别适合I/O密集型性应用,网关就是典型的I/O密集应用(网络I/O处理频繁)。SpringSecurity对WebFlux提供了支持,在WebFlux中WebFilter组件承担着与Filter相似的功能。

我们在先前的应用中通过HttpSecurity组件来组装SpringSecurity功能,在这里要使用新的组件ServerHttpSecurity来组装SpringSecurity功能,配置如下所示:

///启用WebFlux下的SpringSecurity配置
@EnableWebFluxSecurity
public class ResourceServerConfig {
    //// 访问权限验证
    @Autowired
    AuthManagerHandler authManagerHandler;
    //// 无权限访问处理器
    @Autowired
    AccessDeniedHandler accessDeniedHandler;
    /// 登录信息失效处理器
    @Autowired
    LoginLoseHandler loginLoseHandler;
    ////访问白名单,对白名单路径可以实现匿名访问
    @Autowired
    private WhiteUrlProperties whiteUrlProperties;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer()
                /// 这里配置对令牌的校验,从OAuth2授权服务中获取令牌对应的授权信息
                .opaqueToken()
                ///令牌校验地址,用于校验令牌是否有效,已经令牌对应的授权信息
                .introspectionUri("http://localhost:8081/oauth/check_token")
                //// 客户端信息
                .introspectionClientCredentials("blog", "blog")
                .and()
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(loginLoseHandler)
                .and().authorizeExchange()
                .pathMatchers(HttpMethod.OPTIONS).permitAll() //o
                .pathMatchers("/**").access(authManagerHandler)
                .anyExchange().authenticated()
                .and()
                .addFilterBefore(securityGlobalFilter(whiteUrlProperties), SecurityWebFiltersOrder.FIRST)
                .cors().disable().csrf().disable();
        return http.build();
    }
    /// 该过滤器实现将获取到的授权信息转发到下游服务中,方便后续校验
    public WebFilter securityGlobalFilter(WhiteUrlProperties properties) {
        return new SecurityGlobalFilter(properties);
    }

}

网关路由配置以及其它细节信息可以前往代码仓库进行查看,在此不做过多解释。

资源服务器

资源服务器也需要做一些调整,不需要对请求进行严格的访问控制,只需要校验网关传递的授权信息即可,然后将授权信息放入到SecurityContext中方便后续处理,同时需要注意在资源服务中还是使用Spring MVC框架进行处理(Spring WebFlux可以提高系统吞吐量,但是也会增加编程难度,例如原先的线程变量将不适用,具体需要考量整体编程人员掌握的技术栈来做决定)。

这里的资源服务器不再依赖OAuth授权服务,因此可以移除@EnableResourceServer配置(不直接参与权限控制,只需要校验上游传递的授权信息是否有效即可),同时增加对上游SpringCloud Gateway传递的授权信息进行解析处理,增加自定义SecurityAuthTokenFilter组件:

public class SecurityAuthTokenFilter extends OncePerRequestFilter {
    private static final String AUTH_TOKEN_NAME = "token";
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(AUTH_TOKEN_NAME);
        if (StringUtils.isEmpty(token)) {
            /// 继续处理
            filterChain.doFilter(request, response);
            return;
        }
        ///....省略处理细节,具体前往代码仓库进行查看
        //// 创建自定义的Authentication对象,必须申明为已授权,也就是isAuthenticated()方法返回为true
        BlogAuthentication authentication = new BlogAuthentication(userId, clientId, authorities);
        //....省略处理细节,具体前往代码仓库进行查看
        /// 将授权信息放入到SecurityContext中,方便后续使用
        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }
}

运行验证

分别运行Gateway网关服务、OAuth授权服务、Resource资源服务

  • Gateway 8080端口
  • OAuth 8081端口
  • Resource 8082端口

授权登录

使用密码模式进行授权登录,发送请求POST http://localhost:8080/blog-oauth/oauth/token,请求参数

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

返回结果:

{
    "access_token": "4aace702-cc9d-4a92-b507-9b65f192a65f",
    "token_type": "bearer",
    "refresh_token": "a50a6cff-97b0-4d0f-b3d2-e0fdcee6f142",
    "expires_in": 5591,
    "scope": "all user"
}

资源访问

使用得到的access_token访问资源服务器中的/admin/hello接口,发送请求GET http://localhost:8080/blog-resource/admin/hello,请求头中携带参数

Authorization:Bearer 4aace702-cc9d-4a92-b507-9b65f192a65f

返回结果:

{
    "code": 200,
    "data": "Hello Admin"
}
访问其他权限的接口,发送请求GET http://localhost:8080/blog-resource/user/hello,请求头中携带参数:
Authorization:Bearer 4aace702-cc9d-4a92-b507-9b65f192a65f

返回结果:

{
    "code": 400,
    "message": "无权限访问"
}

至此得到期望的访问结果,实现了统一权限控制

总结

  • SpringCloud Gateway使用WebFlux技术进行开发
  • SpringSecurity提供了@EnableWebFluxSecurity来支持WebFlux
  • SpringSecurity使用ReactiveSecurityContextHolder.getContext()来实现SecurityContextHolder功能

参考文档

七、SpringSecurity OAuth2 + JWT + SpringCloud Gateway实现统一鉴权管理

代码

代码仓库:地址

代码分支: lesson7

简介

在上一篇文章中,我们使用SpringSecurity OAuth2 + SpringCloud Gateway搭建了一套符合微服务架构的授权系统,在Gateway网关实现统一身份鉴定、访问权限控制,同时将授权信息下发到下游业务服务中,下游业务服务只需要关注核心业务逻辑。上述架构依赖于auth授权服务器,每一次业务请求都需要使用access_token请求auth授权服务器来获取用户授权信息,如果access_token自带授权信息,那么网关只需要鉴别access_token有效信息,这将会降低系统对auth授权服务器的依赖,JWT(JSON Web Token)将是很好的选择。

JWT

我们这里不详细介绍JWT,有兴趣的同学可以查看阮一峰老师的文章:JWT入门教程。JWT定义了一种数据结构,它由三部分组成:

  • Header,头部,定义了签名算法,令牌类型
  • Payload,负载,是一个JSON对象,包含实际应用中使用的数据,例如用户名,用户角色,注意这部分内容是不加密的,因此不能包含保密信息
  • Signature,签名,用于验证JWT是否有效,防止信息内容篡改

JWT内容是不加密的,可以使用在线工具解码信息,查看内容。例如有一个JWT格式Token:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk0MTc2LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiI2NTdjZmU0Yi05ZDBlLTRhNTUtYjJjOS1iZWE3MTA2YWJkYWIiLCJjbGllbnRfaWQiOiJibG9nIn0.CGQTlvCCwGWIuJBy_qNeX2YBEYYTy6W1FPXOll75P1jdEyvi_TDiTLE4AO2Fa9vtgdWKrtywgGi4kFWZw8mcRFmhVfl9ehdoPcN5Hmdnz-ybJuLWh0i1k0xqg6MsZryTR1wAweEggZkHsIdCZfOw-yPZFTKuhAgVL4d-12Uthb4

在线解析后得到信息如下:

img

项目改造优化

优化分析

在先前文章中,我们将授权信息保存在auth授权服务器中,客户端需要通过请求auth授权服务器来获取授权信息,如果使用JWT,并且在JWT中保存相关授权信息,那么可以直接解析JWT就可以获取授权信息(需要验证JWT是有有效)。

在先前的项目中,我们使用SpringSecurity OAuth2默认配置来创建access_token,实际上SpringSecurity OAuth2提供了对JWT格式access_token支持,我们需要更改access_token的生成方式,因此需要修改auth授权服务中的Token生成方式,同时需要对Gateway网关服务中的Token解析方式进行修改

生成JWT格式Token

使用SpringSecurity OAuth2时,如果没有配置TokenService对象,将会默认使用DefaultTokenServices组件来管理access_token, 使用UUID.randomUUID().toString()方式生成access_token和refresh_token,因此token中不包含任何信息。我们需要配置新的TokenService对象来生成JWT格式Token。

SpringSecurity OAuth2提供了以下组件来生成JWT格式Token:

  • JwtTokenStore实现了TokenStore接口,用来管理access_token和refresh_token
  • JwtAccessTokenConverter实现了TokenEnhancer, AccessTokenConverter接口,可以生成JWT格式token

JWT签名算法我们选用安全性更高的非对称加密算法:RSA(在代码auth/src/test/java/com/hzchendou/blog/demo/RSAKeyTest中提供生成RSA Key方法),配置TokenService:

@Bean
public TokenStore tokenStore() {
   return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
  JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
  converter.setKeyPair(keyPair()); //非对称秘钥,具体参见代码
  return converter;
}

@Bean
public AuthorizationServerTokenServices tokenService() {
   DefaultTokenServices service = new DefaultTokenServices();
   service.setClientDetailsService(authClientDetailService);
   service.setSupportRefreshToken(true);
   service.setTokenStore(tokenStore);
   //令牌增强
   TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
   List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
   tokenEnhancers.add(tokenEnhancer);
   tokenEnhancers.add(accessTokenConverter);
   tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);

   service.setTokenEnhancer(tokenEnhancerChain);
   service.setAccessTokenValiditySeconds(60 * 60 * 2); // 令牌默认有效期2小时
   service.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3); // 刷新令牌默认有效期3天
   return service;
}

将TokenService配置到OAuth服务中:

endpoints.tokenServices(tokenService());//令牌管理服务

解析JWT格式Token

在网关中需要配置JWT格式解析器,使用JwtAuthenticationConverter来解析JWT中的字段:

@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
  JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
  jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
  jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
  JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
  jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
  return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}

因为使用RSA签名算法,因此在Gateway中需要配置RSA公钥来验证Token有效性, 有多种方式可以配置JWT解析验证器来验证JWT的有效性:

方法一、SpringSecurity OAuth2提供的方式:

配置public key信息来验证JWT有效性,在配置文件中配置(配置获取公约的接口地址):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8081/key/public-key /// 这里需要在oauth授权服务器中配置接口

@RestController
public class KeyController {
    @Autowired
    KeyPair keyPair;
    //获取公钥
    @GetMapping("/key/public-key")
    public Map<String, Object> getPublicKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

方法二、直接配置PublicKey方式(直接将公钥写入到配置文件中进行读取):

我们这里直接将public key 配置到gateway网关中:

jwt:
  rsa:
    publickey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCEfoWyfxqYz6j6tczCoELJfCwxpC+iHox7YEvz6slxNworp+CQAC86qt4Rx14lijoufiBMol0/mAABlG1lv3K1LOgQGcwueZDY5nk0uabOWv787moVbQHRTQoAwMIeSDPQ3SgSoEFyHM6Jj/We7XUpAyQEXKk9AabAvywEk2u9ewIDAQAB

然后手动创建JwtDecoder:

@Slf4j
@Configuration
public class TokenConfig {
    @Value("${jwt.rsa.publickey}")
    private String publicKey;
    public RSAPublicKey rsaPublicKey() {
        try {
            return (RSAPublicKey)RSAUtils.decodePublicKey(publicKey);
        } catch (Exception ex) {
            log.error("生成 KeyPair 失败", ex);
            System.exit(-1);
            return null;
        }
    }
    @Bean
    public NimbusReactiveJwtDecoder nimbusReactiveJwtDecoder() {
        return NimbusReactiveJwtDecoder.withPublicKey(rsaPublicKey())
                .signatureAlgorithm(SignatureAlgorithm.from("RS256")).build();
    }
}

还有一步需要配置,JWT Token解析后的类型是JwtAuthenticationToken,因此需要修改SecurityGlobalFilter中ReactiveSecurityContextHolder.getContext()方法返回的authentication类型(具体参见代码)

运行校验

分别运行auth、resource、gateway服务:

  • gateway - 8080
  • auth - 8081
  • resource - 8082

发起OAuth2密码授权请求: POST http://localhost:8080/blog-oauth/oauth/token,请求参数

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

请求结果:

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk4MzY3LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiIyMmVlOTU4My00Y2U5LTRmNzEtOGI4MS02YjViNzdmYWNlYWEiLCJjbGllbnRfaWQiOiJibG9nIn0.atWwzwpCK1ycjf3-EkPUYs4DMqO7rGPIMwMjHKS3FrTKRjMW5DHkQjtilG2EB8qGNBlwQJo0xAnQ_RNMzOjVojGxyb-TUPCubqODnmnYhuee0ho2TurDT5YzfO-Ypkv2SDqEm6Kw38m-oV_93NofGtKNJD1or2kwdoZe6kn4qgw",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiYXRpIjoiMjJlZTk1ODMtNGNlOS00ZjcxLThiODEtNmI1Yjc3ZmFjZWFhIiwiZXhwIjoxNjU0ODUwMzY3LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiJjMzZhNDQ2Mi00ZmE3LTQ2OGUtODNiMS1iYzk4MDFjMzBjMWEiLCJjbGllbnRfaWQiOiJibG9nIn0.IyBeBQMjU-KYGIvvlQTrTkEtrPmTjLZIl1oFvyK0vytOlOFaE4Q5tMOLf1lt1UaBpmi2Tz4ElQSc6EMYX_OKmbyEHSidYxseUr8gE5MVM1raqOPCnR0Dyn7okQ0NvArOB9JuxLTXSa3NoSM3OxRQm2sUS55e6FKpifZ2q7xgGnY",
    "expires_in": 7199,
    "scope": "all user",
    "ex_username": "admin",
    "jti": "22ee9583-4ce9-4f71-8b81-6b5b77faceaa"
}

发起资源服务器请求:POST http://localhost:8080/blog-resource/admin/hello,请求头携带token参数

Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk2OTQyLCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiJkOWRhZjhmYS1jOTY4LTQ2YzQtODMyMi1kOTQxNGU3YWZhY2UiLCJjbGllbnRfaWQiOiJibG9nIn0.Jkz_Tlk1W7opXspihyDkp1VFcIXu3ebfVPkshjFcKpktPmqkUIA4D2aWF5A13fq5QGUIDQKf89rVeGHaFfer657J7kqaax2qNT6yuNgmQAu4C8VQkG01VLDsOa-m9xaZnqR_--Af-Z7FbwpZNOT2pBuyP4M3efnMmGhRQQjB4hQ

返回结果:

{
    "code": 200,
    "data": "Hello Admin"
}

结果符合预期,到此完成JWT + SpringSecurity OAuth2 + SpringCloud Gateway 统一权限访问控制功能

总结

  • JWT自带用户信息,只需要验证Token有效性
  • OAuth授权服务添加JWT TokenService返回JWT格式token
  • Gateway网关服务添加JWTAuthenticationConverter解析JWT信息,同时添加NimbusReactiveJwtDecoder对JWT有效性进行验证

参考文档

Views: 401