網關登錄校驗
單體架構時我們只需要完成一次用戶登錄、身份校驗,就可以在所有業務中獲取到用戶信息。而微服務拆分后,每個微服務都獨立部署,不再共享數據。也就意味著每個微服務都需要做登錄校驗,這顯然不可取。
鑒權思路分析
登錄授權的功能寫在了user-service里,由于采用的是jwt登錄方式,不管在哪里登錄,只要給用戶頒發了jwt的token,那么他就可以帶著token訪問訪問,這時就可以從token里解析出用戶的信息,所以登錄授權這塊代碼不需要改變,只需要放在user-service里即可。但是對于jwt的校驗就不一樣了,很多的微服務都需要知道登錄用戶的信息。比如說購物車服務,查詢和修改購物車都需要登錄用戶是誰。這樣就需要在所有的微服務里面做jwt的校驗,代碼的重復量就增加了,而且還需要將jwt的密鑰發送給他們,密鑰泄露的風險也提高了。因此這個校驗的操作就需要在網關里做了,因為網關是整個微服務的入口。
既然網關是所有微服務的入口,一切請求都需要先經過網關。我們完全可以把登錄校驗的工作放到網關去做,這樣之前說的問題就解決了。
- 只需要在網關和用戶服務保存秘鑰
- 只需要在網關開發登錄校驗功能
此時,登錄校驗的流程如圖:
不過,這里存在幾個問題:
- 網關路由是配置的,請求轉發是Gateway內部代碼,我們如何在轉發之前做登錄校驗?
- 網關校驗JWT之后,如何將用戶信息傳遞給微服務?
- 微服務之間也會相互調用,這種調用不經過網關,又該如何傳遞用戶信息?
這些問題將在接下來幾節一一解決。
網關過濾器
登錄校驗必須在請求轉發到微服務之前做,否則就失去了意義。而網關的請求轉發是Gateway
內部代碼實現的,要想在請求轉發之前做登錄校驗,就必須了解Gateway
內部工作的基本原理。
如圖所示:
- 客戶端請求進入網關后由
HandlerMapping
對請求做判斷,它是基于路由斷言RouterPredicateHandlerMapping
去做路由規則的匹配的,之前對每個路由都配置了路由斷言,所有他就可以基于這個斷言和前端請求去匹配,找到與當前請求匹配的路由規則(Route
)并存入上下文,然后將請求交給WebHandler
去處理。 WebHandler
默認實現是FilteringWebHandler
,它是與過濾器有關的。它會加載網關中配置的多個過濾器。放入集合中排序,形成過濾器鏈(Filter chain
)。然后依次執行這些過濾器。- 圖中
Filter
被虛線分為左右兩部分,是因為Filter
內部的邏輯分為pre
和post
兩部分,分別會在請求路由到微服務之前和之后被執行。 - 只有所有
Filter
的pre
邏輯都依次順序執行通過后,就會進入Netty過濾器,它的作用是將請求轉發到微服務。 - 微服務會將結果返回到
Netty過濾器
,Netty過濾器
會將結果封裝,然后存到上下文里面,接下來再倒序執行Filter
的post
邏輯依次返回給其他過濾器,最終返回給用戶。 - 最終把響應結果返回。
如圖中所示,最終請求轉發是有一個名為NettyRoutingFilter
的過濾器來執行的,而且這個過濾器是整個過濾器鏈中順序最靠后的一個。如果我們能夠定義一個過濾器,在其中實現登錄校驗邏輯,并且將過濾器執行順序定義到NettyRoutingFilter
之前,這就符合我們的需求了!
網關如何將用戶信息傳遞給微服務?
網關沒有業務的,但是微服務要得到用戶信息。網關到微服務是一次新的http請求,通過一次http請求傳遞信息,最佳的傳遞方案是請求頭,因為放到請求頭中是不會對業務產生影響的。
如何在為u服務之間傳遞用戶信息?
一些復雜的業務會出現微服務之間的調用,比如下單完成之后需要清理用戶的購物車,所以交易服務將來還可能調用購物車服務,完成購物車的清理。在網關到交易服務的時候,會將用戶信息通過請求頭傳遞過來。但是在交易服務到購物車服務之間又是一次新的http請求,如果不對接收的請求做處理,那么肯定不會將用戶信息向下傳遞,那么購物城服務就拿不到服務。微服務之間的請求是基于OpenFign發起的,網關的微服務發送請求則是網關內置的一次請求方式,所以在微服務之間發送用戶信息,不能通過請求頭的方式。
自定義過濾器
網關過濾器鏈中的過濾器有兩種:
GatewayFilter
:路由過濾器,就是之前33種過濾器,作用范圍比較靈活,可以是任意指定的路由Route
.GlobalFilter
:全局過濾器,作用范圍是所有路由,不可配置。
其實GatewayFilter
和GlobalFilter
這兩種過濾器的方法簽名完全一致:
/*** 處理請求并將其傳遞給下一個過濾器* @param exchange 當前請求的上下文,其中包含request、response等各種共享數據* @param chain 過濾器鏈,當前過濾器執行完后,要調用過濾器鏈種的下一個過濾器。* @return 根據返回值標記當前請求是否被完成或攔截,chain.filter(exchange)就放行了。*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
網關的過濾器內部分為pre和post,在實現filter方法以后,內部實現的所有邏輯都屬于pre部分,當pre執行完,可以調用過濾器chain,利用chain調用下一個過濾器。當所有的過濾器執行完之后,會將請求轉發給微服務,然后才能執行post邏輯。這樣post就需要等待很長時間,如果所有的過濾器都這樣等待i,那么耗時就會很久。所以網關采用的是一種非阻塞式的編程,需要利用Mono定義回調函數,這樣就不需要等待了,返回結果有了以后再調用它的回調函數Mono
,回調函數里面的邏輯就是post部分的邏輯。在大多數的業務種都不需要關系post部分。
無論是GatewayFilter
還是GlobalFilter
都支持自定義,只不過編碼方式、使用方式略有差別。
自定義GlobalFilter
自定義GlobalFilter則簡單很多,直接實現GlobalFilter即可,而且也無法設置動態參數:
// 加上Component注解,將其注冊為Spring的一個Bean
@Component
// 實現GlobalFilter,并實現其Filter方法。
// Ordered是做排序的,它是Spring核心包下的,要求實現一個getOrder方法,這個值越小代表優先級越高,要保證自定義的過濾器在Netty過濾器之前,Netty過濾器默認的優先級為最低優先級。
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 編寫過濾器pre邏輯,完成登錄校驗。// TODO 模擬登錄校驗邏輯// 獲取請求頭,拿到登錄憑證。ServerHttpRequest request = exchange.getRequest();HttpHeaders headers = request.getHeaders();System.out.println("headers = " + headers);// 放行return chain.filter(exchange);}@Overridepublic int getOrder() {// 過濾器執行順序,值越小,優先級越高return 0;}
}
測試:先登錄用戶,然后打斷點,重啟網關服務。
自定義GatewayFilter
自定義GatewayFilter
不是直接實現GatewayFilter
,而是實現AbstractGatewayFilterFactory
。最簡單的方式是這樣的:
@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {@Overridepublic GatewayFilter apply(Object config) {return new GatewayFilter() {@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 獲取請求ServerHttpRequest request = exchange.getRequest();// 編寫過濾器邏輯System.out.println("過濾器執行了");// 放行return chain.filter(exchange);}};}
}
注意:該類的名稱一定要以GatewayFilterFactory
為后綴!
然后在yaml配置中這樣使用:
spring:cloud:gateway:default-filters:- PrintAny # 此處直接以自定義的GatewayFilterFactory類名稱前綴類聲明過濾器
另外,這種過濾器還可以支持動態配置參數,不過實現起來比較復雜,示例:
@Component
public class PrintAnyGatewayFilterFactory // 父類泛型是內部類的Config類型extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {@Overridepublic GatewayFilter apply(Config config) {// OrderedGatewayFilter是GatewayFilter的子類,包含兩個參數:// - GatewayFilter:過濾器// - int order值:值越小,過濾器執行優先級越高return new OrderedGatewayFilter(new GatewayFilter() {@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 獲取config值String a = config.getA();String b = config.getB();String c = config.getC();// 編寫過濾器邏輯System.out.println("a = " + a);System.out.println("b = " + b);System.out.println("c = " + c);// 放行return chain.filter(exchange);}}, 100);}// 自定義配置屬性,成員變量名稱很重要,下面會用到@Datastatic class Config{private String a;private String b;private String c;}// 將變量名稱依次返回,順序很重要,將來讀取參數時需要按順序獲取@Overridepublic List<String> shortcutFieldOrder() {return List.of("a", "b", "c");}// 返回當前配置類的類型,也就是內部的Config@Overridepublic Class<Config> getConfigClass() {return Config.class;}}
然后在yaml文件中使用:
spring:cloud:gateway:default-filters:- PrintAny=1,2,3 # 注意,這里多個參數以","隔開,將來會按照shortcutFieldOrder()方法返回的參數順序依次復制
上面這種配置方式參數必須嚴格按照shortcutFieldOrder()方法的返回參數名順序來賦值。
還有一種用法,無需按照這個順序,就是手動指定參數名:
spring:cloud:gateway:default-filters:- name: PrintAnyargs: # 手動指定參數名,無需按照參數順序a: 1b: 2c: 3
登錄校驗
接下來,我們就利用自定義GlobalFilter
來完成登錄校驗。
JWT工具
登錄校驗需要用到JWT,而且JWT的加密需要秘鑰和加密工具。這些在hm-service
中已經有了,我們直接拷貝過來:
具體作用如下:
AuthProperties
:配置登錄校驗需要攔截的路徑,因為不是所有的路徑都需要登錄才能訪問JwtProperties
:定義與JWT工具有關的屬性,比如秘鑰文件位置SecurityConfig
:讀取文件,生成密鑰。JwtTool
:JWT工具,其中包含了校驗和解析token
的功能hmall.jks
:秘鑰文件
其中AuthProperties
和JwtProperties
所需的屬性要在application.yaml
中配置:
hm:jwt:location: classpath:hmall.jks # 秘鑰地址alias: hmall # 秘鑰別名password: hmall123 # 秘鑰文件密碼tokenTTL: 30m # 登錄有效期auth:excludePaths: # 無需登錄校驗的路徑,可以直接訪問。- /search/**- /users/login- /items/**
登錄校驗過濾器
接下來,我們定義一個登錄校驗的過濾器:
代碼如下:
package com.hmall.gateway.filter;import com.hmall.common.exception.UnauthorizedException;
import com.hmall.common.utils.CollUtils;
import com.hmall.gateway.config.AuthProperties;
import com.hmall.gateway.util.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;import java.util.List;@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {private final JwtTool jwtTool;private final AuthProperties authProperties;private final AntPathMatcher antPathMatcher = new AntPathMatcher();@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {// 1.獲取RequestServerHttpRequest request = exchange.getRequest();// 2.判斷是否不需要攔截if(isExclude(request.getPath().toString())){// 無需攔截,直接放行return chain.filter(exchange);}// 3.獲取請求頭中的tokenString token = null;List<String> headers = request.getHeaders().get("authorization");if (!CollUtils.isEmpty(headers)) {token = headers.get(0);}// 4.校驗并解析tokenLong userId = null;try {userId = jwtTool.parseToken(token);} catch (UnauthorizedException e) {// 如果無效,攔截ServerHttpResponse response = exchange.getResponse();// 401代表未登錄或者未授權。response.setRawStatusCode(401);// 終止,后續所有的攔截器都不會執行了。return response.setComplete();}// TODO 5.如果有效,傳遞用戶信息System.out.println("userId = " + userId);// 6.放行return chain.filter(exchange);}private boolean isExclude(String antPath) {// 對于一些特殊的路徑帶/**或通配符的路徑,通過Spring提供的AntPathMatcher匹配器來匹配。for (String pathPattern : authProperties.getExcludePaths()) {if(antPathMatcher.match(pathPattern, antPath)){return true;}}return false;}@Overridepublic int getOrder() {return 0;}
}
重啟測試,會發現訪問/items開頭的路徑,未登錄狀態下不會被攔截:
訪問其他路徑則,未登錄狀態下請求會被攔截,并且返回401
狀態碼:
登錄成功后,查詢購物車是成功的。
微服務獲取用戶
現在,網關已經可以完成登錄校驗并獲取登錄用戶身份信息。但是當網關將請求轉發到微服務時,微服務又該如何獲取用戶身份呢?最佳的方案是將用戶信息保存在請求頭中,這樣微服務就可從請求頭中取出用戶信息,接下來就能實現自己的業務了。將來微服務的業務很多,可能每個用戶都需要得到登錄用戶的信息。如果將獲取請求頭中用戶信息的業務邏輯在每個微服務中都寫一遍,就會很麻煩。
微服務的接口都是基于SpringMVC實現的,現在不想在每一個業務接口里都去獲取登錄用戶,而是想直接用。所以要在所有業務執行之前獲取用戶信息,只要SpringMVC的攔截器才會在Controller之前執行。可以在微服務里定義一個SpringMVC的攔截器,這個攔截器里獲取請求頭中的用戶信息,將其保存在ThreadLocal
里,這樣在后續的業務執行過程中可以隨時從ThreadLocal
里取出登錄信息。
因此,接下來我們要做的事情有:
- 改造網關過濾器,在獲取用戶信息后保存到請求頭,轉發到下游微服務
- 編寫微服務攔截器,攔截請求獲取用戶信息,保存到
ThreadLocal
后放行。
保存用戶到請求頭
在網關登錄校驗過濾器中,把獲取到的用戶寫入請求頭。修改gateway模塊中的登錄校驗攔截器,在校驗成功后保存用戶到下游請求的請求頭中。要修改轉發到微服務的請求,需要用到ServerWebExchange
類提供的API。
exchange.mutate() // mutate就是對下游請求做更改.request(builder -> builder.header("user-info",userInfo)).build();
首先,我們修改登錄校驗攔截器的處理邏輯,保存用戶信息到請求頭中:
測試:
查詢購物車信息的時候,打印請求頭中的用戶信息。
在查詢購物車的Controller中添加打印用戶信息的代碼:
重啟服務,查看是否傳遞用戶信息。
說明已經在token里解析出用戶信息,并將用戶信息傳遞給下一個微服務里。
攔截器獲取用戶
ThreadLocal對象不需要自己創建,在hm-common中已經有一個用于保存登錄用戶的ThreadLocal
工具:
其中已經提供了保存和獲取用戶的方法:
接下來,我們只需要編寫攔截器,獲取用戶信息并保存到UserContext
,然后放行即可。這個攔截器是不需要登錄攔截的,真正的登錄攔截在網關中已經做過了,只要請求到這里就代表登錄成功了或者不登錄也能訪問。所以這個攔截器只需要做用戶信息獲取即可。
在這里只需要實現handlerInterceptor
中的兩個方法即可,preHandle
和afterCompletion
。preHeadle在Controller之前執行,需要在里面獲取登錄用戶的信息,最終要將其保存在ThreadLocal里。Controller執行完成之后還需要將ThreadLocal里的信息清理掉,所以使用afterCompletion清理。
由于每個微服務都有獲取登錄用戶的需求,因此攔截器我們直接寫在hm-common
中,并寫好自動裝配。這樣微服務只需要引入hm-common
就可以直接具備攔截器功能,無需重復編寫。
我們在hm-common
模塊下定義一個攔截器:
具體代碼如下:
package com.hmall.common.interceptor;import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;public class UserInfoInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.獲取請求頭中的用戶信息String userInfo = request.getHeader("user-info");// 2.判斷是否為空if (StrUtil.isNotBlank(userInfo)) {// 不為空,保存到ThreadLocalUserContext.setUser(Long.valueOf(userInfo));}// 3.放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用戶UserContext.removeUser();}
}
接著在hm-common
模塊下編寫SpringMVC
的配置類,配置登錄攔截器:
具體代碼如下:
package com.hmall.common.config;import com.hmall.common.interceptors.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration
// h-common不僅僅被微服務引用了,而且也被網關引用了。但是我們希望MvcCongif在網關里不生效,在SpringMVC里生效。這就需要用到SpringBoot自動裝配的原理,給這個配置類加一個條件,讓其在網關里面不生效,在微服務里面生效。這就需要對比網關與其他微服務的區別,網關里面沒有SpringMVC,而微服務里面有。可以利用這個作為條件,有SpirngMvc就會有相關的api,有SpringMvc一定有其核心api,也就是DispatcherServlet。如果將其作為條件,所有微服務都能生效,網關由于沒有SpringMvc不會生效。
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new UserInfoInterceptor());}
}
不過,需要注意的是,這個配置類默認是不會生效的,因為它所在的包是com.hmall.common.config
,與其它微服務的掃描包不一致,無法被掃描到,因此無法生效。
基于SpringBoot的自動裝配原理,我們要將其添加到resources
目錄下的META-INF/spring.factories
文件中:
內容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\com.hmall.common.config.MyBatisConfig,\com.hmall.common.config.MvcConfig
恢復購物車代碼
之前我們無法獲取登錄用戶,所以把購物車服務的登錄用戶寫死了,現在需要恢復到原來的樣子。
找到cart-service
模塊的com.hmall.cart.service.impl.CartServiceImpl
:
修改其中的queryMyCarts
方法:
測試:
啟動CartService
,在根據用戶id獲取購物車信息中,可以看到用戶id,說明用戶id成功調用了。
切換用戶rose:
總結:
首先,需要在網關獲取用戶信息并且向下傳遞。網關獲取用戶信息,通過過濾器攔截以及jwt校驗。傳遞需要用到exchange的api修改請求,將用戶信息添加到請求頭,這樣網關在轉發請求到微服務的時候自然就攜帶微服務的信息。
請求到達微服務的時候需要微服務去解析,如果在每個微服務的controller中手動解析將會很麻煩。所以可以基于SpringMvc
攔截器,攔截器可以在Controller之前執行,這個SpringMvc攔截器如果每個微服務都寫也會很麻煩。最終可以將攔截器寫在common模塊里,在這個模塊里獲取請求頭中的用戶信息,然后保存在ThreadLocal
中,后續所有的業務都可以從ThreadLocal中取用戶信息,執行業務。
在定義攔截器的時候,也會碰到一些問題,將攔截器的配置放到common模塊下,會導致其他的微服務掃描不到。需要使用SpingBoot
的自動裝配原理,將定義的配置類放在META-INF
的spring.factories
文件下,這樣就能實現自動裝配。這樣帶來的另一個問題是,這個配置類只希望在微服務里面生效,不希望在網關中生效。因此,可以使用條件注解判斷當前項目下有沒有SpringMvc
的DispatcherServlet
,網關里面沒有就不會生效。
OpenFeign傳遞用戶
前端發起的請求都會經過網關再到微服務,由于我們之前編寫的過濾器和攔截器功能,微服務可以輕松獲取登錄用戶信息。
但有些業務是比較復雜的,請求到達微服務后還需要調用其它多個微服務。比如下單業務,流程如下:
下單的過程中,需要調用商品服務扣減庫存,調用購物車服務清理用戶購物車。而清理購物車時必須知道當前登錄的用戶身份。但是,訂單服務調用購物車時并沒有傳遞用戶信息,購物車服務無法知道當前用戶是誰!由于微服務獲取用戶信息是通過攔截器在請求頭中讀取,因此要想實現微服務之間的用戶信息傳遞,就必須在微服務發起調用時把用戶信息存入請求頭。
微服務之間調用是基于OpenFeign來實現的,并不是我們自己發送的請求。我們如何才能讓每一個由OpenFeign發起的請求自動攜帶登錄用戶信息呢?這里要借助Feign中提供的一個攔截器接口:feign.RequestInterceptor
,所有由OpenFeign發起的請求都會先調用攔截器處理請求。
public interface RequestInterceptor {/*** Called for every request. * Add data using methods on the supplied {@link RequestTemplate}.*/void apply(RequestTemplate template);
}
我們只需要實現這個接口,然后實現apply方法,利用RequestTemplate
類來添加請求頭,將用戶信息保存到請求頭中。這樣以來,每次OpenFeign發起請求的時候都會調用該方法,傳遞用戶信息。將來所有的微服務都可能調用其他的服務,在調用其他服務的時候都需要傳遞用戶信息,所以在做服務調用的時候都需要去傳遞,所以需要將攔截器放到一個公共的地方。
由于FeignClient
全部都是在hm-api
模塊,因此我們在hm-api
模塊的com.hmall.api.config.DefaultFeignConfig
中編寫這個攔截器:
在com.hmall.api.config.DefaultFeignConfig
中添加一個Bean:
@Bean
public RequestInterceptor userInfoRequestInterceptor(){return new RequestInterceptor() {@Overridepublic void apply(RequestTemplate template) {// 獲取登錄用戶Long userId = UserContext.getUser();if(userId == null) {// 如果為空則直接跳過return;}// 如果不為空則放入請求頭中,傳遞給下游微服務template.header("user-info", userId.toString());}};
}
由于要用到UserContext
,所以需要在依賴中引入common模塊:
<!--common--><dependency><groupId>com.heima</groupId><artifactId>hm-common</artifactId><version>1.0.0</version></dependency>
DefaultFeignConfig
配置類要想生效,需要加在Feign啟動類上,交易服務的啟動類上應該加上這個配置類。
測試:
下單成功后
查看購物車服務的日志:
好了,現在微服務之間通過OpenFeign調用時也會傳遞登錄用戶信息了。
總結:
微服務下實現登錄功能的流程: