DRF之JWT認證

Lea4ning發表於2024-04-27

DRF之JWT認證

【一】JWT

  • WT(JSON Web Token)是一種開放標準(RFC 7519),用於在網路上傳輸宣告的一種緊湊且自包含的方式。JWT 可以使用 HMAC 演算法或是使用 RSA 或 ECDSA 等公鑰/私鑰對進行簽名。通常,它用於在身份提供者和服務之間傳遞被認證的使用者身份資訊,以便於在使用者和服務之間安全地傳遞宣告。
  • JWT 主要由三部分組成,用.分隔開:
    1. Header(頭部):包含了型別(typ)和所使用的演算法(alg)等資訊,通常是 {"alg": "HS256", "typ": "JWT"}
    2. Payload(負載):包含了要傳遞的宣告資訊,如使用者的身份、角色等,以及其他自定義的資料,例如 {"sub": "user123", "role": "admin"}
    3. Signature(簽名):將編碼後的 Header 和 Payload 以及一個秘密(對稱加密)或公鑰/私鑰(非對稱加密)一起進行簽名,以保證資料的完整性和真實性。
  • JWT 的優勢在於它的緊湊性和自包含性,使得它適合在不同的環境中進行傳遞,如在 HTTP 請求頭、URL 引數或 POST 引數中。JWT 的缺點是一旦簽發後,其內容無法更改,也無法撤銷,除非設定了較短的過期時間並在服務端進行驗證。
  • 在 Web 開發中,JWT 被廣泛用於實現使用者認證和授權機制,常見的應用場景包括單點登入(SSO)、API 認證等。

【二】SimpleJwt

【0】SimpleJwt原始碼

  • SimpleJwt其實與drf類似,也是一個app,需要在django專案檔案中註冊

【0.1】登入

# 登入的入口
from rest_framework_simplejwt.views import token_obtain_pair

image-20240426204820103

【0.2】認證

# 認證類的入口
from rest_framework_simplejwt.authentication import JWTAuthentication

image-20240427195114363

【1】SimpleJwt配置檔案

# JWT配置
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),  # Access Token的有效期
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),  # Refresh Token的有效期
    
    # 對於大部分情況,設定以上兩項就可以了,以下為預設配置專案,可根據需要進行調整
    
    # 是否自動重新整理Refresh Token
    'ROTATE_REFRESH_TOKENS': False,  
    # 重新整理Refresh Token時是否將舊Token加入黑名單,如果設定為False,則舊的重新整理令牌仍然可以用於獲取新的訪問令牌。需要將'rest_framework_simplejwt.token_blacklist'加入到'INSTALLED_APPS'的配置中
    'BLACKLIST_AFTER_ROTATION': False,  
    'ALGORITHM': 'HS256',  # 加密演算法
    'SIGNING_KEY': settings.SECRET_KEY,  # 簽名密匙,這裡使用Django的SECRET_KEY
    # 如為True,則在每次使用訪問令牌進行身份驗證時,更新使用者最後登入時間
    "UPDATE_LAST_LOGIN": False, 
    # 用於驗證JWT簽名的金鑰返回的內容。可以是字串形式的金鑰,也可以是一個字典。
    "VERIFYING_KEY": "",
    "AUDIENCE": None,# JWT中的"Audience"宣告,用於指定該JWT的預期接收者。
    "ISSUER": None, # JWT中的"Issuer"宣告,用於指定該JWT的發行者。
    "JSON_ENCODER": None, # 用於序列化JWT負載的JSON編碼器。預設為Django的JSON編碼器。
    "JWK_URL": None, # 包含公鑰的URL,用於驗證JWT簽名。
    "LEEWAY": 0, # 允許的時鐘偏差量,以秒為單位。用於在驗證JWT的過期時間和生效時間時考慮時鐘偏差。
    # 用於指定JWT在HTTP請求頭中使用的身份驗證方案。預設為"Bearer"
    "AUTH_HEADER_TYPES": ("Bearer",), 
    # 包含JWT的HTTP請求頭的名稱。預設為"HTTP_AUTHORIZATION"
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", 
     # 使用者模型中用作使用者ID的欄位。預設為"id"。
    "USER_ID_FIELD": "id",
     # JWT負載中包含使用者ID的宣告。預設為"user_id"。
    "USER_ID_CLAIM": "user_id",
    
    # 用於指定使用者身份驗證規則的函式或方法。預設使用Django的預設身份驗證方法進行身份驗證。
    "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
    #  用於指定可以使用的令牌類。預設為"rest_framework_simplejwt.tokens.AccessToken"。
    "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
    # JWT負載中包含令牌型別的宣告。預設為"token_type"。
    "TOKEN_TYPE_CLAIM": "token_type",
    # 用於指定可以使用的使用者模型類。預設為"rest_framework_simplejwt.models.TokenUser"。
    "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
    # JWT負載中包含JWT ID的宣告。預設為"jti"。
    "JTI_CLAIM": "jti",
    # 在使用滑動令牌時,JWT負載中包含重新整理令牌過期時間的宣告。預設為"refresh_exp"。
    "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
    # 滑動令牌的生命週期。預設為5分鐘。
    "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
    # 滑動令牌可以用於重新整理的時間段。預設為1天。
    "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
    # 用於生成access和刷refresh的序列化器。
    "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
    # 用於重新整理訪問令牌的序列化器。預設
    "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
    # 用於驗證令牌的序列化器。
    "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
    # 用於列出或撤銷已失效JWT的序列化器。
    "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
    # 用於生成滑動令牌的序列化器。
    "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
    # 用於重新整理滑動令牌的序列化器。
    "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
}

