Featured image of post Spring Cloud微服务网关Zuul过滤链和整合OAuth2+JWT入门实战

Spring Cloud微服务网关Zuul过滤链和整合OAuth2+JWT入门实战

一、Spring Cloud Zuul 过滤链

1.1 工作原理

Zuul的核心逻辑是由一系列的Filter来实现的,他们能够在进行HTTP请求或者相应的时候执行相关操作。Zuul Filter的主要特性有一下几点:

  • Filter的类型:Filter的类型决定了它在Filter链中的执行顺序。路由动作发生前、路由动作发生时,路由动作发生后,也可能是路由过程发生异常时。
  • Filter的执行顺序:同一种类型的Filter可以通过filterOrder()方法来设定执行顺序
  • Filter的执行条件:Filter运行所需要的条件
  • Filter的执行效果:符合某个Filter执行条件,产生的执行效果

Zuul内部提供了一个动态读取、编译和运行这些Filter的机制。Filter之间不能直接通信,在请求线程中通过RequestContext来共享状态,它的内部是用ThreadLocal实现的。

上图描述了Zuul关于Filter的请求生命周期。

  • pre:在Zuul按照规则路由到下级服务之前执行。如果需要对请求进行预处理,比如鉴权、限流等,可在考虑在这类Filter中实现。
  • route:这类Filter是Zuul路由动作的执行者,是Http客户端构建和发送HTTP请求的地方。
  • post:这类Filter是在原服务返回结果或者异常信息发生后执行,如果需要对返回信息做一些处理,可以在此类Filter进行处理。
  • error:在整个生命周期内如果发生异常,则会进入error Filter,可以做全局异常处理

其中post Filter抛出错误分成两种情况:

1)在post Filter抛错之前,pre、route Filter没有抛错,此时会进入ZuulException的逻辑,打印堆栈信息,然后再返回status=500的Error信息

2)再post Filter跑错之前,pre、route Filter已有跑错,此时不会打印堆栈信息,直接返回status=500的error信息。

也就是说整个责任链中重点不只是post Filter,还可能是error Filter。

在实际项目中,需要子实现以上类型的Filter来对链路进行处理,根据业务的需求,选取对应生命周期的Filter来达到目的。每个Filter之间通过RequestContext(Zuul包中)类来进行通信,内部采用ThreadLocal保存每个请求的一些信息,包括请求路由,错误信息,HttpServletRequest,HttpServletResponse,这使得一些操作十分可靠,它害扩展了ConcurrentHashMap,目的是为了在处理过程中保存任何形式的信息。

1.2 Zuul中的原生Filter

Zuul Server通过@EnableZuulProxy开启之后,搭配Spring Boot Actuator,会多两个管控断点。

在配置文件中配置一下:

1
2
3
4
5
management:
  endpoints:
    web:
      exposure:
        include: 'routes,filters'

1、/route:返回当前Zuul Server中已生成的映射规则,加上/details可查看明细。例如

每个路由的详细信息

2、/filters:返回当前Zuul Filter中已注册生效的Filter

从Filter的信息可以看到,所有已经注册生效的Filter的信息:Filter实现类的路径、Filter执行次序、是否被禁用、是否静态。而且很明显地可以看出Zuul内Filter的整个请求的生命流程,如下图:

Zuul中各内置的Filter:

名称 类型 次序 描述
ServletDetectionFilter pre -3 通过Spring Dispatcher检查请求是否通过
Servlet30WrapperFilter pre -2 适配HttpServletRequest为Servlet30RequestWrapper对象
FormBodyWrapperFilter pre -1 解析表单数据并为下游请求重新编码
DebugFiter pre 1 Debug路由表示
PreDecorationFilter pre 5 处理请求上下文共后续使用,设置下游相关信息头
RibbonRoutingFilter route 10 使用Ribbon、Hystrix或者嵌入式HTTP客户端发送请求
SimpleHostRoutingFilter route 100 使用Apache Httpclient转发请求
SendForwardFilter route 500 使用Servlet转发请求
SendResponseFilter post 1000 将代理请求的响应写入当前相应
SendErrorFilter error 0 如果RequestContext.getThrowable()不为空,则转发到error.path配置的路径

上表为使用@EnableZuulProxy之后安装的Filter,当使用@EnableZuulServer将会缺少PreDecorationFilter、RibbonRoutingFilter、SimpleHostRoutingFilter。这些原生的Filter可以关掉,例如:在配置文件里面配置zuul.SendErrorFilter.error.disable=true

