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

上一篇中,我們構(gòu)建了一個簡單的Spring Cloud Demo項目茎辐,涵蓋了服務(wù)注冊/發(fā)現(xiàn)缕允,服務(wù)間的相互調(diào)用峡扩,以及熔斷降級等內(nèi)容。但如果服務(wù)需要暴露給外部進行使用障本,比如移動端教届,或者web端响鹃,則還需要考慮更多的事情。整個服務(wù)端的部署情況對于外部調(diào)用方應(yīng)該是一個黑盒案训,外部調(diào)用方無法了解到每個服務(wù)具體是部署到哪一個IP或者域名下面买置,為了安全性也不太可能允許外部調(diào)用方直接連接到Consul去查詢服務(wù)注冊的情況,這樣我們就需要一個服務(wù)網(wǎng)關(guān)來集中對外部請求進行路由和負載均衡强霎,同時驗證調(diào)用方的權(quán)限和身份堕义。如下圖所示:

服務(wù)網(wǎng)關(guān).png

基礎(chǔ)介紹

服務(wù)網(wǎng)關(guān)的概念有點類似于傳統(tǒng)的反向代理服務(wù)器(如nginx),但反向代理一般都只是做業(yè)務(wù)無關(guān)的轉(zhuǎn)發(fā)請求,而服務(wù)網(wǎng)關(guān)與服務(wù)的整合程度更高脆栋,可以看作也是整個服務(wù)體系的組成部分,通過過濾器等組件可以在網(wǎng)關(guān)中集成一些業(yè)務(wù)處理的操作(比如權(quán)限認證等)洒擦。Spring Cloud Gateway正是Spring官方推出的服務(wù)網(wǎng)關(guān)的實現(xiàn)框架椿争,它主要包含三個核心的概念:

  • Route: 負責將某個外部請求路由到一個合適的地址,包含一個ID,一個目標地址熟嫩,一系列的Predicate和Filter秦踪;
  • Predicate: 基于Java 8 Function Predicate的斷言機制,用于將請求匹配到某一個Route
  • Filter: 類似于Servlet filter掸茅,可以在請求傳遞給下一級處理器之前對請求或響應(yīng)進行修改椅邓,用于實現(xiàn)權(quán)限驗證,日志記錄昧狮,限流等功能

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


spring_cloud_gateway_diagram.png

網(wǎng)關(guān)集成

我們現(xiàn)在來為我們的demo項目加入一個服務(wù)網(wǎng)關(guān)景馁。首先需要創(chuàng)建一個新的模塊,名字叫Gateway逗鸣,在pom.xml中加入如下依賴:

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

application.yml中加入如下內(nèi)容:

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協(xié)議會激活LoadBalancerClient來解析后續(xù)的地址合住,自動根據(jù)注冊的服務(wù)實例進行負載均衡
          uri: lb://order-service
          filters:
            - Log
            # 轉(zhuǎn)發(fā)時去掉請求地址的服務(wù)名前綴
            - StripPrefix=1
          predicates:
            - Path=/order-service/**

從以上配置可以很容易看出來,gateway模塊其實也會注冊到consul中成為一個服務(wù)撒璧,并通過consul獲取其它服務(wù)的相關(guān)信息透葛。上面的配置中我們加入了一個名為order-service的路由,其中predicates定義了這個路由的匹配規(guī)則卿樱,也就是訪問路徑以/order-service/開頭的請求僚害,就會被路由到 lb://order-service的地址 (地址代表的含義參見注釋)。

斷言

predicates用于定義route的匹配規(guī)則繁调,可以針對請求的幾乎所有內(nèi)容進行匹配萨蚕,例如針對特定的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已經(jīng)內(nèi)置了多個很有用的GatewayFilter實現(xiàn)寒随,例如StripPrefix就是內(nèi)置的用于轉(zhuǎn)發(fā)時修改請求地址的過濾器。其它內(nèi)置過濾器的作用可以查看 這里。如果內(nèi)置過濾器不能滿足我們的需求妻往,那就需要自行實現(xiàn)新的過濾器了互艾。

我們現(xiàn)在來添加一個簡單的過濾器日志過濾器,用于打印出每次請求所花費的時間:

@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 {
    }
}

自定義過濾器需要實現(xiàn)一個新的GatewayFilterFactory纫普,其類名也需要遵循XXXGatewayFilterFactory的規(guī)則,這樣的話在配置中只需要配置“XXX”的部分就可以正常被識別了好渠,例如 LogGatewayFilterFactory就只需要配置成“Log”就行了昨稼。代碼中的內(nèi)部類Config是用于接收配置時傳遞的參數(shù)(類似于Log=true),這里不需要參數(shù)所以只是一個空類拳锚。需要注意的是Spring Cloud Gateway是使用 Spring WebFlux 來構(gòu)建的假栓,所以filter這里的寫法是基于Reactor異步模式的,和傳統(tǒng)的同步請求模式(如Spring MVC)不太一樣霍掺。

定義了新的過濾器之后需要將其注冊到容器:

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

GatewayFilter都是基于Route進行配置的匾荆,Spring Cloud Filter還定義了一種GlobalFilter,不需要在配置文件中配置杆烁,作用在所有的路由上牙丽。GlobalFilter同樣支持自定義新的過濾器,只需要實現(xiàn)GlobalFilter和Ordered接口即可兔魂,詳細情況我們后面在講到權(quán)限的時候再介紹烤芦。

權(quán)限管理

服務(wù)網(wǎng)關(guān)的一大作用就是可以對外部的請求進行集中權(quán)限認證,這樣每個具體的服務(wù)就不用操心權(quán)限管理的問題了析校,可以專心于業(yè)務(wù)的實現(xiàn)拍棕。基本的思路是外部客戶端首先需要獲取一個由系統(tǒng)中獨立的認證中心負責簽發(fā)的accessToken勺良,然后每次請求服務(wù)時在http header中攜帶該Token绰播,服務(wù)網(wǎng)關(guān)負責校驗accessToken的有效性以及是否具備訪問該服務(wù)的權(quán)限,具體的思路和我之前介紹單系統(tǒng)權(quán)限管理的思路比較類似尚困,可以查看 Spring Boot整合Shiro和JWT的無狀態(tài)權(quán)限管理方案 這篇文章蠢箩。

我們首先需要在服務(wù)網(wǎng)關(guān)中定義一個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("沒有授權(quán)的訪問事甜,{}", reqPath);
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        //獲取token中存儲的用戶唯一標識谬泌,并放入request header中,供后端業(yè)務(wù)服務(wù)使用
        String account = authService.getAccountByToken(token);
        ServerHttpRequest request = exchange.getRequest().mutate()
                .header(authConfig.getHeaderKeyOfAccount(), account).build();
        return chain.filter(exchange.mutate().request(request).build());
    }

    /**
     * 過濾器的優(yōu)先級逻谦,越低越高
     */
    @Override
    public int getOrder() {
        return 1;
    }
}

