Spring Cloud Gateway微服务网关限流与JWT鉴权

不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口,存在一些问题:

  1. 页面需要对接多个域名,非常繁琐;
  2. 安全隐患,服务端暴露的接口增加,增加服务器受攻击的面积;
  3. 跨域问题;
  4. 认证复杂。

微服务网关的主要作用:

  1. 整合各个微服务的功能,形成一套系统;
  2. 在微服务网关中实现日志的统一记录;
  3. 实现用户的操作跟踪;
  4. 实现限流操作;
  5. 用户权限认证操作。

实现微服务网关的技术有很多:

  1. nginx:一个高性能HTTP和反向代理web服务器,同时提供了IMAP/POP3/SMTP服务;(一般用于抵御第一波并发流量)
  2. zuul:Netflix出品的一个基于JVM路由和服务端负载均衡器;
  3. spring-cloud-gateway:spring出品的基于Spring的网关项目,集成断路器,路径重写,性能比Zuul好。

微服务网关项目需要导入的依赖:

    <dependencies>
        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-gatewayartifactId>
            <version>2.2.4.RELEASEversion>
        dependency>

        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-netflix-hystrixartifactId>
            <version>2.2.4.RELEASEversion>
        dependency>

        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
            <version>2.2.4.RELEASEversion>
        dependency>
    dependencies>

application.yml的配置:

spring:
  # spring-cloud网关跨域问题
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]': # 匹配所有请求
            allowedOrigins: "*" # 允许所有的域
            allowedMethods: # 支持的方法
              - GET
              - POST
              - PUT
              - DELETE

1. 路由功能

1.1 Host过滤路由

spring:
  cloud:
    gateway:
      routes:
        # 唯一标识
        - id: changgou_goods_route
          # 指定要路由的服务地址
          uri: http://localhost:9960
          # 路由断言,路由规则配置
          predicates:
            # 所有以指定域名开始的请求都将被路由到上面指定的服务地址
            - Host=cloud.changgou.com**

设置host文件:

127.0.0.1 cloud.changgou.com

访问http://cloud.changgou.com:5656/category,网关将会把当前请求路由到http://localhost:9960/category

Spring Cloud Gateway微服务网关限流与JWT鉴权_第1张图片

1.2 路径过滤路由