1.3 多过滤器组成过滤链

在实际中我们不仅是只定义一个过滤器,而是多个过滤器组成过滤链来完成工作,除了Zuul的其他网关也是有这个功能

要在Zuul中自定义Filter子需要继承ZuulFilter即可。它是个抽象类,主要实现的几个方法:

  • String filterType():使用返回值定义Filter的类型,有pre、route、post、error
  • int filterOrder():使用返回值设置Filter的执行顺序
  • boolean shouldFilter():使用返回值设置Filter是否执行,即所定义Filter的开关
  • Object run():Filter里面的核心执行逻辑便需要写在该方法里面

自定义一个前置过滤器,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CustomPreFilter extends ZuulFilter {
    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        LOG.info("This is custom pre filter...");
        return null;
    }
}

FirstPreFilter注入到Spring Bean容器

1
2
3
4
5
6
7
8
@Configuration
public class ZuulFilterConfig {

    @Bean
    public CustomPreFilter customPreFilter() {
        return new CustomPreFilter();
    }
}

然后启动分别启动eurekazuulservice-a,访问http://localhost:88/servicea/add?a=1&b=2。观察网关的日志输出

INFO 20260 — [ XNIO-1 task-1] c.m.better.zuul.filter.CustomPreFilter : This is custom pre filter…

到这可以看到定义一个Zuul过滤器其实很简单,对于微服务网关来说不仅是Zuul,其他的微服务网关也是,很大部分的开发工作都是开发各种过滤器来达到我们目的。现在来实现一个简单的参数校验功能:

