JWT 在前后端分離中的應用與實踐
本文主要介紹JWT( JSON Web Token )授權機制在前后端分離中的應用與實踐,包括以下三部分:
- JWT原理介紹
- JWT的安全性
- React.js+Flux架構下的實踐( React-jwt example )
0 關于前后端分離
前后端分離是一個很有趣的議題,它不僅僅是指前后端工程師之間的相互獨立的合作分工方式,更是前后端之間開發模式與交互模式的模塊化、解耦化。計算機世界的經驗告訴我們,對于復雜的事物,模塊化總是好的,無論是后端API開發中越來越成為規范的 RESTful API 風格,還是Web前端越來越多的模板、框架(參見 MVC,MVP 和 MVVM 的圖示 ),包括移動應用中前后端天然分離的特質,都證實了前后端分離的重要性與必要性(更生動的細節與實例說明可以參看赫門分享的主題 淘寶前后端分離實踐 )。
實現前后端分離,對于后端開發人員來說是一件很幸福的事情,因為不需要再考慮怎樣在HTML中套入數據,只關心數據邏輯的處理;而前端則需要承擔 接收數據之后界面呈現、用戶交互、數據傳遞等所有任務。雖然這看起來加重了前端的工作量,但實際上有越來越多豐富多樣的前端框架可供選擇,這讓前端開發變 得越來越結構化、系統化,前端工程師也不再只是“套版的”。
在所有前端框架中,非死book推出的 React 無疑是當下最熱門(之一),然而React只負責界面渲染層面,相當于MVC中的V(View),因此只靠React無法完成一個完整的單頁應用( Single Page App )。非死book另外推出與之配套的 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層什么都做不了,非死book推出配套的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){}); </pre>
用戶點擊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
})