功能很簡單掌实,就是對請求頭部的token進行校驗,如果成功就將從token中解析出來的用戶賬戶信息放入轉(zhuǎn)發(fā)的請求頭中供后端的業(yè)務(wù)服務(wù)使用邦马,否則返回UNAUTHORIZED贱鼻。這個Filter也需要注冊到容器中:

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

對token進行校驗的核心邏輯在authService.verifyToken方法中宴卖,代碼如下:

 /**
     * 驗證token的有效性及是否具備對該url的訪問權(quán)限,
     * 判定規(guī)則參考了shiro的一些設(shè)定
     */
    public boolean verifyToken(String url, String token) {
        if (Strings.isNullOrEmpty(token)) {
            return false;
        }
        //獲取每個Url所對應(yīng)的權(quán)限控制符
        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;
            }
            // 進一步判斷用戶權(quán)限
            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;
    }

服務(wù)網(wǎng)關(guān)首先需要知道不同的服務(wù)地址需要什么樣的權(quán)限才允許訪問症昏,這里采用了類似Shiro配置的格式,類似這樣如下的格式父丰,實際環(huán)境中可能是從數(shù)據(jù)庫或配置文件中讀雀翁贰:

 /**
     * 獲取所有的接口url與用戶權(quán)限的映射關(guān)系,格式仿造了shiro的權(quán)限配置格式
     */
    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所需要的權(quán)限標識符蛾扇,再根據(jù)權(quán)限標識符去檢查token對應(yīng)的用戶是否具備相應(yīng)的權(quán)限攘烛。對這部分感興趣的同學可以去查看源碼。

本文的相關(guān)代碼可以查看這里 spring-cloud-demo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末镀首,一起剝皮案震驚了整個濱河市医寿,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蘑斧,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,542評論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件须眷,死亡現(xiàn)場離奇詭異竖瘾,居然都是意外死亡,警方通過查閱死者的電腦和手機花颗,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評論 3 394
  • 文/潘曉璐 我一進店門捕传,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人扩劝,你說我怎么就攤上這事庸论。” “怎么了棒呛?”我有些...
    開封第一講書人閱讀 163,912評論 0 354
  • 文/不壞的土叔 我叫張陵聂示,是天一觀的道長。 經(jīng)常有香客問我簇秒,道長鱼喉,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,449評論 1 293
  • 正文 為了忘掉前任趋观,我火速辦了婚禮扛禽,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘皱坛。我一直安慰自己编曼,他們只是感情好,可當我...
    茶點故事閱讀 67,500評論 6 392
  • 文/花漫 我一把揭開白布剩辟。 她就那樣靜靜地躺著掐场,像睡著了一般往扔。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上刻肄,一...
    開封第一講書人閱讀 51,370評論 1 302
  • 那天瓤球,我揣著相機與錄音,去河邊找鬼敏弃。 笑死卦羡,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的麦到。 我是一名探鬼主播绿饵,決...
    沈念sama閱讀 40,193評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼瓶颠!你這毒婦竟也來了拟赊?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,074評論 0 276
  • 序言:老撾萬榮一對情侶失蹤粹淋,失蹤者是張志新(化名)和其女友劉穎吸祟,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體桃移,經(jīng)...
    沈念sama閱讀 45,505評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡屋匕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,722評論 3 335
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了借杰。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片过吻。...
    茶點故事閱讀 39,841評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖蔗衡,靈堂內(nèi)的尸體忽然破棺而出纤虽,到底是詐尸還是另有隱情,我是刑警寧澤绞惦,帶...
    沈念sama閱讀 35,569評論 5 345
  • 正文 年R本政府宣布逼纸,位于F島的核電站,受9級特大地震影響济蝉,放射性物質(zhì)發(fā)生泄漏樊展。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,168評論 3 328
  • 文/蒙蒙 一堆生、第九天 我趴在偏房一處隱蔽的房頂上張望专缠。 院中可真熱鬧,春花似錦淑仆、人聲如沸涝婉。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,783評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽墩弯。三九已至吩跋,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間渔工,已是汗流浹背锌钮。 一陣腳步聲響...
    開封第一講書人閱讀 32,918評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留引矩,地道東北人梁丘。 一個月前我還...
    沈念sama閱讀 47,962評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像旺韭,于是被迫代替她去往敵國和親氛谜。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,781評論 2 354