【2】基本使用

【2.1】使用SimpleJwt登入

  • urls.py
    • token_obtain_pair,路由中配置即可
from django.urls import path, include
# 匯入token_obtain_pair即可
from rest_framework_simplejwt.views import token_obtain_pair

urlpatterns = [
    path('login/', token_obtain_pair),
]

【2.2】使用SimpleJwt認證

  • 【注】進行認證時,攜帶的請求頭格式由配置檔案中配置

image-20240427165424249

image-20240427165430378

############# views.py ###########
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import GenericViewSet
from .models import Book
from .ser import CommonSerializer


class CommonView(GenericViewSet):
    queryset = Book.objects.all()
    serializer_class = CommonSerializer
    # 將 JWTAuthentication 配置到 authentication_classes 即可
    authentication_classes = [JWTAuthentication]
    # 【注】使用JWT預設的認證類,需要配合 IsAuthenticated 的許可權類
    permission_classes = [IsAuthenticated]

【3】基於SimpleJwt自定製

【3.1】定製登入成功返回格式

  • 繼承TokenObtainPairSerializer並重寫validate方法並修改返回值來控制返回格式
【3.1.1】重寫validate方法
【3.1.1.1】直接使用simple-jwt的登入介面
############### urls.py #########
from rest_framework_simplejwt.views import token_obtain_pair


urlpatterns = [
    path('login/', token_obtain_pair),
]
########### ser.py ##########
class LoginSerializer(TokenObtainPairSerializer):
    '''
    登入介面呼叫simple-jwt的序列化類,重寫validate定製返回格式
    '''

    def validate(self, attrs):
        data = super().validate(attrs)
        response = {
            'status': 100,
            'msg': '登入成功',
            'token': data.get('access')
        }
        return response
【3.1.1.2】使用context獲取token
  • 自定義登入介面
  • 在序列化類中進行校驗
  • 由於序列化類繼承TokenObtainPairSerializer,可以直接呼叫simple-jwtget_token方法獲得token 物件
  • 將token 放在序列化類物件的context屬性
  • view中透過ser.context獲取序列化類中放置的資料
