作者:高鬆
原文地址:yisaer.github.io/2019/02/16/…
介紹
網際網路服務離不開使用者認證。JSON Web Token(後簡稱JWT)是一個輕巧,分散式的使用者授權鑑權規範。和過去的session資料持久化的方案相比,JWT有著分散式鑑權的特點,避免了session使用者認證時單點失敗引起所有服務都無法正常使用的窘境,從而在微服務架構設計下越來越受歡迎。然而JWT單點授權,分佈鑑權的特點也給我們帶來了一個問題,即服務端無法主動回收或者BAN出相應的Token,使得即使某個服務主動封禁了一個使用者時,這個使用者同樣可以使用之前的JWT來從其他服務獲取資源。本文我們將闡述利用Istio Mixer Adapter的能力,來將所有請求在服務網格的入口邊緣層進行JWT檢查的例子,從而實現使用者封禁與主動逐出JWT等功能。
背景
在我之前的投稿中,描繪了一個非常簡單的基於K8S平臺的業務場景,在這裡我們將會基於這個場景來進行討論。 對於一個簡單的微服務場景,我們有著三個服務在Istio服務網格中管理。同時叢集外的請求將會通過nginx-ingress轉發給istio-ingressgateway以後,通過Istio VirtualService的HTTPRoute的能力轉發給對應的服務,這裡不再贅述。
從下圖的架構模式中,我們可以看到所有的請求在進入網格時,都會通過istio-ingressgateway這個邊緣節點,從而湧現出了一個非常顯而易見的想法,即如果我們在所有的請求進入服務網格邊緣時,進行特定的檢查與策略,那麼我們就能將某些不符合某種規則的請求拒絕的網格之外,比如那些攜帶被主動封禁JWT的HTTP請求。
瞭解Istio Mixer
為了達到我們上述的目的,我們首先需要了解一下Istio Mixer這個網格控制層的元件。 Istio Mixer 提供了一個介面卡模型,它允許我們通過為Mixer建立用於外部基礎設施後端介面的處理器來開發介面卡。Mixer還提供了一組模版,每個模板都為介面卡提供了不同的後設資料集。在我們的場景下,我們將使用Auhtorization模板來獲取我們每個請求中的後設資料,然後通過Mixer check的模式來將在HTTP請求通過istio-ingressgateway進入服務網格前,通過Mixer Adapter來進行檢查。
在Istio Mixer的描述中,我們可以發現每個請求在到達資料層時,都會向Mixer做一次check操作,而當請求結束後則會向Mixer做一次report操作。在我們的場景中,我們將會在請求到達istio-ingressgateway時檢查這個請求中的JWT鑑權,通過JWT的Payload中的資訊來決定是否要將請求放行進入網格內部。
得益於Mixer強大的擴充套件能力,我們將通過經典的Handler-Instances-Rule適配模型來一步步展開,同時也意味著我們將要編寫一個自定義的Istio Mixer Adapter。
Mixer適配模型
那麼怎麼通俗易懂的理解Handler-Instances-Rule這三者的關係呢?在我的理解下,當每個請求在服務網格的資料層中游走時,都會在開始與結束時帶上各種元資訊向Mixer元件通訊。而Mixer元件則會根據Rule來將特定的請求中的特定的資料交給特定的處理器去檢查或者是記錄。那麼對於特定的請求,則是通過Rule去決定;對於特定的資料,則是通過Instances去決定;對於特定的處理器,則是通過Handler去決定。最終Rule還把自己與Instances和Handler繫結在一起,從而讓Mixer理解了將哪些請求用哪些資料做哪些處理。 在這裡我們可以通過Istio Policies Task中的黑白名單機制來理解一下這個模型。
在這裡appversion.listentry作為Instances,通過將list entry作為模版,獲取了每個請求中的source.labels["version"]的值,即特定的資料。whitelist.listchecker作為handler,則是告訴了背後的處理器作為白名單模式只通過資料是v1與v2的請求,即特定的處理器。最後checkversion.rule作為rule,將appversion.listentry和whitelist.listchecker兩者繫結在一起,並通過match欄位指明哪些請求會經過這些處理流程,即特定的請求。
## instances apiVersion: config.istio.io/v1alpha2 kind: listentry metadata: name: appversion spec: value: source.labels["version"] --- ## handler apiVersion: config.istio.io/v1alpha2 kind: listchecker metadata: name: whitelist spec: # providerUrl: ordinarily black and white lists are maintained # externally and fetched asynchronously using the providerUrl. overrides: ["v1", "v2"] # overrides provide a static list blacklist: false --- ## rule apiVersion: config.istio.io/v1alpha2 kind: rule metadata: name: checkversion spec: match: destination.labels["app"] == "ratings" actions: - handler: whitelist.listchecker instances: - appversion.listentry複製程式碼
JWT Check 的架構設計
當我們理解了以上的Mixer擴充套件模型以後,那麼對於我們在文章開頭中的JWT封禁需求的Handler-Instances-Rule的模型就非常顯而易見了。在我們的場景下,我們需要將所有帶有JWT並且從istio-ingressgateway準備進入網格邊緣的請求作為我們特定的請求,然後從每個請求中,我們都要獲取request.Header["Authorization"]這個值來作為我們特定的資料,最後我們通過特定的處理器來解析這個資料,並在處理器中通過自定義的策略來決定這個請求是否通過。
當我們搞清楚了這麼一個模型以後,那麼之後的問題就一下子迎刃而解了。在我們的設計中,我們將要自定義一個JWTAdapter服務來作為特定的處理器,JWTAdapter將會通過HTTP通訊把資料轉交給Adapter-Service來讓Adapter-Service來判斷這個請求是否合法,而Adapter-Service的憑證則是通過與業務服務的通訊所決定。
在我們的場景中,假設每個請求所攜帶的JWT的Payload中有一個email屬性來作為使用者的唯一標識,當業務領域中的賬戶服務決定封禁某個使用者時,他將會通知Adapter-Service,後者將會把這個資訊存於某個資料持久服務中,比如Redis服務。當JWT-Adapter服務向Adapter-Service服務詢問這個請求是否合法時,Adapter-Service將會通過Payload中Email屬性在Redis中查詢,如果查詢到對應的資料,則代表這個使用者被封禁,即這個請求不予通過,反之亦然。
如何自定義編寫一個Adapter?
說實話,自定義編寫Adapter是一個上手門檻較為陡峭的一件事情。我在這裡因為篇幅原因不能完全一步步細說自定義Adapter的步驟。在這裡我推薦對自定義編寫Adapter有興趣的人可以根據官網的自定義Mixer Adapter開發指南和自定義Mixer Adapter詳細步驟來進行學習和嘗試。在這裡我給出在我的JWT-Adapter中的關鍵函式來進行描述。
func (s *JwtAdapter) HandleAuthorization(ctx context.Context, r *authorization.HandleAuthorizationRequest) (*v1beta1.CheckResult, error) { log.Infof("received request %v\n", *r) props := decodeValueMap(r.Instance.Subject.Properties) var Authorization interface{} if len(props["custom_token_auth"].(string)) > 0 { Authorization = props["custom_token_auth"] } else { // 沒有獲取到JWT,直接將請求放行 return &v1beta1.CheckResult{ Status: status.OK, }, nil } cookie := props["custom_request_cookie"] host := props["custom_request_host"] if host == "www.example.com" { url := userService + "/check" request, err := http.NewRequest("GET", url, nil) if err != nil { //出現異常時,直接將請求放行 return &v1beta1.CheckResult{ Status: status.OK, }, nil } request.Header.Add("Content-Type", "application/json; charset=utf-8") request.Header.Add("cookie", cookie.(string)) request.Header.Add("Authorization", Authorization.(string)) // 傳送請求給Adapter-Service response, _ := client.Do(request) if response != nil && response.StatusCode == http.StatusOK { body, err := ioutil.ReadAll(response.Body) if err != nil { //如果有異常 log.Infof(err) //記錄異常即刻 } else { log.Infof("success to get response from adapter-service") var value map[string]interface{} json.Unmarshal(body, &value) if value["pass"] == false { //當使用者確實返回處於封禁狀態中時,才返回封禁結果 return &v1beta1.CheckResult{ Status: status.WithPermissionDenied("Banned"), }, nil } } } } log.Infof("jwtadapter don't have enough reason to reject this request") return &v1beta1.CheckResult{ Status: status.OK, }, nil } 複製程式碼
通過以上描述可以發現的是,在我們的場景下,我們當且僅當從Adapter-Service中確實得到了不允許通過的結果才將這個請求進行拒絕處理,而對其他情況一律進行了放行處理,即使發生了某些錯誤與異常。由於我們的錯誤處理會直接影響到這些請求能否在網格中通行,所在做Istio Mixer Check時需要時刻記住的到底是放行特定的請求,還是拒絕特定的請求,在這一點處理上需要十分謹慎與小心。
Handler-Instances-Rule
當我們將自己的Adapter上線以後,我們只要通過宣告我們得的Mixer擴充套件模型讓Mixer識別這個Adapter並且正確處理我們想要的請求即可。這裡我們再回顧一下我們之前所提到的特定的請求,特定的資料,特定的處理器。 對於特定的請求,我們需要將網格邊緣的請求篩選出來,所以我們可以通過host是www.example.com並且攜帶了JWT作為條件將請求篩選出來。對於特定的資料,我們選用authorization作為模版,取出header中的JWT資料,最後通過特定的處理器,將這個check請求交給jwt-adapter。至此,我們通過Istio Mixer Aadapter來進行JWT封禁的需求場景算是基本完成了。
# handler adapter apiVersion: "config.istio.io/v1alpha2" kind: handler metadata: name: h1 namespace: istio-system spec: adapter: jwtadapter connection: address: "[::]:44225" --- ## instances apiVersion: "config.istio.io/v1alpha2" kind: instance metadata: name: icheck namespace: istio-system spec: template: authorization params: subject: properties: custom_token_auth: request.headers["Authorization"] --- # rule to dispatch to handler h1 apiVersion: "config.istio.io/v1alpha2" kind: rule metadata: name: r1 namespace: istio-system spec: match: ( match(request.headers["Authorization"],"Bearer*") == true ) && ( match(request.host,"*.com") == true ) actions: - handler: h1.istio-system instances: - icheck ---複製程式碼
擴充套件閱讀
網格邊緣層驗證JWT的可行性?
既然在網格邊緣層能對JWT進行檢查,那麼能否可以做成在網格邊緣層同時也進行JWT的驗證?
答: 在我最初做Mixer Check時確實想到過這件事情,並且無獨有偶,在PlanGrid在Istio中的使用者鑑權實踐這篇文章中,PlanGrid通過EnvoyFilter實現了在網格邊緣層進行JWT以及其他鑑權協議的鑑權。但對此我的看法是,對於JWT鑑權的場景,我並不推薦這麼做。因為微服務場景中,我們使用JWT的初衷就是為了分散式鑑權來分散某個服務的單點故障所帶來的鑑權層的風險。當我們將使用者鑑權再一次集中在網格邊緣時,我們等於再一次將風險集中在了網格邊緣這個單點。一旦istio-ingressgateway掛了,那麼背後所有暴露的API服務將毫無防備,所以鑑權必須放在每個微服務內。另一方面,在我的《深入淺出istio》讀後感中提到,對於生產環境使用Istio,必須擁有一套備用的不使用Istio的環境方案,這意味著當Istio出現故障時,可以立即通過切換不使用Istio的備用環境來繼續提供服務。這同時意味著Istio所提供的能力與服務不應該與業務服務所強繫結在一起,這也是為什麼我在上文中將Jwt-Adapter與後面的Adapter-Service成為外掛服務的原因。JWT封禁使用者這個能力對我們就像一個外掛一樣,即裝即用。即使當我們切換為備用環境時無法使用Istio,暫時失去使用者封禁這個能力在我們的產品層面也完全可以接受,但對於使用者鑑權則不可能。所以這意味著當我們使用Istio的能力時,一定要時刻想清楚當我們失去Istio時我們該如何應對。
關於作者
從去年畢業以後一直對服務網格與CloudNative領域充滿興趣,在工作中使用Istio在生產環境中也將近有了半年多的時間,寫作分享則是平時的業餘愛好之一。如果你對服務網格或者是CloudNative領域有興趣,或者是對我的技術文章寫作有想法與建議的話,歡迎聯絡我交流。