1. 程式人生 > 實用技巧 >-drf-JWT認證

-drf-JWT認證

一 認證機制對比

在使用者註冊或登入後,我們想記錄使用者的登入狀態,或者為使用者建立身份認證的憑證。我們不再使用Session認證機制,而使用Json Web Token(本質就是token)認證機制。

'''
Json web token(JWT),是為了在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準(RFC 7519),該token被設計為緊湊且安全的,特別使用與分散式站點的單點登入(SSO)場景。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其他業務邏輯必須的宣告資訊,該token也可直接被用於認證,也可被加密。

'''

二 構成和工作原理

1 JWT的構成

JWT就是一段字串,由三段資訊構成的,將這三段資訊文字用 . 連結一起就構成了JWT字串,就像這樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

第一部分我們稱它為頭部(header),第二部分我們稱其為載荷(payload),第三部分是簽證(signatrue)

1)header

JWT的頭部承載兩部分資訊:

  • 宣告型別,這裡是JWT
  • 宣告加密的演算法,通常直接使用 HMAC SHA256

完整的頭部就像下面這樣的Json:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然後將頭部進行base64加密(該加密是可以對稱解密的),構成第一部分:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

2)payload

載荷就是存放有效資訊的地方,這些有效資訊包含三個內容:

  • 標準中註冊的宣告
  • 公共的宣告
  • 私有的宣告

標準中註冊的宣告(建議但不強制使用):

  • iss: jwt簽發者
  • sub: jwt所面向的使用者
  • aud: 接收jwt的一方
  • exp
    : jwt的過期時間,這個過期時間必須要大於簽發時間
  • nbf: 定義在什麼時間之前,該jwt都是不可用的.
  • iat: jwt的簽發時間
  • jti: jwt的唯一身份標識,主要用來作為一次性token,從而回避時序攻擊

公共的宣告:公共的宣告可以新增任何的資訊,一般新增使用者的相關資訊或其他業務需要的必要資訊,但不建議新增敏感資訊,因為該部分在客戶端可解密。

私有的宣告:私有宣告式提供者和消費者所共同定義的宣告,一般不建議存放敏感資訊,因為base64是對稱解密的,意味著該部分資訊可以歸類為明文資訊。

定義一個payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然後哦將其進行base64加密,得到JWT的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

3)signature

JWT的第三部分是一個簽證資訊,這個簽證資訊由三個部分組成:

  • header(base64後的)
  • payload(base64後的)
  • secret

這個部分需要base64加密後的header和base64加密後的payload使用 . 連線組成的字元創,然後通過header中宣告的加密方式進行加鹽secret組合加密,然後就構成了JWT的第三部分。

// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

將這三部分用 . 連線成一個完整的字串,構成了最終的JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是儲存在服務端的,jwt的簽發生也是在服務端的,secret就是用來進行jwt的簽發和jwt的驗證,所以,它就是你服務端的私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret,那就意味著客戶端是可以自我簽發jwt了。

2 本質原理

jwt認證演算法:簽發與校驗

'''
1)jwt分三段式:頭.體.簽名 (head.payload.sign)
2)頭和體是可逆加密,讓伺服器可以反解出user物件;簽名是不可逆加密,保證整個token的安全性
3)頭體簽名三部分,都採用json格式的字串,進行加密,可逆加密一般採用base64演算法,不可逆加密一般採用hash(md5)演算法
4)頭中的內容是基本資訊:公司資訊、專案組資訊、token採用的加密方式資訊 進行base64加密
{
	"company": "公司資訊",
	...
}
5)體中的內容是關鍵資訊:使用者主鍵、使用者名稱、簽發時客戶端資訊(裝置號、地址)、過期時間 進行base64加密
{
	"user_id": 1,
	...
}
6)簽名中的內容時安全資訊:頭的加密結果 + 體的加密結果 + 伺服器不對外公開的安全碼 進行md5加密
{
	"head": "頭的加密字串",
	"payload": "體的加密字串",
	"secret_key": "安全碼"
}

加密時都是jason格式進行加密處理
'''

簽發:根據登入請求提交來的 賬號+密碼+裝置資訊 簽發token

'''
1)基本資訊儲存到json字典中,然後採用base64演算法加密得到 頭字串

2)關鍵資訊儲存到json字典中,然後採用base64演算法加密得到 體字串

3)用頭、體加密字串再加安全碼資訊儲存json字典,採用hash md5演算法加密得到 簽名字串

賬號密碼就能根據User表得到user物件,形成的三段字串用 . 拼接成token返回給前臺

'''

校驗:根據客戶端帶token的請求 反解出user物件

