前言
帶着問題學習是最有目的性的,我們先提出以下幾個問題,看看通過這篇博客的講解,能解決問題嗎?
- 什么是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
為100100100@qq.com
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解密
,就能看到時間戳