class UserView(GenericViewSet):
    serializer_class = LoginSerializer
    authentication_classes = []

    @action(methods=['POST'], detail=False)
    def login(self, request):
        ser = self.get_serializer(data=request.data)
        if ser.is_valid():
            token = ser.context['token']
            refresh = ser.context['refresh']
            return Response({'code': 100, 'msg': '登入成功', 'token': token, 'refresh': refresh})
        else:
            return Response({'code': 101, 'msg': ser.errors})
################ ser.py ############
class LoginSerializer(TokenObtainPairSerializer):
    username = serializers.CharField()
    password = serializers.CharField()


    def validate(self, attrs):
        username = attrs.get('username')
        password = attrs.get('password')
        user = User.objects.filter(username=username, password=password).first()
        if not user:
            raise ValidationError("使用者名稱或密碼錯誤")
            
        token = self.get_token(user)
        self.context['refresh'] = str(token)
        self.context['token'] = str(token.access_token)
        return attrs
【3.1.2】重寫data方法
############ views.py ##########
class UserView(GenericViewSet):
    # 區域性禁用認證和許可權
    authentication_classes = []
    permission_classes = []
    serializer_class = RegisterSerializer

    @action(methods=['POST'], detail=False)
    def register(self, request):
        ser = self.get_serializer(data=request.data)
        ser.is_valid(raise_exception=True)
        ser.save()
        return Response(ser.data)
############### ser.py ###########
class RegisterSerializer(serializers.ModelSerializer):
    class Meta:
        model = UserInfo
        fields ='__all__'

    def create(self, validated_data):
        user = UserInfo.objects.create_user(**validated_data)
        return user

    @property
    def data(self):
        # 重寫data方法定製返回格式
        res = super().data
        return {'code': 100, 'msg': '註冊成功', 'result': res}

【3.2】定製認證類

  • 繼承JWTAuthentication並重寫authenticate方法
  • 由於繼承了JWTAuthentication,所以可以直接呼叫原來進行驗證的方法
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.exceptions import AuthenticationFailed


class CommonAuthentication(JWTAuthentication):
    def authenticate(self, request):
        token = self.get_header(request)
        if not token:
            raise AuthenticationFailed("請先登入或攜帶token資訊")
        payload = self.get_raw_token(token)
        validated_token = self.get_validated_token(payload)
        user = self.get_user(validated_token)
        if not user.locked:
            return user, validated_token
        else:
            raise AuthenticationFailed("當前使用者已凍結")

【4】多方式登陸

  • 繼承TokenObtainPairSerializer並重寫validate方法就能夠實現手動校驗
class LoginSerializer(TokenObtainPairSerializer):
    username = serializers.CharField()
    password = serializers.CharField()

    def _get_user(self, username, password):
        if re.match('^(13|14|15|18)[0-9]{9}$', username):
            user = User.objects.filter(mobile=username, password=password).first()
        elif re.match('^[a-z\d]+(\.[a-z\d]+)*@([\da-z](-[\da-z])?)+\.[a-z]+$', username):
            user = User.objects.filter(email=username, password=password).first()
        else:
            user = User.objects.filter(username=username, password=password).first()
        return user

    def get_token(cls, user):
        token = super().get_token(user)
        token['name'] = user.username
        return token

    def validate(self, attrs):
        username = attrs.get('username')
        password = attrs.get('password')
        user = self._get_user(username, password)
        if not user:
            raise ValidationError("使用者名稱或密碼錯誤")
        token = self.get_token(user)
        self.context['refresh'] = str(token)
        self.context['token'] = str(token.access_token)
        return attrs

【三】自定義JWT認證

  • 基於base64、md5和django金鑰進行加密
    • headers:使用base64對包含頭資訊的字典進行加密
    • payload:載荷中包含使用者的使用者名稱和密碼及其他資訊
    • Signature:簽名由經過base64加密過的headers和payload並加入django金鑰作為鹽,由md5進行加密
  • 由於md5和base64,嚴格來說都不是加密演算法,同樣的資料經過同樣的加密方式獲取的資料一致
  • 透過後端加密的資料與前端傳入的token進行比對
  • 由於我們的django金鑰是絕對私密的,經過比對,就能校驗出該token是否由我們簽發的
