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協議中描述(
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(<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
一種常見的頭部是這樣的:
-
{
-
‘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'