spring:
  cloud:
    gateway:
      routes:
        # 唯一标识
        - id: changgou_goods_route
          # 指定要路由的服务地址
          uri: http://localhost:9960
          # 路由断言,路由规则配置
          predicates:
            # 所有以/brand/路径开头的请求都将路由到上面指定的服务地址
            - Path=/brand/**

访问http://localhost:5656/brand,网关将会把当前请求路由到http://localhost:9960/brand

Spring Cloud Gateway微服务网关限流与JWT鉴权_第2张图片

1.3 前缀路径过滤路由

有些情况下,我们可以给真实请求加一个统一的前缀,例如,所有微服务路径统一以api开头。

1.3.1 自动删除前缀后请求

spring:
  cloud:
    gateway:
      routes:
        # 唯一标识
        - id: changgou_goods_route
          # 指定要路由的服务地址
          uri: http://localhost:9960
          # 路由断言,路由规则配置
          predicates:
            # 所有以/api/brand开始的请求,都将被路由到上面指定的服务地址,希望该路径由服务网关自动添加/api前缀,每次请求真实微服务网关时,需要使用微服务网关将/api去掉。
          - Path=/api/brand/**
          filters:
            #将请求路径中的第一个路径去掉,请求路径以/区分,一个代表一个路径
            - StripPrefix=1

访问http://localhost:5656/api/brand时,网关将会把当前请求路由到http://localhost:9960/brand

Spring Cloud Gateway微服务网关限流与JWT鉴权_第3张图片

1.3.2 自动追加前缀后请求

spring:
  cloud:
    gateway:
      routes:
        # 唯一标识
        - id: changgou_goods_route
          # 指定要路由的服务地址
          uri: http://localhost:9960
          # 路由断言,路由规则配置
          predicates:
          - Path=/**
          filters:
            # 用户请求/**->/brand/**,并且该请求路由到指定的服务地址
            - PrefixPath=/brand

访问http://localhost:5656/时,网关将会把当前请求路由到http://localhost:9960/brand

Spring Cloud Gateway微服务网关限流与JWT鉴权_第4张图片

2. 负载均衡

LoadBalanceClientFilter实现负载均衡调用。它会作用在uri以lb开头的路由,然后利用loadBalancer来获取服务实例,构造目标requestUrl,设置到GATEWAY_REQUEST_URL_ATTR属性中,供NettyRoutingFilter使用。

spring:
  cloud:
    gateway:
      routes:
        # 唯一标识
        - id: changgou_goods_route
          # 使用LoadBalancerClient实现负载均衡
          uri: lb://goods
          # 路由断言,路由规则配置
          predicates:
          - Path=/**
          filters:
            # 用户请求/**->/brand/**,并且该请求路由到指定的服务地址
            - PrefixPath=/brand

3. 限流

虽然Nginx会拦截第一波并发流量,但是仍然无法应对用户恶意访问某一个微服务的问题。网关限流可以通过限制每秒钟某一用户并发访问某一微服务的次数,达到保护微服务,防止雪崩的产生。

通常限流采用的算法有:

  1. 令牌桶算法
  2. 漏桶算法
  3. 计数算法;
    未达到流量阈值则放行请求,否则,返回429。

3.1 令牌桶算法

Spring Cloud Gateway微服务网关限流与JWT鉴权_第5张图片
算法大概流程:

  1. 所有的请求在处理之前都需要得到一个可用的令牌才能被处理;
  2. 根据限流大小,设置一定的速率往桶里添加令牌,一般用redis存储令牌;
  3. 桶设置最大的放置令牌限制,当桶满时,新添加的令牌就会被丢弃或者拒绝;
  4. 请求到达后首先需要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完成后,直接删除令牌;
  5. 令牌桶有最低限额,当桶中的令牌达到最低限额时,请求处理完之后将不会删除令牌,以此保证足够的限流。

3.2 限流实现

spring-cloud-gateway默认使用redis的RateLimter限流算法实现。我们可以根据IP进行限流,例如限制每个IP每秒钟只能请求一次,在主程序中定义KeyResolver来获取客户端IP,并根据IP生成key:

    /**
     * 创建用户唯一标识,使用IP作为用户唯一标识来进行限流
     * @return
     */
    @Bean("ipKeyResolver")
    public KeyResolver userKeyResolver() {
        return new KeyResolver() {
            @Override
            public Mono<String> resolve(ServerWebExchange exchange) {
                // 获取用户IP
                String hostString = exchange.getRequest().getRemoteAddress().getHostString();
                System.out.println(hostString);
                return Mono.just(hostString);
            }
        };
    }

