iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0
Software Development

Django 2024: 從入門到SaaS實戰系列 第 20

Django REST framework: 基礎認證防線 - BasicAuthentication與 TokenAuthentication

  • 分享至 

  • xImage
  •  

我們已經了解到Django REST framework(DRF)中權限的設計,但是在確認用戶是否有對應的權限之前,對於用戶的認證(Authentication)是我們今天要探討的重點,今天主要會探討幾種DRF在認證上的方式與應用,以及應該在哪些場景下做使用

程式碼:https://github.com/class83108/drf_demo/tree/drf_auth_base

今日重點:

  • 內建認證類別介紹
    • BasicAuthentication
    • SessionAuthentication
    • RemoteUserAuthentication
    • 配置內建認證類別
    • 自定義認證類別
  • 探討Token認證
    • Token認證基本原理
    • 配置Token認證完整流程
    • Token認證的風險

內建認證類別介紹

DRF提供許多內建的認證類別,滿足不同的應用場景

BasicAuthentication

  • 使用HTTP Basic Auth進行認證
  • 如果認證成功,會返回request.user (代表Django User模型實例)與request.auth (為None,不會提供額外的認證訊息)
  • 如果想要設置在生產環境,一定要搭配HTTPS做使用
  • 容易實現,在做簡單測時可以快速開發

至於HTTP Basic Auth的流程是什麼?

我們可以來看DRF中BaseAuthentication的原始碼

class BasicAuthentication(BaseAuthentication):
    """
    HTTP Basic authentication against username/password.
    """
    www_authenticate_realm = 'api'

    def authenticate(self, request):
        """
        Returns a `User` if a correct username and password have been supplied
        using HTTP Basic authentication.  Otherwise returns `None`.
        """
        auth = get_authorization_header(request).split()

        if not auth or auth[0].lower() != b'basic':
            return None

        if len(auth) == 1:
            msg = _('Invalid basic header. No credentials provided.')
            raise exceptions.AuthenticationFailed(msg)
        elif len(auth) > 2:
            msg = _('Invalid basic header. Credentials string should not contain spaces.')
            raise exceptions.AuthenticationFailed(msg)

        try:
            try:
                auth_decoded = base64.b64decode(auth[1]).decode('utf-8')
            except UnicodeDecodeError:
                auth_decoded = base64.b64decode(auth[1]).decode('latin-1')

            userid, password = auth_decoded.split(':', 1)
        except (TypeError, ValueError, UnicodeDecodeError, binascii.Error):
            msg = _('Invalid basic header. Credentials not correctly base64 encoded.')
            raise exceptions.AuthenticationFailed(msg)

        return self.authenticate_credentials(userid, password, request)

    def authenticate_credentials(self, userid, password, request=None):
        """
        Authenticate the userid and password against username and password
        with optional request for context.
        """
        credentials = {
            get_user_model().USERNAME_FIELD: userid,
            'password': password
        }
        user = authenticate(request=request, **credentials)

        if user is None:
            raise exceptions.AuthenticationFailed(_('Invalid username/password.'))

        if not user.is_active:
            raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

        return (user, None)

    def authenticate_header(self, request):
        return 'Basic realm="%s"' % self.www_authenticate_realm
  • authenticate方法中,首先調用get_authorization_header方法,去取得請求頭中HTTP_AUTHORIZATION的值
  • HTTP_AUTHORIZATION會包含Basic關鍵字,以及後面的關鍵訊息:用戶名與密碼經過base64編碼後的字符串
  • 拿到用戶名與密碼後調用authenticate_credentials方法,確認該用戶是否存在,最後返回**(user, None)**,也就是前面提到的user實例以及沒有其他額外的認證資訊
  • authenticate_header方法為認證失敗時生成對應的響應頭
  • 這也是為什麼Basic Auth的安全性較低:
    • base64要進行decode很容易
    • 不搭配HTTPS使用很容易被攔截

SessionAuthentication

  • 依賴Django內建的會話系統
  • 根據請求中的sessionid來判斷是否有該用戶,同時enforce_csrf方法強制檢查請求中有無CCSRF token(一些不安全的HTTP方法:POST, PUT, PATCH與DELETE)
  • 適用與Django常規視圖混合使用的API,且由瀏覽器訪問時
  • 成本較高,每個請求都需要訪問會話存儲,可能影響性能

RemoteUserAuthentication

  • 依賴外部的認證系統,只檢查請頭中的REMOTE_USER變數
  • 需要先進行額外的配置,詳細方式可以參考官方文檔
  • 因為是仰賴外部認證系統,所以可以搭配大型或是企業級的應用,特別是本來就有建立好認證基礎建設的環境,提供更靈活與安全的方式

配置內建認證類別

可以選擇在settings.py中設置