################# 定義類 ##################
import hashlib
import json
import base64
import time
from django.conf import settings


class CommonJWT:
    def __init__(self, header=None, salt=None, expired=180):
        '''
        初始化token的引數
        '''
        if not header:
            # 如果不指定headers  # 使用預設的headers
            self.header = header = {'alg': 'md5', 'type': 'JWT'}
        self.header = header  # 可以拿到原始的頭資訊
        # 類方法中使用經過base64編碼過的頭
        self.token_headers = self._encode_base64(header)
        if not salt:
            # 如果不指定鹽  # 使用預設的鹽
            salt = settings.SECRET_KEY  # django的金鑰
        self.salt = salt.encode(encoding='utf-8')
        self.create_time = time.time()
        self.expired = expired

    @staticmethod
    def _encode_base64(data: dict):
        data_string = json.dumps(data).encode(encoding='utf8')
        return base64.b64encode(data_string)

    @staticmethod
    def _decode_base64(data):
        return base64.b64decode(data)

    def _encrypt_md5(self, data):
        md5 = hashlib.md5()
        md5.update(data + self.salt)
        return md5.hexdigest().encode(encoding='utf8')

    def create_token(self, create_time=None, **kwargs):
        if not create_time:
            create_time = self.create_time
        # 將傳遞的資料作為載荷建立
        payload = kwargs
        # create_time 因為後續驗證的時候需要將老的token時間再拿回來,所以create_time放在最後面,否則校驗的時候順序就會不對
        payload.update({'expired': self.expired, 'create_time': create_time})
        # 將資料進行base64編碼
        token_payload = self._encode_base64(payload)
        # 我的headers頭是在物件初始化時定義的,這裡可以直接呼叫
        # 簽名由 頭 + 載荷 + 鹽 的資料拼接並使用摘要演算法完成構建
        signature = self._encrypt_md5(data=self.token_headers + token_payload)
        # 將 前面再次透過base64進行編碼
        signature = base64.b64encode(signature)
        # 使用【.】進行拼接 返回token
        return b'.'.join((self.token_headers, token_payload, signature))  # bytes

    def get_payload_from_token(self, token):
        # 將token按【.】切分  # 分為三個部分
        token_headers, token_payload, token_sign = token.split('.')
        # 中間部分就是攜帶的荷載
        payload = json.loads(self._decode_base64(token_payload))
        return payload
################### 使用 ##############
# 登入簽發token
class UserView(ViewSetMixin, CreateAPIView):
    queryset = UserInfo.objects.all()
    serializer_class = UserInfoSerializer
    authentication_classes = []

    @action(methods=['post'], detail=False)
    def login(self, request, *args, **kwargs):
        username = request.data.get('username')
        password = request.data.pop('password')
        user = auth.authenticate(username=username, password=password)
        if not user:
            return Response({'code': 101, 'msg': '使用者名稱或密碼錯誤'})
        token = CommonJWT().create_token(**request.data)
        return Response({'code': 100, 'msg': '登入成功', 'token': token})
############### 認證類校驗token ##############
class UserAuthentication(BaseAuthentication):
    def authenticate(self, request):
        jwt = CommonJWT()
        token = request.META.get('HTTP_TOKEN')
        if not token:
            raise AuthenticationFailed("請先登入")
        payload = jwt.get_payload_from_token(token=token)
        create_time = payload.pop('create_time')
        expired = payload.get('expired')
        # user = auth.authenticate(**payload)
        # if not user:
        #     raise AuthenticationFailed('使用者名稱或密碼有誤')
        username = payload.get('username')
        user = UserInfo.objects.filter(username=username).first()
        if not user:
            raise AuthenticationFailed("token有誤")
        if time.time() - create_time > expired:
            raise AuthenticationFailed("當前token已過期")
        true_token = jwt.create_token(create_time=create_time, **payload)
        if token.encode('utf-8') == true_token:
            return user, token
        else:
            raise AuthenticationFailed("請檢查token")

相關文章