发布于 2026-01-06 7 阅读
0

Django Rest Framework 自定义 JWT 身份验证

Django Rest Framework 自定义 JWT 身份验证

本文并非教程或指南,更像是请求更有经验的 Django 开发者对代码进行审查和验证,因此,除非您能够自行审查和验证,否则请勿使用此代码。

介绍

Web 应用程序安全至关重要,在生产环境中自行构建安全体系责任重大。正因如此,我才如此钟爱 Django,因为它能处理大多数安全漏洞。但当我尝试将后端构建为 RESTful API 以便连接各种类型的客户端(SPA、移动端、桌面端等)时,问题就出现了。Django
Rest Framework (DRF) 内置了多种身份验证类,令牌身份验证或JWT更适合我的用例。但我仍然担心如何在客户端保存令牌。
大家都说不要将令牌保存在 localStorage 中,因为容易受到XSS 攻击,最好将其保存在 httponly cookie 中。但 cookie 也容易受到CSRF 攻击,而 DRF 会禁用所有 APIView 的 CSRF 保护。那么,最佳实践是什么呢?
一段时间以来,我一直都在使用它,因为大家都这么做,尤其是在移动端(我用的是 React Native)。我们有安全的存储,而且即使设备没有越狱(比如安卓没root或者iOS没越狱),每个应用也都是沙盒化的,大多数情况下令牌都是安全的。
但是这个问题在网页客户端(SPA)中仍然存在,所以我实现了一个可能有用的方案,并想在这里记录下来,以便从更有经验的开发者那里获得反馈。我的实现可以概括为以下步骤:

  • 用户发送包含用户名和密码的 POST 请求进行登录,然后服务器将执行以下 3 项操作:

    • 生成一个access_token有效期较短(例如 5 分钟)的 JWT,并将其包含在响应正文中发送。
    • 生成一个refresh_token有效期较长(数天)的 JWT,并将其放在 HTTP Only Cookie 中发送,这样客户端 JavaScript 就无法访问它。
    • 发送一个包含 CSRF 令牌的普通 cookie
  • 开发者需要确保所有不安全的视图(POST、UPDATE、PUT、Delete)都受到内置的 Django CSRF 保护,因为正如我上面提到的,DRF 默认会禁用它们。

  • 在客户端,开发人员应该注意以下事项:

    • 在客户端,每个请求都会自动在 cookie 中包含刷新令牌(请确保您的客户端域已在服务器 CORS 标头设置中列入白名单)。
    • access_token在邮件头中发送Authorization
    • X-CSRFTOKEN如果他发送的是 POST 请求,则在请求头中发送 CSRF 令牌。
    • 当他需要新的令牌时access_token,他需要向刷新令牌端点发送 POST 请求。

话不多说,让我们来看代码吧。

项目设置

python3 -m venv .venv
source .venv/bin/activate
pip install django django-cors-headers djangorestframework PyJWT

# create Django project with the venv activates
django-admin startproject project

# create an app 
./manage.py start app accounts
Enter fullscreen mode Exit fullscreen mode

在项目设置中启用应用程序并添加一些设置

INSTALLED_APPS = [
    ...
    # 3rd party apps
    'corsheaders',
    'rest_framework',

    # project apps
    'accounts',
]

CORS_ALLOW_CREDENTIALS = True # to accept cookies via ajax request
CORS_ORIGIN_WHITELIST = [
    'http://localhost:3000' # the domain for front-end app(you can add more than 1) 
]

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated', # make all endpoints private
    )
}
Enter fullscreen mode Exit fullscreen mode

在应用第一个迁移之前,根据Django 官方文档的建议,我喜欢在项目开始时创建一个自定义用户模型,即使我现在不会
accounts.models定义用户模型时使用它。

from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass
Enter fullscreen mode Exit fullscreen mode

project.settings.py定义用户模型时

...
AUTH_USER_MODEL = 'accounts.User'
...
Enter fullscreen mode Exit fullscreen mode

之后您可以通过以下方式之一引用用户模型。

from django.conf import settings
User = settings.AUTH_USER_MODEL

# OR

from django.contrib.auth import get_user_model
User = get_user_model()
Enter fullscreen mode Exit fullscreen mode

并将accounts.admin新用户模型注册到管理站点

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from accounts.models import User

admin.site.register(User, UserAdmin)
Enter fullscreen mode Exit fullscreen mode

创建超级用户

./manage.py createsuperuser
Enter fullscreen mode Exit fullscreen mode

