Django(65)jwt認證原理

Silent丿丶黑羽發表於2021-06-20

前言

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

  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進行再次封裝的。最新的官網(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這些,而是自定義的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'為admin123phone13345678901email100100100@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解密,就能看到時間戳

相關文章