1. 程式人生 > >python實現後臺系統的JWT認證

python實現後臺系統的JWT認證

介紹一種適用於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協議僅僅規定了這個協議的格式(<a href=”https://tools.ietf.org/heml/rfc7519”>RFC7519</a>),它的序列生成方法在JWS協議中描述(

https://tools.ietf.org/html/rfc7515),分為三個部分:

1.3.1 header頭部:

  • 宣告型別,這裡是jwt

  • 宣告加密的演算法 通常直接使用 HMAC SHA256

一種常見的頭部是這樣的:


    
  1. {
  2. ‘typ’: ‘JWT’,
  3. ‘alg’: ‘HS256’
  4. }

再將其進行base64編碼。

1.3.2 payload載荷:

payload是放置實際有效使用資訊的地方。JWT定義了幾種內容,包括:

  • 標準中註冊的宣告,如簽發者,接收者,有效時間(exp),時間戳(iat,issued at)等;為官方建議但非必須
  • 公共宣告
  • 私有宣告

一個常見的payload是這樣的:


    
  1. { 'user_id': 123456,
  2. 'user_role': admin,
  3. '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)。


    
  1. # serializer for JWT
  2. from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
  3. """
  4. token is generated as the JWT protocol.
  5. JSON Web Tokens(JWT) are an open, industry standard RFC 7519 method
  6. """
  7. def genTokenSeq(self, expires):
  8. s = Serializer(
  9. secret_key=app.config[ 'SECRET_KEY'],
  10. salt=app.config[ 'AUTH_SALT'],
  11. expires_in=expires)
  12. timestamp = time.time()
  13. return s.dumps(
  14. { 'user_id': self.user_id,
  15. 'user_role': self.role_id,
  16. 'iat': timestamp})
  17. # The token contains userid, user role and the token generation time.
  18. # u can add sth more inside, if needed.
  19. # 'iat' means 'issued at'. claimed in JWT.

使用這個Serializer可以幫我們處理好header、signature的問題。我們只需要用s.dumps將payload的內容寫進來。這裡我準備在每個token中寫入三個值:使用者id、使用者角色id和當前時間(‘iat’是JWT標準註冊宣告中的一項)。

假設我所寫入的資訊是


    
  1. {
  2. "iat": 1467271277.131803,
  3. "user_id": "46501228343b11e6aaa6a45e60ed5ed5f973ba0fcf783bb8ade34c7b492d9e55",
  4. "user_role": 3
  5. }

採用以上的方法所生成的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的方法前面,則程式碼可以更加優雅。


    
  1. # serializer for JWT
  2. from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
  3. # exceptions for JWT
  4. from itsdangerous import SignatureExpired, BadSignature, BadData
  5. # Class xxx
  6. # after definition of your class, here goes the auth method:
  7. def tokenAuth(token):
  8. # token decoding
  9. s = Serializer(
  10. secret_key=api.app.config[ 'SECRET_KEY'],
  11. salt=api.app.config[ 'AUTH_SALT'])
  12. try:
  13. data = s.loads(token)
  14. # token decoding faild
  15. # if it happend a plenty of times, there might be someone
  16. # trying to attact your server, so it should be a warning.
  17. except SignatureExpired:
  18. msg = 'token expired'
  19. app.logger.warning(msg)
  20. return [ None, None, msg]
  21. except BadSignature, e:
  22. encoded_payload = e.payload
  23. if encoded_payload is not None:
  24. try:
  25. s.load_payload(encoded_payload)
  26. except BadData:
  27. # the token is tampered.
  28. msg = 'token tampered'
  29. app.logger.warning(msg)
  30. return [ None, None, msg]
  31. msg = 'badSignature of token'
  32. app.logger.warning(msg)
  33. return [ None, None, msg]
  34. except:
  35. msg = 'wrong token with unknown reason'
  36. app.logger.warning(msg)
  37. return [ None, None, msg]
  38. if ( 'user_id' not in data) or ( 'user_role' not in data):
  39. msg = 'illegal payload inside'
  40. app.logger.warning(msg)
  41. return [ None, None, msg]
  42. msg = 'user(' + data[ 'user_id'] + ') logged in by token.'
  43. # app.logger.info(msg)
  44. userId = data[ 'user_id']
  45. roleId = data[ 'user_role']
  46. return [userId, roleId, msg]

檢查和判定的機制如下:

  1. 使用加密的類,再用來解密(用上之前的金鑰和鹽值),得到結果存入data;
  1. 如果捕獲到SignatureExpired異常,則代表根據token中的expired設定,token已經超時失效,返回‘token expired’;
  2. 如果是其他BadSignature異常,又要分為:
    3.1 如果payload還完整,則解析payload,如果捕獲BadData異常,則代表token已經被篡改,返回‘token tampered’;
    3.2 如果payload不完整,直接返回‘badSignature of token’;
  3. 如果以上異常都不對,那隻能返回未知異常‘wrong token with unknown reason’;
  4. 最後,如果data能正常解析,則將payload中的資料取出來,驗證payload中是否有合法資訊(這裡是user_id和user_role鍵值的json資料),如果資料不合法,則返回‘illegal payload inside’。一旦出現這種情況,則代表金鑰和鹽值洩露的可能性很大。

4. 優化

上述的方法可以做到基本的JWT認證,但在實際開發過程中還有其他問題:

token在生成之後,是靠expire使其過期失效的。簽發之後的token,是無法收回修改的,因此涉及token的有效期的更改是個難題,它體現在以下兩個問題:

  • 問題1.使用者登出
  • 問題2.token自動延期

如何解決更改token有效期的問題,網上看到很多討論,主要集中在以下內容:

 
  1. JWT是一次性認證完畢載入資訊到token裡的,token的資訊內含過期資訊。過期時間過長則被重放攻擊的風險太大,而過期時間太短則請求端體驗太差(動不動就要重新登入)
  2. 把token存進庫裡,很自然能想到的是把每個token存庫,設定一個valid欄位,一旦登出了就valid=0;設定有效期欄位,想要延期就增加有效期時間。openstack keystone就是這麼做的。這個做法雖方便,但對資料庫的壓力較大,甚至在訪問量較大,簽發token較多的情況下,是對資料庫的一個挑戰。況且這也有悖於JWT的初衷。
  3. 為了使使用者不需要經常重新登入,客戶端將使用者名稱密碼儲存起來(cookie),然後使用使用者名稱密碼驗證,但那還得考慮防禦CSRF攻擊的問題。

這裡,筆者借鑑了第三方認證協議Oauth2.0(<a href=”https://tools.ietf.org/html/rfc6749”>RFC6749</a>),它採取了另一種方法: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

介紹一種適用於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協議僅僅規定了這個協議的格式(<a href=”https://tools.ietf.org/heml/rfc7519”>RFC7519</a>),它的序列生成方法在JWS協議中描述(https://tools.ietf.org/html/rfc7515),分為三個部分:

1.3.1 header頭部:

  • 宣告型別,這裡是jwt

  • 宣告加密的演算法 通常直接使用 HMAC SHA256

一種常見的頭部是這樣的:


  
  1. {
  2. ‘typ’: ‘JWT’,
  3. ‘alg’: ‘HS256’
  4. }

再將其進行base64編碼。

1.3.2 payload載荷:

payload是放置實際有效使用資訊的地方。JWT定義了幾種內容,包括:

  • 標準中註冊的宣告,如簽發者,接收者,有效時間(exp),時間戳(iat,issued at)等;為官方建議但非必須
  • 公共宣告
  • 私有宣告

一個常見的payload是這樣的:


  
  1. { 'user_id': 123456,
  2. 'user_role': admin,
  3. '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)。


  
  1. # serializer for JWT
  2. from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
  3. """
  4. token is generated as the JWT protocol.
  5. JSON Web Tokens(JWT) are an open, industry standard RFC 7519 method
  6. """
  7. def genTokenSeq(self, expires):
  8. s = Serializer(
  9. secret_key=app.config[ 'SECRET_KEY'],
  10. salt=app.config[ 'AUTH_SALT'],
  11. expires_in=expires)
  12. timestamp = time.time()
  13. return s.dumps(
  14. { 'user_id': self.user_id,
  15. 'user_role': self.role_id,
  16. 'iat': timestamp})
  17. # The token contains userid, user role and the token generation time.
  18. # u can add sth more inside, if needed.
  19. # 'iat' means 'issued at'. claimed in JWT.

使用這個Serializer可以幫我們處理好header、signature的問題。我們只需要用s.dumps將payload的內容寫進來。這裡我準備在每個token中寫入三個值:使用者id、使用者角色id和當前時間(‘iat’是JWT標準註冊宣告中的一項)。

假設我所寫入的資訊是


  
  1. {
  2. "iat": 1467271277.131803,
  3. "user_id": "46501228343b11e6aaa6a45e60ed5ed5f973ba0fcf783bb8ade34c7b492d9e55",
  4. "user_role": 3
  5. }

採用以上的方法所生成的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的方法前面,則程式碼可以更加優雅。


  
  1. # serializer for JWT
  2. from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
  3. # exceptions for JWT
  4. from itsdangerous import SignatureExpired, BadSignature, BadData
  5. # Class xxx
  6. # after definition of your class, here goes the auth method:
  7. def tokenAuth(token):
  8. # token decoding
  9. s = Serializer(
  10. secret_key=api.app.config[ 'SECRET_KEY'],
  11. salt=api.app.config[ 'AUTH_SALT'])
  12. try:
  13. data = s.loads(token)
  14. # token decoding faild
  15. # if it happend a plenty of times, there might be someone
  16. # trying to attact your server, so it should be a warning.
  17. except SignatureExpired:
  18. msg = 'token expired'
  19. app.logger.warning(msg)
  20. return [ None, None, msg]
  21. except BadSignature, e:
  22. encoded_payload = e.payload
  23. if encoded_payload is not None:
  24. try:
  25. s.load_payload(encoded_payload)
  26. except BadData:
  27. # the token is tampered.
  28. msg = 'token tampered'
  29. app.logger.warning(msg)
  30. return [ None, None, msg]
  31. msg = 'badSignature of token'
  32. app.logger.warning(msg)
  33. return [ None, None, msg]
  34. except:
  35. msg = 'wrong token with unknown reason'
  36. app.logger.warning(msg)
  37. return [ None, None, msg]
  38. if ( 'user_id' not in data) or ( 'user_role'