# settings.py

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.BasicAuthentication',
        'rest_framework.authentication.TokenAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ]
}

或是直接在視圖中調用

from rest_framework.authentication import BaseAuthentication

class WorkspaceViewSet(viewsets.ModelViewSet):
    authentication_classes = [BaseAuthentication]
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer
    permission_classes = [permissions.IsAuthenticated]

自定義認證類別

這邊我就不會完整示範整個流程,主要是說明自定義的原理

我們在探討BasicAuthentication時可以了解到大致的流程如下

  1. 定義認證類別,選擇繼承BasicAuthentication或是其他合適的基礎類別
  2. 改寫authenticate 方法
    • 從請求中拿到需要認證的資訊
    • 進行驗證後返回結果
  3. 改寫authenticate_credentials方法(可選)
    • 是否需要添加額外的認證訊息
  4. 響應與錯誤返回(可選)
    • 認證失敗拋出exceptions.AuthenticationFailed
    • 改寫authenticate_header方法,改寫響應頭
from rest_framework import authentication
from rest_framework import exceptions

class CustomAuthentication(authentication.BaseAuthentication):
    def authenticate(self, request):
        username = request.META.get('X_USERNAME')
        if not username:
            return None

        try:
            user = User.objects.get(username=username)
        except User.DoesNotExist:
            raise exceptions.AuthenticationFailed('No such user')

        return (user, None)

探討Token認證

接著我們來談也是DRF內建之一的認證方式:TokenAuthentication

相比於BasicAuthentication,SessionAuthentication更加的安全,因為不會每次請求都附帶用戶ID與密碼,但是如果是在前後端分離的開發情形,還有跨域等相關問題,使用SessionAuthentication會有較大的限制。因此在這樣的架構下,使用無狀態(服務端不需要儲存會話訊息)的Token認證會是更適合的選擇

Token認證適用場景如下:

Token認證的流程

  • 用戶提供有效的憑證(通常是用戶名和密碼)
  • 伺服器端驗證憑證,並生成一個唯一的 token
  • 伺服器端將 token 返回給客戶端
  • 客戶端在後續請求中包含這個 token
  • 伺服器端驗證 token 的有效性,並識別用戶

我們等等會回來再重新複習整個流程,這邊先對大綱有基本的認知

配置Token認證完整流程

  • 在settings.py中做好配置,接著進行資料庫的遷移
# settings.py
INSTALLED_APPS = [
    ...
    'rest_framework.authtoken',
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.TokenAuthentication',
    ],
}
  • 為用戶生成Token

前面有提到,每一個用戶都會有自己唯一的一組Token,因此生成Token可能有以下幾種情境:

  1. 建立新用戶時同時幫他產生Token
  2. 為已建立的用戶生成Token

前者的話,可以搭配Django的signals去實現,底下為官方文檔資訊:

接收用戶建立時的User's post_save signal來進行處理,但因為之前沒有特別提及有關Django signals的部分,這邊就不展開說明

from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from rest_framework.authtoken.models import Token

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_auth_token(sender, instance=None, created=False, **kwargs):
    if created:
        Token.objects.create(user=instance)

而如果是已建立的用戶生成Token,則有幾種不同的方式:

./manage.py drf_create_token <username>
  • 或是使用API端點的方式
from rest_framework.authtoken import views

urlpatterns = [
    ...
    path("api-token-auth/", views.obtain_auth_token),
    ...
]

https://ithelp.ithome.com.tw/upload/images/20240930/20161866uHZnsqmsql.png

可以看到Token被儲存到剛剛遷移到DB的authtoken_token表中,也因此重複調用API,Token一旦建立除非經過刪除不然不會改變

https://ithelp.ithome.com.tw/upload/images/20240930/20161866z62tbc4BwW.png

也能看到Token與User進行了綁定

https://ithelp.ithome.com.tw/upload/images/20240930/20161866d64uBRGFrH.png

那這個Token是怎麼生成的呢?路由導到obtain_auth_token視圖中,就是ObtainAuthToken視圖類別,相信經過前面的介紹,現在對視圖類別也很熟悉了

class ObtainAuthToken(APIView):
    ...

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.validated_data['user']
        token, created = Token.objects.get_or_create(user=user)
        return Response({'token': token.key})

obtain_auth_token = ObtainAuthToken.as_view()

看到在post方法中,找到序列化器AuthTokenSerializer 後,用輸入用戶名與密碼先確認用戶使否合法,最後再查找屬於該用戶的Token或是重新建立並且返回

因此如果想要自定義建立Token的流程,就是繼承ObtainAuthToken然後改寫post方法

from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.authtoken.models import Token
from rest_framework.response import Response