'''
1)將token按 . 拆分為三段字串,第一段 頭加密字串 一般不需要做任何處理(因為不需要反解獲取資訊)

2)第二段 體加密字串,要反解出使用者主鍵,通過主鍵從User表中就能得到登入使用者,過期時間和裝置資訊都
是安全資訊,確保token沒過期,且是同一裝置來的(相比session與cookie安全多,因為這需要盜取token情況下還需要控制之前登入的主機進行登入操作),之後再進行base64加密便於之後簽名字串的校驗

3)再用 第一段 + 第二段 + 伺服器安全碼 不可逆md5加密 與第三段 簽名字串進行碰撞校驗,通過後才能代表第二段校驗得到的user物件就是合法的登入使用者(這樣通過演算法進行token校驗,免去了從資料庫取出token進行校驗的操作,實現了一定的高併發)此時校驗成功才代表你是之前登入過的使用者,因為base64是可以在客戶端被反解的,需要校驗有沒有存在在客戶端被反解後修改資訊的行為
'''

3 drf專案的jwt認證開發流程(重點)

  • 用賬號密碼訪問登入介面,登入介面邏輯中呼叫 簽發token 演算法,得到token,返回客戶端,客戶端自己存到cookies中
  • 校驗token的演算法應該寫在認證類中(在認證類中呼叫),全域性配置給認證元件,所有檢視類請求,都會進行認證校驗,所以請求帶了token,就會反解出user物件,在檢視類中用request.user就能訪問登入使用者了

注意:登入介面需要做到 認證 + 許可權 ,因為只有登入了才能產生token有權訪問其他介面(註冊介面也是)

補充base64編碼解碼

import base64
import json
dic_info={
  "sub": "1234567890",
  "name": "lqz",
  "admin": True
}
byte_info=json.dumps(dic_info).encode('utf-8')
# base64編碼
base64_str=base64.b64encode(byte_info)
print(base64_str)
# base64解碼
base64_str='eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogImxxeiIsICJhZG1pbiI6IHRydWV9'
str_url = base64.b64decode(base64_str).decode("utf-8")
print(str_url)

三 drf-jwt安裝和簡單使用

1 官網

http://getblimp.github.io/django-rest-framework-jwt/

2 安裝

pip install djangorestframework-jwt

3 簡單使用

# 1 建立超級使用者
python3 manage.py createsuperuser

# 2 配置路由
from django.urls import path
from rest_framework_jwt.views import obtain_jwt_token
urlpatterns = [
    path('login/', obtain_jwt_token),
]

# 3 postman傳送登入請求,成功之後返回生成的token

# 4 使用者登入了才能訪問某個介面
from rest_framework_jwt.authentication import JSONWebTokenAuthentication
from rest_framework.permissions import IsAuthenticated
# 有內建的認證模組,直接匯入使用區域性使用即可

# 只是認證,不能限制匿名使用者訪問,因為原始碼中對匿名使用者的條件並沒有raise報錯
# 而是return None
# 所以要配合許可權,是否登入的許可權去限制
class BookInfo(APIView):
    authentication_classes = [JSONWebTokenAuthentication, ]
    permission_classes = [IsAuthenticated, ]
    def get(self,request):
        return Response('檢視書籍')

如圖在請求頭加上這樣的鍵值對,注意的是values 中 需要jwt空格後輸入token值

# 5 使用者是否登入都可訪問某個介面,區別的是登入了的產生了user物件
from rest_framework_jwt.authentication import JSONWebTokenAuthentication
class BookInfo(APIView):
    authentication_classes = [JSONWebTokenAuthentication, ]
    def get(self,request):
        return Response('檢視書籍')

四 Dango auth的User表自動簽發

1 自定義認證返回結果(需要在setting中配置)

# utils.py
from app10.seralizer import UserSerializer

def jwt_response_payload_handler(token, user=None, request=None):
    return {
        'status': 0,
        'msg': 'ok',
        'data': {
            'token': token,
            'user': UserSerializer(user).data
        }
    }

2 配置setting.py

import datetime
JWT_AUTH = {
    # 過期時間1天
    'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1),
    # 自定義認證結果:見下方序列化user和自定義response
  	# 如果不自定義,返回的格式是固定的,只有token欄位
    'JWT_RESPONSE_PAYLOAD_HANDLER': 'app10.utils.jwt_response_payload_handler',
}

配置後登入的返回結果

3 自定義基於JWT的認證類

from rest_framework.exceptions import AuthenticationFailed
from rest_framework_jwt.authentication import jwt_decode_handler
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
import jwt


