本文主要介紹JWT(JSON Web Token)授權機制在前后端分離中的應用與實踐,包括以下三部分:
- JWT原理介紹
- JWT的安全性
- React.js+Flux架構下的實踐(React-jwt example)
0 關於前后端分離
前后端分離是一個很有趣的議題,它不僅僅是指前后端工程師之間的相互獨立的合作分工方式,更是前后端之間開發模式與交互模式的模塊化、解耦化。計算機世界的經驗告訴我們,對於復雜的事物,模塊化總是好的,無論是后端API開發中越來越成為規范的RESTful API風格,還是Web前端越來越多的模板、框架(參見MVC,MVP 和 MVVM 的圖示),包括移動應用中前后端天然分離的特質,都證實了前后端分離的重要性與必要性(更生動的細節與實例說明可以參看赫門分享的主題淘寶前后端分離實踐)。
實現前后端分離,對於后端開發人員來說是一件很幸福的事情,因為不需要再考慮怎樣在HTML中套入數據,只關心數據邏輯的處理;而前端則需要承擔接收數據之后界面呈現、用戶交互、數據傳遞等所有任務。雖然這看起來加重了前端的工作量,但實際上有越來越多豐富多樣的前端框架可供選擇,這讓前端開發變得越來越結構化、系統化,前端工程師也不再只是“套版的”。
在所有前端框架中,Facebook推出的React無疑是當下最熱門(之一),然而React只負責界面渲染層面,相當於MVC中的V(View),因此只靠React無法完成一個完整的單頁應用(Single Page App)。Facebook另外推出與之配套的Flux架構,主要為了避免Angular.js之類MVC的架構模式,規避數據雙向綁定而采用單向綁定的數據傳遞方式。實際上React無論是學習還是使用都是非常簡單的,而Flux則需要花更多時間去理解消化,本文第3部分我采用Flux架構的一種實現Reflux.js,做了一個基於JWT授權機制的登入、登出的例子,順便介紹Flux架構的細節。
1 JWT 介紹及其原理
JWT是我之前做Android應用的時候了解到的一種用戶授權機制,雖然原生的移動手機應用與基於瀏覽器的Web應用之間存在很多差異,但很多情況下后端往往還是沿用已有的架構跟代碼,所以用戶授權往往還是采用Cookie+Session的方式,也就是需要原生應用中模擬瀏覽器對Cookie的操作。
Cookie+Session的存在主要是為了解決HTTP這一無狀態協議下服務器如何識別用戶的問題,其原理就是在用戶登錄通過驗證后,服務端將數據加密后保存到客戶端瀏覽器的Cookie中,同時服務器保留相對應的Session(文件或DB)。用戶之后發起的請求都會攜帶Cookie信息,服務端需要根據Cookie尋回對應的Session,從而完成驗證,確認這是之前登陸過的用戶。其工作原理如下圖所示:
JWT是Auth0提出的通過對JSON進行加密簽名來實現授權驗證的方案,編碼之后的JWT看起來是這樣的一串字符:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
由.
分為三段,通過解碼可以得到:
// 1. Headers // 包括類別(typ)、加密算法(alg); { "alg": "HS256", "typ": "JWT" } // 2. Claims // 包括需要傳遞的用戶信息; { "sub": "1234567890", "name": "John Doe", "admin": true } // 3. Signature // 根據alg算法與私有秘鑰進行加密得到的簽名字串; // 這一段是最重要的敏感信息,只能在服務端解密; HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), SECREATE_KEY )
在使用過程中,服務端通過用戶登錄驗證之后,將Header+Claim信息加密后得到第三段簽名,然后將簽名返回給客戶端,在后續請求中,服務端只需要對用戶請求中包含的JWT進行解碼,即可驗證是否可以授權用戶獲取相應信息,其原理如下圖所示:
通過比較可以看出,使用JWT可以省去服務端讀取Session的步驟,這樣更符合RESTful的規范。但是對於客戶端(或App端)來說,為了保存用戶授權信息,仍然需要通過Cookie或類似的機制進行本地保存。因此JWT是用來取代服務端的Session而非客戶端Cookie的方案,當然對於客戶端本地存儲,HTML5提供了Cookie之外更多的解決方案(localStorage/sessionStorage),究竟采用哪種存儲方式,其實從Js操作上來看沒有本質上的差異,不同的選擇更多是出於安全性的考慮。
2 JWT 安全性
用戶授權這樣敏感的信息,安全性當然是首先需要考慮的因素。這里主要討論在使用JWT時如何防止XSS和XSRF兩種攻擊。
XSS是Web中最常見的一種漏洞(我們的**學報官網就存在這個漏洞這件事我就不說了=.=),其主要原因是對用戶輸入信息不加過濾,導致用戶(被誤導)惡意輸入的Js代碼在訪問該網頁時被執行,而Js可以讀取當前網站域名下保存的Cookie信息。針對這種攻擊,無論是Cookie還是localStorage中的信息都有可能被竊取,但防止XSS也相對簡單一些,對用戶輸入的所有信息進行過濾即可。另外,現在越來越多的CDN服務,讓我們可以節省服務器流量,但同時也有可能引入不安全的Js腳本,例如前段時間Github被Great Cannon轟擊的案例,則需要提高對某度之類服務的警惕。
另外一種更加棘手的XSRF漏洞主要利用Cookie是按照域名存儲,同時訪問某域名時瀏覽器會自動攜帶該域名所保存的Cookie信息這一特征。如果執意要將JWT存儲在Cookie中,服務端則需要額外驗證請求來源,或者在提交表單中加入隨機簽名並在處理表單時進行驗證。
我在后面的實例中采用將JWT保存在localStorage中的方案,請求時將JWT放入Request Header中的Authorization位。對JWT安全性問題想要了解更多可以參考下面幾篇文章:
- Where to Store Your JWTs - Cookies vs HTML5 Web Storage
- Use JWT the Right Way!
- 10 Things You Should Know about Tokens
- Where to store JWT in browser? How to protect against CSRF?
3 React-jwt Example
本節源碼可見Github: react-jwt-example。
前面提到的React.js框架學習成本其實非常低,只要跟着官方教程走一遍,搞清楚props、states、virtual DOM幾個概念,就可以開始用了。但是只有View層什么都做不了,Facebook推出配套的Flux架構,一開始看到下面這張架構圖,當時我就懵逼了。
好在Flux只是一種理論架構,雖然官方也提供了實現方案,但是我更傾向於Reflux.js的實現方式,如下圖所示:
其中View Components即視圖層由React負責,Stores用於存儲數據,Actions則用於監聽所有動作,所有數據的傳遞都是單向綁定的,在分割不同模塊時,可以清楚地看到數據的流動方向。
我嘗試寫了一個簡單的登錄、登出以及獲取用戶個人數據的例子,除了Reflux之外,還用到如下模塊:
- react-router: SPA路由;
- react-bootstrap: React化的Bootstrap,UI樣式;
- reqwest: Ajax請求;
- jwt-decode: 客戶端的JWT解碼;
另外服務端API采用Go gin框架,依賴於jwt-go。代碼目錄結構如下:
tree -I 'node_modules|.git' . ├── README.md ├── gulpfile.js ├── index.html ├── package.json ├── scripts │ ├── actions │ │ └── actions.js │ ├── app.js │ ├── build │ │ └── dist.js │ ├── components │ │ └── HelloWorld.js │ ├── stores │ │ ├── loginStore.js │ │ └── userStore.js │ └── views │ ├── home.js │ ├── login.js │ └── profile.js └── server.go
完整的頁面放在view中,可復用的組件放在components,用戶的動作包括login、logout以及getBalance,因此需要創建相應的action來監聽這些動作:
// actions.js var actions = Reflux.createActions({ "login": {}, "updateProfile": {}, // login成功更新用戶數據 "loginError": {}, // login失敗錯誤信息 "logout": {}, "getBalance": {asyncResult: true} }); actions.login.listen(function(data){});
用戶點擊view中的Submit Button時,將表單信息提交給login action:
// views/login.js var Login = React.createClass({ ... login: function (e) { e.preventDefault(); actions.login({ name: this.refs.name.getValue(), pass: this.refs.pass.getValue(), }), ... }); // actions.js var req = require('reqwest'); actions.login.listen(function(data){ req({ url: HOST+"/user/token", method: "post", data: JSON.stringify(data), type: 'json', contentType: 'application/json', headers: {'X-Requested-With': 'XMLHttpRequest'}, success: function (resp) { if(resp.code == 200){ actions.updateProfile(resp.jwt) }else{ actions.updateProfile(resp.msg) } }, }) });
根據API返回結果,將再次觸發updateProfile或updateProfile action,而分別由userStore和loginStore接收:
// stores/userStore.js var userStore = Reflux.createStore({ listenables: actions, // 聲明userStore所監聽的action updateProfile: function(jwt){ // 注冊監聽actions.updateProfile localStorage.setItem('jwt', jwt); this.user = jwt_decode(jwt); this.user.logd = true; this.trigger(this.user); }, }) // stores/loginStore.js var loginStore = Reflux.createStore({ listenables: actions, loginError: function(msg){ this.trigger(msg); }, });
store接收action數據后,通過this.trigger(msg)
將處理過后的數據重新傳遞會view:
var Login = React.createClass({ mixins : [ Router.Navigation, Reflux.listenTo(userStore, 'onLoginSucc'), Reflux.listenTo(loginStore, 'onLoginErr') ], onLoginSucc: function(){ // 登錄成功,跳轉回首頁 this.transitionTo('home'); }, onLoginErr: function (msg) { // 登錄失敗,顯示錯誤信息 this.setState({ errorMsg: msg, }); }, ... });
至此,從用戶點擊登錄到登錄結果傳回,整個流程數據在View->Action->Store->View
中完成單向傳遞,這就是Flux架構的基本概念。
在完成登錄后,API會將驗證通過的JWT傳回:
// server.go token := jwt.New(jwt.SigningMethodHS256) // Headers token.Header["alg"] = "HS256" token.Header["typ"] = "JWT" // Claims token.Claims["name"] = validUser.Name token.Claims["mail"] = validUser.Mail token.Claims["exp"] = time.Now().Add(time.Hour * 72).Unix() tokenString, err := token.SignedString([]byte(mySigningKey)) if err != nil { c.JSON(200, gin.H{"code": 500, "msg": "Server error!"}) return } c.JSON(200, gin.H{"code": 200, "msg": "OK", "jwt": tokenString})
當登錄之后的用戶在profile頁面發起getBalance請求時,存儲於本地的jwt將一起傳遞,我這里采用Header的方式傳遞,具體取決於API端的協議:
// actions.js actions.getBalance.listen(function(){ var jwt = localStorage.getItem('jwt'); req({ url: HOST+"/user/balance", method: "post", type: "json", headers: { 'Authorization': "Bearer "+jwt, }, success: function (resp) { if (resp.code == 200) { actions.updateProfile(resp.jwt); }else{ actions.loginError(resp.msg); } } }) })
而服務端面對任何需要驗證權限的請求需要通過Token驗證:
//server.go token, err := jwt.ParseFromRequest(c.Request, func(token *jwt.Token) (interface{}, error) { b := ([]byte(mySigningKey)) return b, nil })
- END -
if(post.content.isHelpful){ $("button#donate").click(); };