Django(65)jwt認證原理

前言

帶著問題學習是最有目的性的,我們先提出以下幾個問題,看看通過這篇部落格的講解,能解決問題嗎?

  1. 什麼是JWT?
  2. 為什麼要用JWT?它有什麼優勢?
  3. JWT的認證流程是怎樣的?
  4. 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優點

  1. 伺服器不需要存儲tokentoken交給每一個客戶端自己存儲,伺服器壓力小
  2. 伺服器存儲的是 簽發和校驗token兩段演算法,簽發認證的效率高
  3. 演算法完成各集群伺服器同步成本低,路由項目完成集群部署(適應高並發)
     

2.5 JWT特點

  1. token一定在伺服器產生,且在伺服器校驗
  2. token一定參與網路傳輸
  3. 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支援的演算法,主要是HS256RS256

它會使用 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):編號

常用的有issiatexpaudsub

{
  "sub": "1234567890",
  "name": "John Doe",
  "id": 1,
  "iat": 1516239022
}

同樣的,它會使用base64url編碼組成 JWT 結構的第二部分
 

3.3 Signature

前面兩部分都是使用base64url進行編碼的,前端可以解開知道裡面的資訊。Signature需要使用編碼後的 headerpayload 以及我們提供的一個密鑰,這個密鑰只有伺服器才知道,不能泄露給用戶,然後使用 header 中指定的簽名演算法(HS256)進行簽名。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

算出簽名以後,把 HeaderPayloadSignature 三個部分拼成一個字元串,每個部分之間用”點”(.)分隔,就可以返回給用戶。

簽名的目的
最後一步簽名的過程,實際上是對頭部以及負載內容進行簽名,防止內容被篡改。如果有人對頭部以及負載的內容解碼之後進行修改,再進行編碼,最後加上之前的簽名組合形成新的JWT的話,那麼伺服器端會判斷出新的頭部和負載形成的簽名和JWT附帶上的簽名是不一樣的。如果要對新的頭部和負載進行簽名,在不知道伺服器加密時用的密鑰的話,得出來的簽名也是不一樣的。
 

4.解密原理

1.對token進行切割
2.對第二段進行base64url解密,並獲取payload資訊,檢測exp是否過期
3.將第1,2部分密文拼接起來,再次執行HS256加密
將加密後的密文 = base64解密(第三段字元串)
如果相等則通過,不相等則失敗
 

5.JWT的使用方式

  客戶端收到伺服器返回的 JWT,可以儲存在 Cookie 裡面,也可以儲存在 localStorage
  此後,客戶端每次與伺服器通訊,都要帶上這個 JWT。你可以把它放在Cookie裡面自動發送,但是這樣不能跨域,所以更好的做法是放在HTTP請求的頭資訊Authorization欄位裡面。

  1. 首先,前端通過Web表單將自己的用戶名和密碼發送到後端的介面。這一過程一般是一個HTTP POST請求。建議的方式是通過SSL加密的傳輸(https協議),從而避免敏感資訊被嗅探。

  2. 後端核對用戶名和密碼成功後,將用戶的id等其他資訊作為JWT Payload(負載),將其與頭部分別進行Base64編碼拼接後簽名,形成一個JWT。形成的JWT就是一個形同aaa.bbb.ccc的字元串。

  3. 後端將JWT字元串作為登錄成功的返回結果返回給前端。前端可以將返回的結果保存在localStoragesessionStorage上,退出登錄時前端刪除保存的JWT即可。

  4. 前端在每次請求時將JWT放入HTTP Header中的Authorization位。(解決XSSXSRF問題)

  5. 後端檢查是否存在,如存在驗證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進行再次封裝的。最新的官網(//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這些,而是自定義的usrpwdusr欄位的值可以是用戶名或郵箱或手機號,這樣一來就實現了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,創建超級用戶,usernameadmin,’password’為admin123phone13345678901email[email protected]
 

9.測試自定義的token許可權

我們使用apifox工具進行介面測試,首先使用post請求訪問//127.0.0.1:8000/api/login/

9.1手機號登錄


 

9.2郵箱登錄


 

9.3帳號密碼登錄


 

9.4攜帶token登錄

登錄成功後,我們拿著token去訪問視圖,我們在header中添加AUTHORIZATION欄位

我們發現是可以登錄成功的,最後如果你想驗證過期時間,你可以把token中的第二段字元串,使用base64解密,就能看到時間戳

Tags: