今天的文章介紹一種適用於restful+json的API認證方法,這個方法是基於jwt,並且加入了一些從oauth2.0借鑒的改良。
1. 常見的幾種實現認證的方法
首先要明白,認證和鑒權是不同的。認證是判定用戶的合法性,鑒權是判定用戶的權限級別是否可執行后續操作。這里所講的僅含認證。認證有幾種方法:
1.1 basic auth
這是http協議中所帶帶基本認證,是一種簡單為上的認證方式。原理是在每個請求的header中添加用戶名和密碼的字符串(格式為“username:password”,用base64編碼)。
這種方式相當於將“用戶名:密碼”綁定為一個開放式證書,這會有幾個問題:
- 每次請求都需要用戶名密碼,如果此連接未使用SSL/TLS,或加密被破解,用戶名密碼基本就暴露了;
- 無法注銷用戶的登錄狀態;
- 證書不會過期,除非修改密碼。
總體來說,這種方法的特點就是,簡單但不安全。
1.2cookie
將認證的結果存在客戶端的cookie中,通過檢查cookie中的身份信息來作為認證結果。
這種方式的特點是便捷,且只需要一次認證,多次可用;也可以注銷登錄狀態和設置過期時間;甚至也有辦法(比如設置httpOnly)來避免XSS攻擊。
但它的缺點十分明顯,使用cookie那便是有狀態的服務了。
1.3 token
JWT協議似乎已經應用十分廣泛,JSON Web Token——一種基於token的json格式web認證方法。基本的原理是,第一次認證通過用戶名密碼,服務端簽發一個json格式的token。后續客戶端的請求都攜帶這個token,服務端僅需要解析這個token,來判別客戶端的身份和合法性。
而JWT協議僅僅規定了這個協議的格式(RFC7519),它的序列生成方法在JWS協議中描述(https://tools.ietf.org/html/rfc7515),分為三個部分:
1.3.1 header頭部:
-
聲明類型,這里是jwt
-
聲明加密的算法 通常直接使用 HMAC SHA256
一種常見的頭部是這樣的:
{
'typ': 'JWT',
'alg': 'HS256'
}
再將其進行base64編碼。
1.3.2 payload載荷:
payload是放置實際有效使用信息的地方。JWT定義了幾種內容,包括:
- 標准中注冊的聲明,如簽發者,接收者,有效時間(exp),時間戳(iat,issued at)等;為官方建議但非必須
- 公共聲明
- 私有聲明
一個常見的payload是這樣的:
{'user_id': 123456,
'user_role': admin,
'iat': 1467255177}
事實上,payload中的內容是自由的,按照自己開發的需要加入。
Ps.有個小問題。使用itsdangerous包的TimedJSONWebSignatureSerializer進行token序列生成的結果,exp是在頭部里的。這里似乎違背了jwt的協議規則。
1.3.3 signature
存儲了序列化的secreate key和salt key。這個部分需要base64加密后的header和base64加密后的payload使用.連接組成的字符串,然后通過header中聲明的加密方式進行加鹽secret組合加密,然后就構成了jwt的第三部分。
2. 認證需求
目標場景是一個前后端分離的后端系統,用於運維工作,雖在內網使用,也有一定的保密性要求。
- API為restful+json的無狀態接口,要求認證也是相同模式
- 可橫向擴展
- 較低數據庫壓力
- 證書可注銷
- 證書可自動延期
選擇JWT。
3. JWT實現
2.1 如何生成token
這里使用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”構成的。
3.2 如何解析token
解析需要使用到同樣的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’。一旦出現這種情況,則代表密鑰和鹽值泄露的可能性很大。
4. 優化
上述的方法可以做到基本的JWT認證,但在實際開發過程中還有其他問題:
token在生成之后,是靠expire使其過期失效的。簽發之后的token,是無法收回修改的,因此涉及token的有效期的更改是個難題,它體現在以下兩個問題:
- 問題1.用戶登出
- 問題2.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時間是不同的。
5. 總結一下
我們做了一個JWT的認證模塊:
(access token在以下代碼中為'token',refresh token在代碼中為'rftoken')
- 首次認證
client -----用戶名密碼-----------> server
client <------token、rftoken----- server
- access token存續期內的請求
client ------請求(攜帶token)----> server
client <-----結果----------------- server
- access token超時
client ------請求(攜帶token)----> server
client <-----msg:token expired--- server
- 重新申請access token
client -請求新token(攜帶rftoken)-> server
client <-----新token-------------- server
- rftoken token超時
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
作者:茶客furu聲
鏈接:http://www.jianshu.com/p/537b356d34c9
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。