最后,还有一个待测试的端点。正如我们在上述设置中声明的,所有端点默认都需要身份验证。我们可以在某些视图中覆盖此设置,就像我们稍后在登录部分所做的那样。

我们将创建一个用户配置文件端点,该端点将以 JSON 格式返回当前已认证用户对象,为此我们需要创建一个用户序列化器。

# accounts.serializers
from rest_framework import serializers
from accounts.models import User


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email',
                  'first_name', 'last_name', 'is_active']

Enter fullscreen mode Exit fullscreen mode
# project.urls
from accounts import urls as accounts_urls

urlpatterns = [
    path('accounts/', include(accounts_urls)),
]

# accounts.urls
urlpatterns = [
    path('profile', profile, name='profile'),
]

# accounts.views
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .serializers import UserSerializer


@api_view(['GET'])
def profile(request):
    user = request.user
    serialized_user = UserSerializer(user).data
    return Response({'user': serialized_user })
Enter fullscreen mode Exit fullscreen mode

现在,如果您尝试访问此端点,将会收到 403 错误。如引言中所述,我们需要登录,然后在请求头中发送 access_token。

登录视图

登录端点将是一个 POST 请求,请求体中包含 `<response_name>`username和 `<response_name> `。 我们将使用权限类装饰器将登录视图设为公开视图,并强制 Django 在登录成功时在响应中发送 CSRF cookie。password
AllowAny@ensure_csrf_cookie

如果登录成功,我们将得到:

  • access_token在响应正文中
  • 在仅限 HTTP 的cookierefreshtoken中。
  • csrftoken位于一个普通的 cookie 中,这样我们就可以通过 JavaScript 读取它,并在需要时重新发送它。
# accounts.views
from django.contrib.auth import get_user_model
from rest_framework.response import Response
from rest_framework import exceptions
from rest_framework.permissions import AllowAny
from rest_framework.decorators import api_view, permission_classes
from django.views.decorators.csrf import ensure_csrf_cookie
from accounts.serializers import UserSerializer
from accounts.utils import generate_access_token, generate_refresh_token


@api_view(['POST'])
@permission_classes([AllowAny])
@ensure_csrf_cookie
def login_view(request):
    User = get_user_model()
    username = request.data.get('username')
    password = request.data.get('password')
    response = Response()
    if (username is None) or (password is None):
        raise exceptions.AuthenticationFailed(
            'username and password required')

    user = User.objects.filter(username=username).first()
    if(user is None):
        raise exceptions.AuthenticationFailed('user not found')
    if (not user.check_password(password)):
        raise exceptions.AuthenticationFailed('wrong password')

    serialized_user = UserSerializer(user).data

    access_token = generate_access_token(user)
    refresh_token = generate_refresh_token(user)

    response.set_cookie(key='refreshtoken', value=refresh_token, httponly=True)
    response.data = {
        'access_token': access_token,
        'user': serialized_user,
    }

    return response

Enter fullscreen mode Exit fullscreen mode

以下是生成令牌的函数,请注意,为了提高安全性,我使用不同的密钥来签署刷新令牌。

# accounts.utils
import datetime
import jwt
from django.conf import settings


def generate_access_token(user):

    access_token_payload = {
        'user_id': user.id,
        'exp': datetime.datetime.utcnow() + datetime.timedelta(days=0, minutes=5),
        'iat': datetime.datetime.utcnow(),
    }
    access_token = jwt.encode(access_token_payload,
                              settings.SECRET_KEY, algorithm='HS256').decode('utf-8')
    return access_token


def generate_refresh_token(user):
    refresh_token_payload = {
        'user_id': user.id,
        'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7),
        'iat': datetime.datetime.utcnow()
    }
    refresh_token = jwt.encode(
        refresh_token_payload, settings.REFRESH_TOKEN_SECRET, algorithm='HS256').decode('utf-8')

    return refresh_token

Enter fullscreen mode Exit fullscreen mode

登录响应正文

登录响应 Cookie

DRF 的自定义身份验证类

Django Rest Framework 可以轻松创建自定义身份验证方案,官方文档
中有详细说明 。以下代码最初取自 DRF 源代码,我根据需要进行了修改。
请注意,DRF 仅在会话身份验证中强制执行 CSRF (参见 rest_framework/authentication.py)。

# accounts.authentication

import jwt
from rest_framework.authentication import BaseAuthentication
from django.middleware.csrf import CsrfViewMiddleware
from rest_framework import exceptions
from django.conf import settings
from django.contrib.auth import get_user_model


