Spring Cloud笔记(5)Spring Cloud Gateway与权限认证

上一篇中,我们构建了一个简单的Spring Cloud Demo项目,涵盖了服务注册/发现,服务间的相互调用,以及熔断降级等内容。但如果服务需要暴露给外部进行使用,比如移动端,或者web端,则还需要考虑更多的事情。整个服务端的部署情况对于外部调用方应该是一个黑盒,外部调用方无法了解到每个服务具体是部署到哪一个IP或者域名下面,为了安全性也不太可能允许外部调用方直接连接到Consul去查询服务注册的情况,这样我们就需要一个服务网关来集中对外部请求进行路由和负载均衡,同时验证调用方的权限和身份。如下图所示:

服务网关.png

基础介绍

服务网关的概念有点类似于传统的反向代理服务器(如nginx),但反向代理一般都只是做业务无关的转发请求,而服务网关与服务的整合程度更高,可以看作也是整个服务体系的组成部分,通过过滤器等组件可以在网关中集成一些业务处理的操作(比如权限认证等)。Spring Cloud Gateway正是Spring官方推出的服务网关的实现框架,它主要包含三个核心的概念:

  • Route: 负责将某个外部请求路由到一个合适的地址,包含一个ID,一个目标地址,一系列的Predicate和Filter;
  • Predicate: 基于Java 8 Function Predicate的断言机制,用于将请求匹配到某一个Route
  • Filter: 类似于Servlet filter,可以在请求传递给下一级处理器之前对请求或响应进行修改,用于实现权限验证,日志记录,限流等功能

整个工作流程如下图所示:


spring_cloud_gateway_diagram.png

网关集成

我们现在来为我们的demo项目加入一个服务网关。首先需要创建一个新的模块,名字叫Gateway,在pom.xml中加入如下依赖:

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

application.yml中加入如下内容:

server:
  port: 9000

spring:
  application:
    name: gateway
  cloud:
    consul:
      host: 192.168.1.220
      port: 8500
      discovery:
        prefer-ip-address: true
    gateway:
      routes:
        - id: order-service
          #lb协议会激活LoadBalancerClient来解析后续的地址,自动根据注册的服务实例进行负载均衡
          uri: lb://order-service
          filters:
            - Log
            # 转发时去掉请求地址的服务名前缀
            - StripPrefix=1
          predicates:
            - Path=/order-service/**

从以上配置可以很容易看出来,gateway模块其实也会注册到consul中成为一个服务,并通过consul获取其它服务的相关信息。上面的配置中我们加入了一个名为order-service的路由,其中predicates定义了这个路由的匹配规则,也就是访问路径以/order-service/开头的请求,就会被路由到 lb://order-service的地址 (地址代表的含义参见注释)。

断言

predicates用于定义route的匹配规则,可以针对请求的几乎所有内容进行匹配,例如针对特定的header进行匹配:

predicates:
  - Header=X-Request-Id, \d+**

针对Cookie进行匹配:

predicates:
  - Cookie=mycookie,mycookievalue

匹配特定域名的请求

predicates:
  - Host=**.somehost.org,**.anotherhost.org

更多predicates种类的介绍可以查看 这里

过滤器

刚才的路由配置中,我们定义了两个过滤器: Log,StripPrefix,这些都属于GatewayFilter,每个Route可以定义多个GatewayFilter。Spring Cloud Gateway已经内置了多个很有用的GatewayFilter实现,例如StripPrefix就是内置的用于转发时修改请求地址的过滤器。其它内置过滤器的作用可以查看 这里。如果内置过滤器不能满足我们的需求,那就需要自行实现新的过滤器了。

我们现在来添加一个简单的过滤器日志过滤器,用于打印出每次请求所花费的时间:

@Slf4j
public class LogGatewayFilterFactory extends AbstractGatewayFilterFactory<LogGatewayFilterFactory.Config> {

    private static final String REQUEST_START_TIME = "request_start_time";


    public LogGatewayFilterFactory() {
        // 这里需要将自定义的config传过去,否则会报告ClassCastException
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            exchange.getAttributes().put(REQUEST_START_TIME, System.currentTimeMillis());
            return chain.filter(exchange).then(
                    Mono.fromRunnable(() -> {
                        Long startTime = exchange.getAttribute(REQUEST_START_TIME);
                        if (startTime != null) {
                            log.info("请求地址:{},消耗时间:{}ms", exchange.getRequest().getURI(), System.currentTimeMillis() - startTime);
                        }
                    })
            );
        };
    }

    public static class Config {
    }
}

自定义过滤器需要实现一个新的GatewayFilterFactory,其类名也需要遵循XXXGatewayFilterFactory的规则,这样的话在配置中只需要配置“XXX”的部分就可以正常被识别了,例如 LogGatewayFilterFactory就只需要配置成“Log”就行了。代码中的内部类Config是用于接收配置时传递的参数(类似于Log=true),这里不需要参数所以只是一个空类。需要注意的是Spring Cloud Gateway是使用 Spring WebFlux 来构建的,所以filter这里的写法是基于Reactor异步模式的,和传统的同步请求模式(如Spring MVC)不太一样。

定义了新的过滤器之后需要将其注册到容器:

    @Bean
    public LogGatewayFilterFactory logGatewayFilterFactory() {
        return new LogGatewayFilterFactory();
    }

GatewayFilter都是基于Route进行配置的,Spring Cloud Filter还定义了一种GlobalFilter,不需要在配置文件中配置,作用在所有的路由上。GlobalFilter同样支持自定义新的过滤器,只需要实现GlobalFilter和Ordered接口即可,详细情况我们后面在讲到权限的时候再介绍。

权限管理

服务网关的一大作用就是可以对外部的请求进行集中权限认证,这样每个具体的服务就不用操心权限管理的问题了,可以专心于业务的实现。基本的思路是外部客户端首先需要获取一个由系统中独立的认证中心负责签发的accessToken,然后每次请求服务时在http header中携带该Token,服务网关负责校验accessToken的有效性以及是否具备访问该服务的权限,具体的思路和我之前介绍单系统权限管理的思路比较类似,可以查看 Spring Boot整合Shiro和JWT的无状态权限管理方案 这篇文章。

我们首先需要在服务网关中定义一个GlobalFilter对所有的外部请求进行过滤,代码如下:

@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private AuthService authService;

    private AuthConfigProperties authConfig;

    public AuthGlobalFilter(AuthConfigProperties authConfig, AuthService authService) {
        this.authConfig = authConfig;
        this.authService = authService;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String reqPath = exchange.getRequest().getURI().getPath();
        String token = exchange.getRequest().getHeaders().getFirst(authConfig.getHeaderKeyOfToken());
        if (!authService.verifyToken(reqPath, token)) {
            log.warn("没有授权的访问,{}", reqPath);
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        //获取token中存储的用户唯一标识,并放入request header中,供后端业务服务使用
        String account = authService.getAccountByToken(token);
        ServerHttpRequest request = exchange.getRequest().mutate()
                .header(authConfig.getHeaderKeyOfAccount(), account).build();
        return chain.filter(exchange.mutate().request(request).build());
    }

    /**
     * 过滤器的优先级,越低越高
     */
    @Override
    public int getOrder() {
        return 1;
    }
}