class JSONWebTokenAuthentication(BaseJSONWebTokenAuthentication):
    def authenticate(self, request):
        # token資訊放在請求頭,請求地址中
        token = request.META.get('HTTP_Authorization'.upper())
        # 檢驗token是否合法

        try:
            payload = jwt_decode_handler(token)
            # 解碼荷載,得到user_id
        except jwt.ExpiredSignature:
            raise AuthenticationFailed('過期了')
        except jwt.DecodeError:
            raise AuthenticationFailed('解碼錯誤')
        except jwt.InvalidTokenError:
            raise AuthenticationFailed('不合法的token')
        # token認證不過直接報錯,杜絕了匿名使用者訪問該介面
        user = self.authenticate_credentials(payload)
        # 認證通過得到user物件
        return user, token
# 在檢視類中配置 區域性配置
	authentication_classes = [JSONWebTokenAuthentication, ]

自定義的,所以沒有配置jwt的字首,那麼就可以不用使用jwt的字首了

全域性使用

# setting.py
REST_FRAMEWORK = {
    # 認證模組
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'users.app_auth.JSONWebTokenAuthentication',
    ),
}

# 全域性使用下進行區域性禁用
# 檢視類下定義
authentication_classes = []

五 基於jwt的多方式登入

1 手機+密碼 使用者名稱+密碼 郵箱+密碼
2 流程分析(post請求)
	-路由:自動生成
  -檢視類:ViewSet(ViewSetMixin, views.APIView)
  -序列化類:重寫validate方法,在這裡對使用者名稱和密碼進行了校驗

路由

path('login/', views.LoginUser.as_view({'post': 'login'}))

序列化器

from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework_jwt.utils import jwt_encode_handler, jwt_payload_handler
from app10 import models
import re

class LoginSerializer(serializers.ModelSerializer):
    username = serializers.CharField()
    # 重寫username,因為輸入的使用者名稱可能並不是username,而是手機號或是郵箱
    class Meta:
        model = models.UserInfo
        fields = ['username', 'password']
        extra_kwargs = {'password': {'write_only': True}}

    def validate(self, attrs):
        # 該username是上步重寫的username
        username = attrs.get('username')
        password = attrs.get('password')

        # 如果是手機號
        if re.match('^1[3-9]\d{9}$',username):
            # 以手機號登入
            user = models.UserInfo.objects.filter(mobile=username).first()
        elif re.match('^.+@.+$', username):
            # 以郵箱登入
            user =models.UserInfo.objects.filter(email=username).first()
        else:
            # 以使用者名稱登入
            user =models.UserInfo.objects.filter(username=username).first()
        # 再進行輸入使用者與資料庫的資料校驗

        # 如果user 有值即使用者名稱校驗正確,且密碼正確
        if user and user.check_password(password):
            # 登入成功後生成token
            # drf-jwt中有通過user物件生成token的方法
            payload = jwt_payload_handler(user)
            token = jwt_encode_handler(payload)
            # token要在檢視類中使用
            # 所以檢視類和序列化類之間通過context這個字典傳遞資料
            self.context['token'] = token
            self.context['username'] = user.username
            return attrs

        else:
            raise ValidationError('使用者名稱或密碼錯誤')

檢視

class LoginUser(ViewSet):
    def login(self, request):
        # 例項化得到一個序列化物件,將data資料傳入到序列化器中進行邏輯處理
        ser = seralizer.LoginSerializer(data=request.data)
        # 序列化類的物件的校驗方法
        ser.is_valid(raise_exception=True)
        # 沒有斷言報錯則是登入成功,返回手動簽發token
        token = ser.context.get('token')
        username = ser.context.get('username')
        # return APIResponse(token=token,username=username)
        return APIResponse(token=token, username=username)

封裝Response

from app10.seralizer import UserSerializer
from rest_framework.response import Response

# 這是預設的drf-jwt登入後返回自定義
def jwt_response_payload_handler(token, user=None, request=None):
    return {
        'status': 0,
        'msg': 'ok',
        'data': {
            'token': token,
            'user': UserSerializer(user).data
        }
    }

# 這是自定義登入類的返回自定義
class APIResponse(Response):
    def __init__(self, code=100, msg='成功', data=None, status=None, headers=None, content_type=None, **kwargs):
        dic = {'code': code, 'msg': msg}
        if data: # 一般是指序列化返回的值

            dic['data'] = data

        dic.update(kwargs)  # 這裡使用update
				# token值在這裡接收並以鍵值對進入字典中,然後賦值給父類的data
        # 這樣返回到前端的資料則是父類的data
        super().__init__(data=dic, status=status,
                         template_name=None, headers=headers,
                         exception=False, content_type=content_type)