class CSRFCheck(CsrfViewMiddleware):
    def _reject(self, request, reason):
        # Return the failure reason instead of an HttpResponse
        return reason


class SafeJWTAuthentication(BaseAuthentication):
    '''
        custom authentication class for DRF and JWT
        https://github.com/encode/django-rest-framework/blob/master/rest_framework/authentication.py
    '''

    def authenticate(self, request):

        User = get_user_model()
        authorization_heaader = request.headers.get('Authorization')

        if not authorization_heaader:
            return None
        try:
            # header = 'Token xxxxxxxxxxxxxxxxxxxxxxxx'
            access_token = authorization_heaader.split(' ')[1]
            payload = jwt.decode(
                access_token, settings.SECRET_KEY, algorithms=['HS256'])

        except jwt.ExpiredSignatureError:
            raise exceptions.AuthenticationFailed('access_token expired')
        except IndexError:
            raise exceptions.AuthenticationFailed('Token prefix missing')

        user = User.objects.filter(id=payload['user_id']).first()
        if user is None:
            raise exceptions.AuthenticationFailed('User not found')

        if not user.is_active:
            raise exceptions.AuthenticationFailed('user is inactive')

        self.enforce_csrf(request)
        return (user, None)

    def enforce_csrf(self, request):
        """
        Enforce CSRF validation
        """
        check = CSRFCheck()
        # populates request.META['CSRF_COOKIE'], which is used in process_view()
        check.process_request(request)
        reason = check.process_view(request, None, (), {})
        print(reason)
        if reason:
            # CSRF failed, bail with explicit error message
            raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)

Enter fullscreen mode Exit fullscreen mode

创建好类之后,转到相应部分project.settings并激活它,REST_FRAMEWORK如下所示appname.filename.classname


REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'accounts.authentication.SafeJWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    )
}

Enter fullscreen mode Exit fullscreen mode

现在我们已经access_token设置好了身份验证方法,可以重新访问该profile端点,但这次我们将设置Authorization标头。

配置文件端点响应

刷新令牌视图

无论何时令牌过期或因任何原因需要新令牌,我们都需要一个名为refresh_token“端点”的接口。
此视图需要权限,AlloAny因为我们没有相应的接口access_token,但它将受到另外两项措施的保护。

  • refresh_token发送到 httoponly cookie 的有效值。
  • access_token如果满足这两个条件,服务器将生成一个新的有效 cookie并将其发送回去,这样我们就可以确定上述 cookie 没有被泄露。

如果账号refresh_token无效或已过期,用户需要重新登录。

import jwt
from django.conf import settings
from django.contrib.auth import get_user_model
from django.views.decorators.csrf import csrf_protect
from rest_framework import exceptions
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.decorators import api_view, permission_classes
from accounts.utils import generate_access_token


@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_protect
def refresh_token_view(request):
    '''
    To obtain a new access_token this view expects 2 important things:
        1. a cookie that contains a valid refresh_token
        2. a header 'X-CSRFTOKEN' with a valid csrf token, client app can get it from cookies "csrftoken"
    '''
    User = get_user_model()
    refresh_token = request.COOKIES.get('refreshtoken')
    if refresh_token is None:
        raise exceptions.AuthenticationFailed(
            'Authentication credentials were not provided.')
    try:
        payload = jwt.decode(
            refresh_token, settings.REFRESH_TOKEN_SECRET, algorithms=['HS256'])
    except jwt.ExpiredSignatureError:
        raise exceptions.AuthenticationFailed(
            'expired refresh token, please login again.')

    user = User.objects.filter(id=payload.get('user_id')).first()
    if user is None:
        raise exceptions.AuthenticationFailed('User not found')

    if not user.is_active:
        raise exceptions.AuthenticationFailed('user is inactive')


    access_token = generate_access_token(user)
    return Response({'access_token': access_token})

Enter fullscreen mode Exit fullscreen mode

令牌撤销

最后一步是如何撤销有效期较长的刷新令牌。您可以将该令牌列入黑名单,或者为其分配一个 UUID 并将其放入有效负载中,然后将其与用户关联并保存到数据库中。撤销令牌或注销时,只需更改数据库中的 UUID,使其与有效负载中的值不匹配即可。您可以根据应用程序的需求选择合适的方法。

文章来源:https://dev.to/a_atalla/django-rest-framework-custom-jwt-authentication-5n5