配置

      routes:
        # 唯一标识
        - id: changgou_goods_route
          # 指定要路由的服务地址
          # uri: http://localhost:9960
          uri: lb://goods
          # 路由断言,路由规则配置
          predicates:
          - Path=/**
          filters:
            # 用户请求/**->/brand/**,并且该请求路由到指定的服务地址
            - PrefixPath=/brand
            - name: RequestRateLimiter # 局部限流过滤器
              args:
                # 指定唯一标识
                key-resolver: "#{@ipKeyResolver}"
                # 每秒钟只允许一个请求
                redis-rate-limiter.replenishRate: 1
                # 允许并发有4个请求
                redis-rate-limiter.burstCapacity: 1

Ctrl+Shift+N可以找到RequestRateLimiter的过滤器

Spring Cloud Gateway微服务网关限流与JWT鉴权_第6张图片

当并发请求超过配置中的限制时,将会直接返回429。

Spring Cloud Gateway微服务网关限流与JWT鉴权_第7张图片

4 用户鉴权

Spring Cloud Gateway微服务网关限流与JWT鉴权_第8张图片

Api网关实现用户鉴权的流程:

  1. 当不存在用户令牌时,提示用户登录;
  2. 验证身份成功后生成令牌,并将生成好的令牌返回给用户;
  3. 用户再次访问Api网关时,从请求参数、请求头、Cookie中获取令牌;
  4. 网关校验令牌;
  5. 如果校验通过,则直接通过,否则提示用户登录。

商品微服务和用户微服的路由配置如下:

spring:
  cloud:
    gateway:
      routes:
        # 商品微服务唯一标识
        - id: changgou_goods_route
          # 指定要路由的服务
          uri: lb://goods
          predicates:
          - Path=/api/goods/**
          filters:
            - StripPrefix=2 # 访问http://localhost:5656/api/goods/category时,将路由到goods微服务,并去掉/api/goods
            - name: RequestRateLimiter # 局部限流过滤器
              args:
                # 指定唯一标识
                key-resolver: "#{@ipKeyResolver}"
                # 每秒钟只允许1个请求
                redis-rate-limiter.replenishRate: 1
                # 最大允许并发请求的数量
                redis-rate-limiter.burstCapacity: 1
        # 用户微服务唯一标识
        - id: changgou_user_route
          # 指定要路由的服务
          uri: lb://user
          predicates:
          - Path=/api/user/**,/api/address/**,/api/area/**,/api/cities/**,/api/provinces/**
          filters:
            - StripPrefix=1

4.1 JWT令牌

JSON Web Tocken (JWT) 是一个非常轻巧的规范,它允许我们使用JWT在用户和服务器之间传递安全可靠的信息。

一个JWT字符串由三部分组成,通过.分割:

  1. 头部
    包含当前JWT的基本信息,如类型和签名算法
    {"typ":"JWT","alg":"HS256"}
    将其进行BASE64编码后字符串如下:
    eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
    注意:JDK中提供了非常方便的BASE64Encoder和BASE64Decoder,用他们可以非常方便地完成基于BASE64的编码和解码。
  2. 载荷
    存放有效信息
    *标准中注册的声明,参与令牌校验,包含iss(jwt签发者)、sub(当前令牌的描述说明)、aud(接收jwt的一方)、exp(jwt的过期时间,这个过期时间必须要大于签发时间)、nbf(在某一时间之前,该jwt不能使用)、iat(jwt的签发时间)和jti(jwt的唯一身份标识,主要用来作为一次性tocken,从而回避重放攻击);
    *公共声明,一般添加用户的相关信息或者其他业务需要的必要信息,但不要添加敏感信息,因为这一部分在客户端是可解密的;
    *私有声明,提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称加密,可解密。这个指的是自定义的claim。
    {"sub":"1234567890","name":"John Doe","admin":true}
    将其进行BASE64编码后字符串如下:
    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
  3. 签名
    校验数据是否被篡改,签名过程:
    *将base64加密后的头部和base64加密后的载荷使用.拼接在一起;
    *通过头部指定的加密方式加盐组合加密

4.2 JWT令牌创建

定义依赖

        
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>0.6.0version>
        dependency>

令牌创建帮助类

public class JwtUtil {
    //有效期为
    public static final Long JWT_TTL = 3600000L;// 60 * 60 *1000  一个小时

    //Jwt令牌信息
    public static final String JWT_KEY = "itcast";

    /**
     * 生成令牌
     * @param id
     * @param subject
     * @param ttlMillis
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        //指定算法
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        //当前系统时间
        long nowMillis = System.currentTimeMillis();
        //令牌签发时间
        Date now = new Date(nowMillis);

        //如果令牌有效期为null,则默认设置有效期1小时
        if (ttlMillis == null) {
            ttlMillis = JwtUtil.JWT_TTL;
        }

        //令牌过期时间设置
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);

        //生成秘钥
        SecretKey secretKey = generalKey();

        //封装Jwt令牌信息
        JwtBuilder builder = Jwts.builder()
                .setId(id)                    //唯一的ID
                .setSubject(subject)          // 主题  可以是JSON数据
                .setIssuer("admin")          // 签发者
                .setIssuedAt(now)             // 签发时间
                .signWith(signatureAlgorithm, secretKey) // 签名算法以及密匙(盐)
                .setExpiration(expDate);      // 设置过期时间
        return builder.compact();// 获取令牌信息,每次都不一样,因为签发时间每次都不同
    }

    /**
     * 生成加密 secretKey
     *
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getEncoder().encode(JwtUtil.JWT_KEY.getBytes());
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * 解析令牌数据
     *
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)// 传入秘钥(盐)
                .parseClaimsJws(jwt)// 解析令牌
                .getBody();// 获取解析的内容
    }

    public static void main(String[] args) {
        String jwt = JwtUtil.createJWT("weiyibiaoshi", "aaaaaa", null);
        System.out.println(jwt);
        try {
            Claims claims = JwtUtil.parseJWT(jwt);
            System.out.println(claims);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

4.3 网关用户鉴权实现

创建一个全局过滤器,实现GlobalFilter接口和Ordered接口。下面的全局过滤器的处理流程:

  1. 从请求头、请求参数和Cookie中获取jwt令牌;
  2. 若不存在令牌,则返回401,提示用户没有权限;
  3. 否则,判断令牌是否有效,有效,则放行;否则,说明令牌失效,同样返回401,提示用户没有权限;
  4. 放行之前,为了方便后面微服务的鉴权处理,将jwt令牌放入到请求头中。
import com.changgou.gateway.util.JwtUtil;
import org.apache.commons.lang.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
 * 全局过滤器:需要实现GlobalFilter接口和Ordered接口
 * 用户权限鉴别过滤器
 */
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
    // 令牌头的名字
    private static final String AUTHORIZE_TOCKEN = "Authorization";

    /**
     * 全局过滤
     * @param exchange
     * @param chain
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取Request、Response对象
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();

        // 获取用户令牌信息:可能存在于请求头、请求参数和Cookie中
        String jwt = request.getHeaders().getFirst(AUTHORIZE_TOCKEN);
        boolean jwtInHeader = true;// 标记jwt是否在请求头
        if(StringUtils.isEmpty(jwt)){
            jwtInHeader = false;
            jwt = request.getQueryParams().getFirst(AUTHORIZE_TOCKEN);
            if(StringUtils.isEmpty(jwt)){
                HttpCookie cookie = request.getCookies().getFirst(AUTHORIZE_TOCKEN);
                if(cookie != null) {
                    jwt = cookie.getValue();
                }
            }
        }

        // 没有令牌,则拦截
        if(StringUtils.isEmpty(jwt)){
            // 设置401状态码,提示用户没有权限,用户收到该提示后需要重定向到登陆页面
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            // 响应空数据
            return response.setComplete();
        }

        // 有令牌
        try {
            JwtUtil.parseJWT(jwt);
        } catch (Exception e) {
            // 无效令牌
            // 设置401状态码,提示用户没有权限,用户收到该提示后需要重定向到登陆页面
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            // 响应空数据
            return response.setComplete();
        }

        // 令牌正常解析,为了方便在其他微服务进行认证,这里将jwt放入到请求头中
        request.mutate().header(AUTHORIZE_TOCKEN, jwt);

        // 放行
        return chain.filter(exchange);
    }

    /**
     * 排序,越小越先执行
     * @return
     */
    @Override
    public int getOrder() {
        return 0;
    }
}

直接访问用户微服务正常访问

Spring Cloud Gateway微服务网关限流与JWT鉴权_第9张图片

登陆前,通过微服务网关访问用户微服务返回401状态码。登陆后,通过微服务网关访问用户微服务正常。

Spring Cloud Gateway微服务网关限流与JWT鉴权_第10张图片

另外,对于令牌可能被他人盗用的情况,解决方案如下:

  1. 生成令牌时,将用户的IP使用MD5加密后放入到jwt中;
  2. 每次校验时,获取用户登陆的IP,并进行MD5加密;
  3. 接着,取出jwt中之前用户登陆时IP所产生的MD5值与上面的MD5值对比,若一致,则说明用户使用的是同一台主机访问,否则,说明用户使用的是另外一台主机进行操作,jwt令牌可能被盗,jwt令牌失效,返回401。

你可能感兴趣的:(分布式技术,Spring,Cloud)