FirstPreFilter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class FirstPreFilter extends ZuulFilter {
    private Logger log = LoggerFactory.getLogger(FirstPreFilter.class);

    @Override
    public String filterType() {
        // 自定义的过滤器类型为前置过滤器
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        // 自定义过滤器的执行次序
        return 2;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        log.info("first pre filter...");
        // 拿到请求上下文
        RequestContext requestContext = RequestContext.getCurrentContext();
        // 拿到HttpServletRequest
        HttpServletRequest request = requestContext.getRequest();
        // 获取传入的参数值
        String a = request.getParameter("a");
        if (StringUtils.isBlank(a)) {
            // 禁止路由,也就是不允许访问下游服务
            requestContext.setSendZuulResponse(false);
            // 设置响应结果,供PostFilter使用,参数是字符串,序列化一下返回对象也行。
            ObjectMapper mapper = new ObjectMapper();
            Map<String, Object> map = new HashMap<>();
            map.put("code", -1);
            map.put("msg", "参数a不能为空");
            String result = null;
            try {
                result = mapper.writeValueAsString(map);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
            requestContext.setResponseBody(result);
            // parameter-check-success保存于上下文,作为同类型下游Filter的执行开关
            requestContext.set("parameter-check-success", false);
            return null;
        }
        // 设置避免报空
        requestContext.set("parameter-check-success", true);
        return null;
    }
}

SecondPreFilter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class SecondPreFilter extends ZuulFilter {
    private Logger log = LoggerFactory.getLogger(SecondPreFilter.class);

    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 3;
    }

    @Override
    public boolean shouldFilter() {
        RequestContext requestContext = RequestContext.getCurrentContext();
        // 参数a是否检验成功,不成功那就没必要继续执行下去
        return (boolean) requestContext.get("parameter-check-success");
    }

    @Override
    public Object run() throws ZuulException {
        log.info("second pre filter...");
        // 拿到请求上下文
        RequestContext requestContext = RequestContext.getCurrentContext();
        // 拿到HttpServletRequest
        HttpServletRequest request = requestContext.getRequest();
        // 获取传入的参数值
        String b = request.getParameter("b");
        if (StringUtils.isBlank(b)) {
            // 禁止路由,也就是不允许访问下游服务
            requestContext.setSendZuulResponse(false);
            // 设置响应结果,供PostFilter使用,参数是字符串,序列化一下返回对象也行。
            ObjectMapper mapper = new ObjectMapper();
            Map<String, Object> map = new HashMap<>();
            map.put("code", -1);
            map.put("msg", "参数b不能为空");
            String result = null;
            try {
                result = mapper.writeValueAsString(map);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
            requestContext.setResponseBody(result);
            // parameter-check-success保存于上下文,作为同类型下游Filter的执行开关
            requestContext.set("parameter-check-success", false);
            return null;
        }
        return null;
    }
}

CustomPostFilter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class CustomPostFilter extends ZuulFilter {

    private static final Logger LOG = LoggerFactory.getLogger(CustomPostFilter.class);

    @Override
    public String filterType() {
        return POST_TYPE;
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        System.out.println("这是PostFilter!");
        // 从RequestContext获取上下文
        RequestContext requestContext = RequestContext.getCurrentContext();
        // 处理返回中文乱码
        requestContext.getResponse().setCharacterEncoding("UTF-8");
        // 获取上下文中保存的responseBody
        String responseBody = requestContext.getResponseBody();
        // 如果responseBody不为空,则说明流程有异常发生
        if (null != responseBody) {
            //设定返回状态码
            requestContext.setResponseStatusCode(500);
            //替换响应报文
            requestContext.setResponseBody(responseBody);
        }
        return null;
    }
}

这整个小功能实现下来,体验到了Zuul中过滤器的执行顺序,以及通过RequestContext来获取HttpServletRequest得到请求信息。

二、Spring Cloud Zuul整合OAuth2+JWT入门实战

作为一个微服务网关,一般我们会在网关上进行鉴权,对于网关后面众多的无状态服务常用的授权和认证便是基于OAuth2。

2.1 什么是OAuth2和JWT

OAuth2是OAuth协议的第二个版本,是对授权认证比较成熟地面向资源的授权协议,在业界中广泛应用。出了定义了常用的用户名密码登录之后,还可以使用第三方一个用登录。例如在某些网站上可以使用QQ、微信、Github等进行登录。其主要流程如下:

至于JWT则是一种使用JSON格式来规约Token和Session的协议。因为传统的认证方式中会产生一个凭证,比如Session会话是保存在服务端,然后依赖于Cookie返回给客户端,Session是有状态的。但是对于众多的微服务来说又是无状态,便诞生像JWT这样的解决方案。

JWT通常有三部分组成:

  • Header:头部,指定JWT使用的签名算法
  • Payload:载荷,包含一些自定义或非自定义的认证信息
  • Signature:签名,将头部和载荷用.连接之后,使用头部的签名算法生成的签名信息并拼接到末尾

OAuth2 + JWT 就是服务端使用OAuth2的方式进行认证,然后颁发一个Token,而这个Token使用JWT。客户端拿着这个Token,便可以访问系统,一般我们会给这个Token设置一个有效期,因为服务端并不会保存这个Token。OAuth2的实现有很多,这里使用Spring社区的基于Spring Security实现的OAuth2

2.2 Zuul + OAuth2 + JWT 入门实操

2.2.1 修改cloud-zuul-gateway

在Zuul网关中我们需要对接口的请求进行保护,判断是否登录鉴权。如果未登录需要重定向到登录页面,登录成功由认证服务器颁发JWT Token;把JWT Token放到请求头传递到下游服务器。

引入Maven依赖:

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

配置文件:

  • 首先定义了service-a服务的路由规则
  • 注册中心Eureka的地址
  • 验证授权端点:http://localhost:7788/uaa/oauth/authorize
  • Token的颁发端点:http://localhost:7788/uaa/oauth/token
  • 默认是使用HS256加密算法,密钥是hahaha。加密算法的话建议使用安全性更高的非堆成加密
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server:
  port: 88
spring:
  application:
    name: zuul-gateway
eureka:
  client:
    serviceUrl:
      defaultZone: http://${eureka.host:127.0.0.1}:${eureka.port:8671}/eureka/
  instance:
    prefer-ip-address: true
zuul:
  routes:
    service-a:
      path: /servicea/**
      serviceId: service-a
security:
  oauth2:
    client:
      access-token-uri: http://localhost:7788/uaa/oauth/token #令牌端点
      user-authorization-uri: http://localhost:7788/uaa/oauth/authorize #授权端点
      client-id: zuul-gateway #OAuth2客户端ID
      client-secret: my-secret #OAuth2客户端密钥
    resource:
      jwt:
        key-value: hahaha #使用对称加密方式,默认算法为HS256

WebSecurity的配置:主要是声明

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Configuration
@Order(101)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login", "/servicea/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .csrf()
                .disable();
    }
}

在启动类上添加@EnableOAuth2Sso注解

1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableZuulProxy
@EnableDiscoveryClient
@EnableOAuth2Sso
public class ZuulServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ZuulServerApplication.class, args);
    }
}

2.2.2 编写认证服务器cloud-auth-server

创建cloud-auth-server来基于OAuth2 实现我们的认证服务器。依赖如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>cloud-zuul-practice-intermediate</artifactId>
        <groupId>com.msr.better</groupId>
        <version>1.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>cloud-auth-server</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
    </dependencies>

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

</project>

配置文件application.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
spring:
  application:
    name: cloud-auth-server
server:
  port: 7788
  servlet:
    contextPath: /uaa
eureka:
  client:
    serviceUrl:
      defaultZone: http://${eureka.host:127.0.0.1}:${eureka.port:8671}/eureka/
  instance:
    prefer-ip-address: true

认证服务器配置:继承AuthorizationServerConfigurerAdapter编写认证授权服务器配置。主要是指定clientId、密钥、以及权限定义和作用域声明,指定JwtTokenStore,类似的实现Spring Security还有RedisTokenStore等。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients
                .inMemory()
                .withClient("zuul-gateway")
                .secret("my-secret")
                .scopes("write", "read").autoApprove(true)
                .authorities("WRIGTH_READ", "WRIGTH_WRITE")
                .authorizedGrantTypes("implicit", "refresh_token", "password", "authorization_code")
                .redirectUris("http://localhost:88/login");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .tokenStore(jwtTokenStore())
                .tokenEnhancer(jwtTokenConverter())
                .authenticationManager(authenticationManager);
    }

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtTokenConverter());
    }

    @Bean
    protected JwtAccessTokenConverter jwtTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("hahaha");
        return converter;
    }
}

Web Security 相关配置:声明guest用户,密码为guest,拥有READ权限。admin用户,密码为admin,拥有READ、WRITE权限。

AuthenticationManager是认证管理器,需要注入到Spring容器中。passwordEncoder()声明密码的加密方式,在Spring Security中要求需要对密码进行加密,因此需要向Spring容器中注入。但是这里使用了内存的方式存放用户信息,而且密码是原值保存,所以使用NoOpPasswordEncoder,即不做加密处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("guest").password("guest").authorities("READ")
                .and()
                .withUser("admin").password("admin").authorities("READ", "WRITE");
    }
}

认证服务器启动类:

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableDiscoveryClient
public class AuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }
}

2.2.3 cloud-service-a服务整合资源服务器

service-a的编写相对简单,在Spring Security OAuth2中,每个服务都是一个资源服务器,拥有者该服务的资源。

引入依赖:

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

配置文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
server:
  port: 8080
spring:
  application:
    name: service-a
eureka:
  client:
    serviceUrl:
      defaultZone: http://${eureka.host:127.0.0.1}:${eureka.port:8671}/eureka/
  instance:
    prefer-ip-address: true

编写资源服务器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Configuration
public class ServiceAResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf()
                .disable()
                .authorizeRequests()
                .antMatchers("/**").authenticated()
                .antMatchers(HttpMethod.GET, "/servicea/test")
                .hasAuthority("WRIGHT_READ");
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("WRIGHT")
                .tokenStore(jwtTokenStore());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter tokenConverter = new JwtAccessTokenConverter();
        tokenConverter.setSigningKey("hahaha");
        return tokenConverter;
    }

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
}

编写ClientController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@RestController
@RequestMapping
public class ClientController {

    @GetMapping("/test")
    public String test(HttpServletRequest request, HttpServletResponse response) {
        System.out.println("================header================");
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String key = headerNames.nextElement();
            System.out.println(key + ": " + request.getHeader(key));
        }
        System.out.println("================header================");
        return "hello word!";
    }
}

servicea的启动类:启用资源服务器@EnableResourceServer

1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableDiscoveryClient
@EnableResourceServer
public class ServiceAApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServiceAApplication.class, args);
    }
}

2.2.4 测试

先启动注册中心Eureka、然后启动Zuul网关、serivce-a、auth-server。

请求访问:

http://localhost:88/service/test

OAuth2 + JWT 实战小总结

这里关于Zuul整合OAuth2 + JWT 的介绍就到这,后面会写一篇详细的Spring Security实现的OAuth2文章。本文这里用到的认证服务器和资源服务器是较为早期的写法了,前年Spring Security开了一个新项目专门来编写认证服务器。

Github链接:

https://github.com/spring-projects/spring-security/tree/main/oauth2 Spring Security实现的OAuth2

https://github.com/spring-projects/spring-authorization-server Spring Security团队维护的认证服务器