Django(65)jwt認證原理
前言
帶著問題學習是最有目的性的,我們先提出以下幾個問題,看看通過這篇部落格的講解,能解決問題嗎?
- 什麼是JWT?
- 為什麼要用JWT?它有什麼優勢?
- JWT的認證流程是怎樣的?
- JWT的工作原理?
我們帶著4個問題進入學習
1.什麼是JWT?
JWT
全稱Json Web Token
,JWT 是一種開發的行業標準 RFC 7519
,用於安全的表示雙方之間的宣告。目前,JWT
廣泛應用在系統的使用者認證方面,特別是現在前後端分離專案。
2.為什麼要使用JWT?它有什麼優勢?
使用者登入認證方式分為傳統的token
登入方式和JWT
方式,傳統的方式又分為session
登入和快取登入
2.1 session登入
"""
接收到登入請求, 1.得到使用者 2.產生token 3.記錄到session表 4.返回token
接收需要認證資訊的請求, 1.拿到token 2.資料庫校驗 3.確定登入使用者 4.返回認證後資訊 與資料庫session表互動
"""
2.2 快取登入
"""
接收到登入請求, 1.得到使用者 2.產生token 3.記錄到快取 4.返回token
接收需要認證資訊的請求, 1.拿到token 2.快取校驗 3.確定登入使用者 4.返回認證後資訊 使用者登入資訊快取儲存
"""
2.3 JWT方式
""" 接收到登入請求, 1.得到使用者 2.根據使用者產生有使用者資訊的token 3.返回token 接收需要認證資訊的請求, 1.拿到token 2.檢驗token是否合法,校驗出使用者 3.返回認證後資訊 """
2.4 JWT優點
- 伺服器不需要儲存
token
,token
交給每一個客戶端自己儲存,伺服器壓力小 - 伺服器儲存的是 簽發和校驗
token
兩段演算法,簽發認證的效率高 - 演算法完成各叢集伺服器同步成本低,路由專案完成叢集部署(適應高併發)
2.5 JWT特點
token
一定在伺服器產生,且在伺服器校驗token
一定參與網路傳輸token
攜帶的資訊存在能被反解
與不能被反解
的多部分組成
3.JWT組成以及加密原理
JWT
是由頭部header
、載荷payload
、簽名signature
,三段式組成,用.
進行拼接,例如官網的這段字串
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
每一部分都是一個json
字典加密形參的字串,頭部和載荷採用的是base64url
加密(前臺後臺都可以解密),簽名採用hash256
不可逆加密
注意:base64url
加密是先做base64
加密,然後再將字串中的 -
替代 +
及_
替代 /
。
3.1 Header
頭部包含了兩部分,token
型別和採用的加密演算法
{
"alg": "HS256",
"typ": "JWT"
}
typ
: (Type)型別,指明型別是JWT
。alg
: (Algorithm)演算法,必須是JWS
支援的演算法,主要是HS256
和RS256
它會使用 base64url
編碼組成 JWT
結構的第一部分
3.2 Payload
這部分就是我們存放資訊的地方了,你可以把使用者ID
等資訊放在這裡,JWT
規範裡面對這部分有進行了比較詳細的介紹,JWT
規定了7個官方欄位,供選用
iss (issuer)
:簽發人exp (expiration time)
:過期時間,時間戳sub (subject)
:主題aud (audience)
:受眾nbf (Not Before)
:生效時間,時間戳iat (Issued At)
:簽發時間,時間戳jti (JWT ID)
:編號
常用的有iss
、iat
、exp
、aud
和sub
{
"sub": "1234567890",
"name": "John Doe",
"id": 1,
"iat": 1516239022
}
同樣的,它會使用base64url
編碼組成 JWT
結構的第二部分
3.3 Signature
前面兩部分都是使用base64url
進行編碼的,前端可以解開知道里面的資訊。Signature
需要使用編碼後的 header
和 payload
以及我們提供的一個金鑰,這個金鑰只有伺服器才知道,不能洩露給使用者,然後使用 header
中指定的簽名演算法(HS256)
進行簽名。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
算出簽名以後,把 Header
、Payload
、Signature
三個部分拼成一個字串,每個部分之間用"點"(.)分隔,就可以返回給使用者。
簽名的目的
最後一步簽名的過程,實際上是對頭部以及負載內容進行簽名,防止內容被篡改
。如果有人對頭部以及負載的內容解碼之後進行修改,再進行編碼,最後加上之前的簽名組合形成新的JWT
的話,那麼伺服器端會判斷出新的頭部和負載形成的簽名和JWT
附帶上的簽名是不一樣的。如果要對新的頭部和負載進行簽名,在不知道伺服器加密時用的金鑰的話,得出來的簽名也是不一樣的。
4.解密原理
1.對token
進行切割
2.對第二段進行base64url
解密,並獲取payload
資訊,檢測exp
是否過期
3.將第1,2部分密文拼接起來,再次執行HS256
加密
將加密後的密文 = base64
解密(第三段字串)
如果相等則通過,不相等則失敗
5.JWT的使用方式
客戶端收到伺服器返回的 JWT
,可以儲存在 Cookie
裡面,也可以儲存在 localStorage
。
此後,客戶端每次與伺服器通訊,都要帶上這個 JWT
。你可以把它放在Cookie
裡面自動傳送,但是這樣不能跨域,所以更好的做法是放在HTTP
請求的頭資訊Authorization
欄位裡面。
-
首先,前端通過
Web
表單將自己的使用者名稱和密碼傳送到後端的介面。這一過程一般是一個HTTP POST
請求。建議的方式是通過SSL
加密的傳輸(https
協議),從而避免敏感資訊被嗅探。 -
後端核對使用者名稱和密碼成功後,將使用者的
id
等其他資訊作為JWT Payload
(負載),將其與頭部分別進行Base64
編碼拼接後簽名,形成一個JWT
。形成的JWT
就是一個形同aaa.bbb.ccc
的字串。 -
後端將
JWT
字串作為登入成功的返回結果返回給前端。前端可以將返回的結果儲存在localStorage
或sessionStorage
上,退出登入時前端刪除儲存的JWT
即可。 -
前端在每次請求時將
JWT
放入HTTP Header
中的Authorization
位。(解決XSS
和XSRF
問題) -
後端檢查是否存在,如存在驗證
JWT
的有效性。例如,檢查簽名是否正確;檢查Token
是否過期;檢查Token
的接收方是否是自己(可選)。
6.JWT程式碼演示
首先我們需要安裝JWT
pip3 install PyJWT==1.7.1
然後建立一個新的檔案jwt_auth
,名字隨便取,寫一個簽發token
的方法和校驗token
的方法
import datetime
import jwt
salt = "iv%x6xo7l7_u9bf_u!9#g#m*)*=ej@bek5)(@u3kh*72+unjv="
def create_token():
"""
自定義token
"""
# 過期時間
expire_time = datetime.datetime.utcnow() + datetime.timedelta(days=7)
# 構造headers
headers = {
'typ': 'jwt',
'alg': 'HS256'
}
# 構造payload
payload = {
"userId": 1,
"exp": expire_time
}
result = jwt.encode(payload=payload, key=salt, algorithm="HS256", headers=headers).decode("utf-8")
return result
def parse_payload(token):
"""
對token進行校驗並獲取payload
"""
try:
verified_payload = jwt.decode(token, key=salt)
return verified_payload
except jwt.ExpiredSignatureError:
print('token已失效')
except jwt.DecodeError:
print('token認證失敗')
except jwt.InvalidTokenError:
print('非法的token')
if __name__ == '__main__':
token = create_token()
print(token)
print(parse_payload(token))
我們上面寫了一個建立token
的方法和校驗token
的方法,然後我們執行這個指令碼,結果如下
token:eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImV4cCI6MTYyNDYwOTAzNX0.VyjHR6xn94nImEsaIqVE_g84WY_88XuzVHhbqEk-XbM
校驗結果:{'userId': 1, 'exp': 1624609035}
可以看到,我們可以正常簽發和校驗token
了,實際開發過程中,我們會把salt
換成settings.py
檔案下的SECRET_KEY
,然後把useId
不要寫死換成user.pk
即可
7.djangorestframework-jwt
以上我們都是使用的PyJWT
,而DRF
有個第三方庫djangorestframework-jwt
,幫我們更加方便的使用JWT
,它是基於PyJWT==1.7.1
進行再次封裝的。最新的官網(http://jpadilla.github.io/django-rest-framework-jwt/)
7.1安裝命令
pip3 install djangorestframework-jwt
8.實戰案例
我們做一個使用者登入的需求,使用者登入可以使用以下3種方式
- 賬號密碼登入
- 手機號密碼登入
- 郵箱密碼登入
且需要自己自定義JWT
認證,認證的格式為header
請求頭中的AUTHORIZATION
欄位的值為jwt token
的形式,然後後端取出token
,通過演算法檢查出token
是否合法
8.1前置準備工作
建立專案jwt_demo
,然後建立個app
名字為api
,接著配置好資料庫,然後在models.py
檔案中建立MyUser
模型
from django.db import models
from django.contrib.auth.models import AbstractUser
class MyUser(AbstractUser):
phone = models.CharField(verbose_name='手機號碼', max_length=11, null=True, unique=True)
這樣User
表中就有了phone
欄位,並且在settings.py
檔案中設定預設User
模型AUTH_USER_MODEL = "api.MyUser"
接著在api
中建立serializers.py
檔案,編寫如下序列化
from django.contrib.auth import get_user_model
from rest_framework import serializers
from rest_framework_jwt.settings import api_settings
User = get_user_model() # 獲取使用者模型
class LoginSerializer(serializers.ModelSerializer):
# 設定自定義的反序列化欄位usr,pwd
usr = serializers.CharField(write_only=True)
pwd = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['username', 'email', 'phone', 'usr', 'pwd']
extra_kwargs = {
"username": {
"read_only": True
},
"email": {
"read_only": True
},
"phone": {
"read_only": True
}
}
我們在序列化的時候,讓前臺傳的欄位不再是User
表中的username
這些,而是自定義的usr
和pwd
,usr
欄位的值可以是使用者名稱或郵箱或手機號,這樣一來就實現了3種登入方式
編寫完序列化類,我們來完成檢視的工作
import re
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import check_password
from rest_framework.views import APIView
from rest_framework_jwt.settings import api_settings
from api.utils.response import APIResponse
User = get_user_model()
class LoginView(APIView):
"""
登陸檢視,使用者名稱與密碼匹配返回token
"""
authentication_classes = []
permission_classes = []
def post(self, request, *args, **kwargs):
try:
# 獲取前臺穿的usr和pwd欄位
usr = request.data.get("usr")
pwd = request.data.get("pwd")
except KeyError:
return APIResponse(data_status=10002, data_msg="請求資料非法")
if re.match(r"1[35678]\d{9}", usr):
# 正則匹配手機號
user = User.objects.filter(phone=usr).first()
elif re.match(r'^[0-9a-zA-Z_]{0,19}@[0-9a-zA-Z]{1,13}\.[com,cn,net]{1,3}$', usr):
# 正則匹配郵箱
user = User.objects.filter(email=usr).first()
else:
# 使用者名稱
user = User.objects.filter(username=usr).first()
if not user:
return APIResponse(data_status=10002, data_msg="該使用者未註冊")
if user.is_active == 0:
return APIResponse(data_status=10002, data_msg="使用者被禁用")
if not check_password(pwd, user.password):
return APIResponse(data_status=10002, data_msg="使用者名稱或密碼錯誤")
# 呼叫第三方的JWT_PAYLOAD_HANDLER和JWT_ENCODE_HANDLER,這裡也可以自定義該方法
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
# 通過user解析出payload
payload = jwt_payload_handler(user)
# 通過payload生成token
token = jwt_encode_handler(payload)
return APIResponse(token=token, results={"user": user.username})
class TestView(APIView):
def get(self, request, *args, **kwargs):
return APIResponse()
檢視中我們建立了2個類檢視,第一個是登入檢視,用來登入後返回token
,第二個類檢視是為了測試登入成功後,以後訪問檢視都需要在請求頭中攜帶token
,否則許可權驗證失敗。
最後我們配置路由即可
urlpatterns = [
path('login/', views.LoginView.as_view()),
path('test/', views.TestView.as_view())
]
8.2自定義許可權驗證
我們建立一個auth.py
檔案,編寫自定義許可權
import jwt
from django.contrib.auth import get_user_model
from rest_framework.authentication import get_authorization_header
from rest_framework_jwt.authentication import jwt_decode_handler, BaseJSONWebTokenAuthentication, \
jwt_get_username_from_payload
from rest_framework import exceptions
User = get_user_model() # 獲取使用者模型
class JWTAuthentication(BaseJSONWebTokenAuthentication):
keyword = "JWT"
def authenticate(self, request):
# 獲取請求頭字串,分割成列表
auth = get_authorization_header(request).split()
if not auth:
msg = "未獲取到Authorization請求頭"
raise exceptions.AuthenticationFailed(msg)
if auth[0].lower() != self.keyword.lower().encode():
msg = "Authorization請求頭中認證方式錯誤"
raise exceptions.AuthenticationFailed(msg)
if len(auth) == 1:
msg = "非法Authorization請求頭"
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
raise exceptions.AuthenticationFailed({"message": "無效的授權頭。憑據字串''不應包含空格"})
try:
jwt_token = auth[1]
payload = jwt_decode_handler(jwt_token)
except jwt.ExpiredSignature:
msg = 'token已失效'
raise exceptions.AuthenticationFailed(msg)
except jwt.DecodeError:
msg = '簽名解析失敗'
raise exceptions.AuthenticationFailed(msg)
except jwt.InvalidTokenError:
raise exceptions.AuthenticationFailed()
user = self.authenticate_credentials(payload)
return user, jwt_token
def authenticate_credentials(self, payload):
"""
Returns an active user that matches the payload's user id and email.
"""
User = get_user_model()
username = jwt_get_username_from_payload(payload)
if not username:
msg = _('Invalid payload.')
raise exceptions.AuthenticationFailed(msg)
try:
user = User.objects.get_by_natural_key(username)
except User.DoesNotExist:
msg = '使用者不存在'
raise exceptions.AuthenticationFailed(msg)
if not user.is_active:
msg = '使用者已禁用'
raise exceptions.AuthenticationFailed(msg)
return user
最後我們在settings.py
檔案中配置下即可
REST_FRAMEWORK = {
# 自定義的認證類
'DEFAULT_AUTHENTICATION_CLASSES': (
'api.auth.JWTAuthentication',
),
# 使用drf的許可權驗證
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
JWT_AUTH = {
# token的過期時間設定,預設是5分鐘過期
'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300),
}
最後我們通過python manage createsuperuser
,建立超級使用者,username
為admin
,'password'為admin123
,phone
為13345678901
,email
為[email protected]
9.測試自定義的token許可權
我們使用apifox
工具進行介面測試,首先使用post
請求訪問http://127.0.0.1:8000/api/login/
9.1手機號登入
9.2郵箱登入
9.3賬號密碼登入
9.4攜帶token登入
登入成功後,我們拿著token
去訪問檢視,我們在header
中新增AUTHORIZATION
欄位
我們發現是可以登入成功的,最後如果你想驗證過期時間,你可以把token
中的第二段字串,使用base64解密
,就能看到時間戳