今天的文章介紹一種適用於restful+json的API認證方法,這個方法是基於jwt,而且加入了一些從oauth2.0借鑑的改良。html
首先要明白,認證和鑑權是不一樣的。認證是斷定用戶的合法性,鑑權是斷定用戶的權限級別是否可執行後續操做。這裏所講的僅含認證。認證有幾種方法:前端
這是http協議中所帶帶基本認證,是一種簡單爲上的認證方式。原理是在每一個請求的header中添加用戶名和密碼的字符串(格式爲「username:password」,用base64編碼)。python
這種方式至關於將「用戶名:密碼」綁定爲一個開放式證書,這會有幾個問題:web
- 每次請求都須要用戶名密碼,若是此鏈接未使用SSL/TLS,或加密被破解,用戶名密碼基本就暴露了;
- 沒法註銷用戶的登陸狀態;
- 證書不會過時,除非修改密碼。
整體來講,這種方法的特色就是,簡單但不安全。算法
將認證的結果存在客戶端的cookie中,經過檢查cookie中的身份信息來做爲認證結果。
這種方式的特色是便捷,且只須要一次認證,屢次可用;也能夠註銷登陸狀態和設置過時時間;甚至也有辦法(好比設置httpOnly)來避免XSS攻擊。shell
但它的缺點十分明顯,使用cookie那即是有狀態的服務了。數據庫
JWT協議彷佛已經應用十分普遍,JSON Web Token——一種基於token的json格式web認證方法。基本的原理是,第一次認證經過用戶名密碼,服務端簽發一個json格式的token。後續客戶端的請求都攜帶這個token,服務端僅須要解析這個token,來判別客戶端的身份和合法性。json
而JWT協議僅僅規定了這個協議的格式(RFC7519),它的序列生成方法在JWS協議中描述(https://tools.ietf.org/html/rfc7515),分爲三個部分:flask
聲明類型,這裏是jwt後端
聲明加密的算法 一般直接使用 HMAC SHA256
一種常見的頭部是這樣的:
{ 'typ': 'JWT', 'alg': 'HS256' }
再將其進行base64編碼。
payload是放置實際有效使用信息的地方。JWT定義了幾種內容,包括:
一個常見的payload是這樣的:
{'user_id': 123456, 'user_role': admin, 'iat': 1467255177}
事實上,payload中的內容是自由的,按照本身開發的須要加入。
Ps.有個小問題。使用itsdangerous包的TimedJSONWebSignatureSerializer進行token序列生成的結果,exp是在頭部裏的。這裏彷佛違背了jwt的協議規則。
存儲了序列化的secreate key和salt key。這個部分須要base64加密後的header和base64加密後的payload使用.鏈接組成的字符串,而後經過header中聲明的加密方式進行加鹽secret組合加密,而後就構成了jwt的第三部分。
目標場景是一個先後端分離的後端系統,用於運維工做,雖在內網使用,也有必定的保密性要求。
- API爲restful+json的無狀態接口,要求認證也是相同模式
- 可橫向擴展
- 較低數據庫壓力
- 證書可註銷
- 證書可自動延期
選擇JWT。
這裏使用python模塊itsdangerous,這個模塊能作不少編碼工做,其中一個是實現JWS的token序列。
genTokenSeq這個函數用於生成token。其中使用的是TimedJSONWebSignatureSerializer進行序列的生成,這裏secret_key密鑰、salt鹽值從配置文件中讀取,固然也能夠直接寫死在這裏。expires_in是超時時間間隔,這個間隔以秒記,能夠直接在這裏設置,我選擇將其設爲方法的形參(由於這個函數也用在瞭解決下提到的問題2)。
# serializer for JWT from itsdangerous import TimedJSONWebSignatureSerializer as Serializer """ token is generated as the JWT protocol. JSON Web Tokens(JWT) are an open, industry standard RFC 7519 method """ def genTokenSeq(self, expires): s = Serializer( secret_key=app.config['SECRET_KEY'], salt=app.config['AUTH_SALT'], expires_in=expires) timestamp = time.time() return s.dumps( {'user_id': self.user_id, 'user_role': self.role_id, 'iat': timestamp}) # The token contains userid, user role and the token generation time. # u can add sth more inside, if needed. # 'iat' means 'issued at'. claimed in JWT.
使用這個Serializer能夠幫咱們處理好header、signature的問題。咱們只須要用s.dumps將payload的內容寫進來。這裏我準備在每一個token中寫入三個值:用戶id、用戶角色id和當前時間(‘iat’是JWT標準註冊聲明中的一項)。
假設我所寫入的信息是
{ "iat": 1467271277.131803, "user_id": "46501228343b11e6aaa6a45e60ed5ed5f973ba0fcf783bb8ade34c7b492d9e55", "user_role": 3 }
採用以上的方法所生成的token爲
eyJhbGciOiJIUzI1NiIsImV4cCI6MTQ2NzM0MTQ3NCwiaWF0IjoxNDY3MzM3ODc0fQ.eyJpYXQiOjE0NjczMzc4NzQuNzE3MDYzLCJ1c2VyX2lkIjoiNDY1MDEyMjgzNDNiMTFlNmFhYTZhNDVlNjBlZDVlZDVmOTczYmEwZmNmNzgzYmI4YWRlMzRjN2I0OTJkOWU1NSIsInVzZXJfcm9sZSI6M30.23QD0OwLjdioKu5BgbaH2gHT2GoMz90n8VZcpvdyp7U
它是由「header.payload.signature」構成的。
解析須要使用到一樣的serializer,配置同樣的secret key和salt,使用loads方法來解析token。itsdangerous提供了各類異常處理類,用起來也很方便:
若是是SignatureExpired,則能夠直接返回過時;
若是是BadSignature,則表明了全部其餘簽名錯誤的狀況,因而又分爲:
- 能讀取到payload:那麼這個消息是一個內容被篡改、消息體加密過程正確的消息,secret key和salt極可能泄露了;
- 不能讀取到payload: 消息體直接被篡改,secret key和salt應該仍然安全。
以上內容寫成一個函數,用於驗證用戶token。若是實如今python flask,能夠考慮將此函數改成一個decorator修飾漆,將修飾器@到全部須要驗證token的方法前面,則代碼能夠更加優雅。
# serializer for JWT from itsdangerous import TimedJSONWebSignatureSerializer as Serializer # exceptions for JWT from itsdangerous import SignatureExpired, BadSignature, BadData # Class xxx # after definition of your class, here goes the auth method: def tokenAuth(token): # token decoding s = Serializer( secret_key=api.app.config['SECRET_KEY'], salt=api.app.config['AUTH_SALT']) try: data = s.loads(token) # token decoding faild # if it happend a plenty of times, there might be someone # trying to attact your server, so it should be a warning. except SignatureExpired: msg = 'token expired' app.logger.warning(msg) return [None, None, msg] except BadSignature, e: encoded_payload = e.payload if encoded_payload is not None: try: s.load_payload(encoded_payload) except BadData: # the token is tampered. msg = 'token tampered' app.logger.warning(msg) return [None, None, msg] msg = 'badSignature of token' app.logger.warning(msg) return [None, None, msg] except: msg = 'wrong token with unknown reason' app.logger.warning(msg) return [None, None, msg] if ('user_id' not in data) or ('user_role' not in data): msg = 'illegal payload inside' app.logger.warning(msg) return [None, None, msg] msg = 'user(' + data['user_id'] + ') logged in by token.' # app.logger.info(msg) userId = data['user_id'] roleId = data['user_role'] return [userId, roleId, msg]
檢查和斷定的機制以下:
- 使用加密的類,再用來解密(用上以前的密鑰和鹽值),獲得結果存入data;
- 若是捕獲到SignatureExpired異常,則表明根據token中的expired設置,token已經超時失效,返回‘token expired’;
- 若是是其餘BadSignature異常,又要分爲:
3.1 若是payload還完整,則解析payload,若是捕獲BadData異常,則表明token已經被篡改,返回‘token tampered’;
3.2 若是payload不完整,直接返回‘badSignature of token’;- 若是以上異常都不對,那隻能返回未知異常‘wrong token with unknown reason’;
- 最後,若是data能正常解析,則將payload中的數據取出來,驗證payload中是否有合法信息(這裏是user_id和user_role鍵值的json數據),若是數據不合法,則返回‘illegal payload inside’。一旦出現這種狀況,則表明密鑰和鹽值泄露的可能性很大。
上述的方法能夠作到基本的JWT認證,但在實際開發過程當中還有其餘問題:
token在生成以後,是靠expire使其過時失效的。簽發以後的token,是沒法收回修改的,所以涉及token的有效期的更改是個難題,它體如今如下兩個問題:
如何解決更改token有效期的問題,網上看到不少討論,主要集中在如下內容:
- JWT是一次性認證完畢加載信息到token裏的,token的信息內含過時信息。過時時間過長則被重放攻擊的風險太大,而過時時間過短則請求端體驗太差(動不動就要從新登陸)
- 把token存進庫裏,很天然能想到的是把每一個token存庫,設置一個valid字段,一旦註銷了就valid=0;設置有效期字段,想要延期就增長有效期時間。openstack keystone就是這麼作的。這個作法雖方便,但對數據庫的壓力較大,甚至在訪問量較大,簽發token較多的狀況下,是對數據庫的一個挑戰。何況這也有悖於JWT的初衷。
- 爲了使用戶不須要常常從新登陸,客戶端將用戶名密碼保存起來(cookie),而後使用用戶名密碼驗證,但那還得考慮防護CSRF攻擊的問題。
這裏,筆者借鑑了第三方認證協議Oauth2.0(RFC6749),它採起了另外一種方法:refresh token,一個用於更新令牌的令牌。在用戶首次認證後,簽發兩個token:
- 一個爲access token,用於用戶後續的各個請求中攜帶的認證信息
- 另外一個是refresh token,爲access token過時後,用於申請一個新的access token。
由此能夠給兩類不一樣token設置不一樣的有效期,例如給access token僅1小時的有效時間,而refresh token則能夠是一個月。api的登出經過access token的過時來實現(前端則可直接拋棄此token實現登出),在refresh token的存續期內,訪問api時可執refresh token申請新的access token(前端可存此refresh token,access token過其實進行更新,達到自動延期的效果)。
refresh token不可再延期,過時需從新使用用戶名密碼登陸。
這種方式的理念在於,將證書分爲三種級別:
- access token 短時間證書,用於最終鑑權
- refresh token 較長期的證書,用於產生短時間證書,不可直接用於服務請求
- 用戶名密碼 幾乎永久的證書,用於產生長期證書和短時間證書,不可直接用於服務請求
經過這種方式,使證書功效和證書時效結合考慮。
ps.前面提到建立token的時候將expire_in(jwt的推薦字段,超時時間間隔)做爲函數的形參,是爲了將此函數用於生成access token和refresh token,而二者的expire_in時間是不一樣的。
咱們作了一個JWT的認證模塊:
(access token在如下代碼中爲'token',refresh token在代碼中爲'rftoken')
client -----用戶名密碼-----------> server
client <------token、rftoken----- server
client ------請求(攜帶token)----> server
client <-----結果----------------- server
client ------請求(攜帶token)----> server
client <-----msg:token expired--- server
client -請求新token(攜帶rftoken)-> server
client <-----新token-------------- server
client -請求新token(攜帶rftoken)-> server
client <----msg:rftoken expired--- server
若是設計一個針對此認證的前端,須要:
存儲access token、refresh token
訪問時攜帶access token,自動檢查access token超時,超時則使用refresh token更新access token;狀態延期用戶無感知
用戶登出直接拋棄access token與refresh token