Spring Cloud筆記(5)Spring Cloud Gateway與權限認證

上一篇中,我們構建了一個簡單的Spring Cloud Demo項目,涵蓋了服務註冊/發現,服務間的相互調用,以及熔斷降級等內容。但如果服務需要暴露給外部進行使用,比如移動端,或者web端,則還需要考慮更多的事情。整個服務端的部署情況對於外部調用方應該是一個黑盒,外部調用方無法瞭解到每個服務具體是部署到哪一個IP或者域名下面,爲了安全性也不太可能允許外部調用方直接連接到Consul去查詢服務註冊的情況,這樣我們就需要一個服務網關來集中對外部請求進行路由和負載均衡,同時驗證調用方的權限和身份。如下圖所示:

基礎介紹

服務網關的概念有點類似於傳統的反向代理服務器(如nginx),但反向代理一般都只是做業務無關的轉發請求,而服務網關與服務的整合程度更高,可以看作也是整個服務體系的組成部分,通過過濾器等組件可以在網關中集成一些業務處理的操作(比如權限認證等)。Spring Cloud Gateway正是Spring官方推出的服務網關的實現框架,它主要包含三個核心的概念:

  • Route: 負責將某個外部請求路由到一個合適的地址,包含一個ID,一個目標地址,一系列的Predicate和Filter;
  • Predicate: 基於Java 8 Function Predicate的斷言機制,用於將請求匹配到某一個Route
  • Filter: 類似於Servlet filter,可以在請求傳遞給下一級處理器之前對請求或響應進行修改,用於實現權限驗證,日誌記錄,限流等功能

整個工作流程如下圖所示:


網關集成

我們現在來爲我們的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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章