class CustomAuthToken(ObtainAuthToken):

    def post(self, request, *args, **kwargs):
        serializer = self.serializer_class(data=request.data,
                                           context={'request': request})
        serializer.is_valid(raise_exception=True)
        user = serializer.validated_data['user']
        token, created = Token.objects.get_or_create(user=user)
        return Response({
            'token': token.key,
            'user_id': user.pk,
            'email': user.email
        })

那我們現在已經拿到Token了,怎麼驗證的話前面BaseAuthentication 有看過類似的例子

再來看TokenAuthentication 架構也不會差太多,畢竟都是繼承BaseAuthentication然後改寫方法

  • 先來看authenticate 方法
def authenticate(self, request):
        auth = get_authorization_header(request).split()

        if not auth or auth[0].lower() != self.keyword.lower().encode():
            return None

        if len(auth) == 1:
            msg = _('Invalid token header. No credentials provided.')
            raise exceptions.AuthenticationFailed(msg)
        elif len(auth) > 2:
            msg = _('Invalid token header. Token string should not contain spaces.')
            raise exceptions.AuthenticationFailed(msg)

        try:
            token = auth[1].decode()
        except UnicodeError:
            msg = _('Invalid token header. Token string should not contain invalid characters.')
            raise exceptions.AuthenticationFailed(msg)

        return self.authenticate_credentials(token)

這邊的auth,也就是我們要在發送請求之前,需要在header附上的Token

curl的話會是如下:

curl -X GET http://127.0.0.1:8000/api/example/ -H 'Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b'

而使用postman則會如圖所示,製作出對應的請求頭

https://ithelp.ithome.com.tw/upload/images/20240930/20161866z4qfxGpaMu.png

  • 拿到decode過的Token,調用 authenticate_credentials
def authenticate_credentials(self, key):
        model = self.get_model()
        try:
            token = model.objects.select_related('user').get(key=key)
        except model.DoesNotExist:
            raise exceptions.AuthenticationFailed(_('Invalid token.'))

        if not token.user.is_active:
            raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

        return (token.user, token)

在model的部分如果沒有特別設定就會是rest_framework.authtoken.models import Token

此時要注意,請求中只有包含Token資料,並且仰賴這個Token去返回對應的User實例

  • 視圖中調用

視圖類別:

from rest_framework.authentication import TokenAuthentication

class WorkspaceViewSet(viewsets.ModelViewSet):
    authentication_classes = [TokenAuthentication]

視圖函式則是使用裝飾器調用:

from rest_framework.decorators import api_view, authentication_classes
from rest_framework.authentication import TokenAuthentication
from rest_framework.response import Response

@api_view(['GET'])
@authentication_classes([TokenAuthentication])
def my_view(request):
    # 視圖邏輯
    return Response({"message": "Hello, authenticated user!"})

我們再來回顧整個流程:

  1. 用戶發起POST請求來向ObtainAuthToken拿到Token,ObtainAuthToken會驗證用戶名與密碼是否合法
  2. 如果沒問題,建立或是查找出用戶唯一的Token,最終返回Token
  3. Token是存在DRF內建的Token模型之下,並與User模型建立關聯
  4. 用戶如果要對配置Token認證的視圖發請求時,需要在請求頭附上Token
  5. TokenAuthentication 從請求中找到用戶資料與Token,拿到Token表中比對
  6. 如果認證失敗則拋出AuthenticationFailed
  7. 反之返回User實例與Token實例

Token認證的風險

從剛剛的過程中我們可以了解到只用DRF內建的Token認證可能有以下問題

  • authenticate_credentials 方法中可以發現,認證是只認Token,因此如果Token被盜用是無法辨別身份的
  • DRF內建的Token模型沒有設置過期時間
  • 內建Token本身沒有辦法儲存額外的資訊

因此要解決的話,就必須自定義Token模型,或是添加其他欄位來進行改進

但是沒辦法解決的是伺服器端一定得保留Token的相關資料,增加額外的壓力

今日總結

我們今天了解了DRF中最基本的幾種認證身份的方式

  • BaseAuthentication 透過請求中的用戶名與密碼來進行驗證
  • TokenAuthentication 則是改進密碼被攔截的風險,事先建立Token,並且依照Token來進行身份驗證,如果想要增進安全的話,則需要定期刷新或是額外自訂一些訊息進入Token中。但是還是有需要額外儲存Token的痛點

而明天要介紹的**JWT(JSON Web Token)**認證,就是在此Token認證的基礎經過改量的一種認證方式,不用在服務端儲存資料,並且也內建過期時間等額外訊息來補全普通DRF Token認證的缺失,是一種在生產環境下更合適的認證方式


上一篇
Django REST framework: 權限基礎到角色存取控制
下一篇
Django REST framework: JWT與TokenAuthentication的全面比較
系列文
Django 2024: 從入門到SaaS實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言