功能很简单,就是对请求头部的token进行校验,如果成功就将从token中解析出来的用户账户信息放入转发的请求头中供后端的业务服务使用,否则返回UNAUTHORIZED。这个Filter也需要注册到容器中:

    @Bean
    public AuthGlobalFilter authGlobalFilter(AuthService authService) {
        return new AuthGlobalFilter(authConfig, authService);
    }

对token进行校验的核心逻辑在authService.verifyToken方法中,代码如下:

 /**
     * 验证token的有效性及是否具备对该url的访问权限,
     * 判定规则参考了shiro的一些设定
     */
    public boolean verifyToken(String url, String token) {
        if (Strings.isNullOrEmpty(token)) {
            return false;
        }
        //获取每个Url所对应的权限控制符
        String urlPermission = getUrlPermission(url);
        if ("anno".equals(urlPermission)) {
            return true;
        } else {
            //获取token中包含的用户唯一标识
            String account = jwtHelper.getAccount(token);
            if (Strings.isNullOrEmpty(account)) {
                return false;
            }
            //获取token的加密密钥
            String secret = getUserSecret(account);
            //校验accessToken
            if (jwtHelper.verify(token, secret) == null) {
                return false;
            }
            // 如果url仅要求验证用户有效性,则直接通过
            if (Strings.isNullOrEmpty(urlPermission) ||
                    "authc".equals(urlPermission)) {
                return true;
            }
            // 进一步判断用户权限
            if (urlPermission.startsWith("perms")) {
                Set<String> userPerms = this.getUserPermissions(account);
                String perms = urlPermission.substring(urlPermission.indexOf("[") + 1, urlPermission.lastIndexOf("]"));
                return userPerms.containsAll(Arrays.asList(perms.split(",")));
            }
        }
        return false;
    }

服务网关首先需要知道不同的服务地址需要什么样的权限才允许访问,这里采用了类似Shiro配置的格式,类似这样如下的格式,实际环境中可能是从数据库或配置文件中读取:

 /**
     * 获取所有的接口url与用户权限的映射关系,格式仿造了shiro的权限配置格式
     */
    public Map<String, String> getAllUrlPermissionsMap() {
        Map<String, String> urlPermissionsMap = Maps.newHashMap();
        urlPermissionsMap.put("/api/order/orders", "authc");
        urlPermissionsMap.put("/api/order/create-order", "perms[order]");
        urlPermissionsMap.put("/api/storage/**", "perms[storage]");
        return urlPermissionsMap;
    }

通过Spring 提供的工具类AntPathMatcher,就可以查询到每个请求url所需要的权限标识符,再根据权限标识符去检查token对应的用户是否具备相应的权限。对这部分感兴趣的同学可以去查看源码。

本文的相关代码可以查看这里 spring-cloud-demo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345