ジャンゴレストフレームワークカスタムJWT認証


This article is not a tutorial or a guide, it is more like a request for code review and validate the implementation from more experienced Django developers, so please don't use this code unless you are able to review and validate yourself


導入


Webアプリケーションのセキュリティは重要であり、自分自身でそれを行うことは巨大な責任である.それは私がDjangoを愛している理由である.なぜなら、それはそこでのセキュリティ脆弱性のほとんどを面倒にするからだ.しかし、私はすべてのタイプのクライアント(SPA、モバイル、デスクトップ、…など)を接続できるように、RESTful APIとしてバックエンドを構築したいときに、私の問題が現れる
ジャンゴレストフレームワークはauthentication classes , トークン認証JWT 私のユースケースに行く方法ですが、私はまだクライアント側でトークンを保存する方法の心配がある
誰もがXSS attacks そして、httponlyクッキーであなたのトークンを保存するより良いです、しかし、クッキーは開いていますCSRF attack あまりにもDRFはすべてのapiviewのためのCSF保護を無効にするので、何がこれをする最高の実行ですか.
しばらくの間、私はすべての携帯電話(私はネイティブに反応)として我々は安全なストレージを持っている場合でも、デバイスがない場合(ルートAndroidまたはJailbreak IOS)は、各アプリはサンドボックスされ、トークンはほとんどの場合は安全であるとしてそれを使用し続けて
問題はまだWebクライアント(SPA)にあるので、私は役に立つかもしれない実装に入ってきて、ここでそれを文書化したいので、より経験のある開発者からのフィードバックを得て、次のステップにまとめます.

  • ユーザーは、ログインするためにユーザ名とパスワードでポスト要求を送ります、そして、サーバーは3つのものをします
  • 生成するaccess_token そして、それは短い生命JWT(多分5分)です、そして、反応体にそれを送ってください
  • 生成するrefresh_token これは長寿命JWT(日)であり、httpOnlyクッキーでそれを送るので、それはクライアントJavaScript
  • CSRFトークンを含む通常のクッキーを送信する
  • 開発者は、上記のようにDDFがデフォルトで無効にしているので、すべての安全でないビュー( post , update , put , delete )がbuilddjango - csrf保護によって保護されていることを確認する必要があります.

  • クライアント側では開発者は注意しなければなりません.
  • クライアント側では、すべての要求は自動的にクッキーにリフレッシュトークンを含んでいます(クライアントドメインがサーバのCRSヘッダの設定で白くなっていることを確認してください)
  • 送信するaccess_tokenAuthorization ヘッダ.
  • CSRFトークンをX-CSRFTOKEN 彼がポスト要求をしているなら、ヘッダー
  • 彼が新しい必要があるときaccess_token , 彼は、トークン終点をリフレッシュするためにポストリクエストを送る必要があります
  • 十分な話、いくつかのコードを見てみましょう

    プロジェクト設定


    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
    
    プロジェクトの設定では、アプリケーションを有効にし、いくつかの設定を追加
    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
        )
    }
    
    最初の移行を適用する前に、official Django docs 私はそれを使用しない場合でも、カスタムユーザーモデルで私のプロジェクトを開始するのが好きです
    インaccounts.models ユーザモデル
    from django.contrib.auth.models import AbstractUser
    
    class User(AbstractUser):
        pass
    
    project.settings.py ユーザモデル
    ...
    AUTH_USER_MODEL = 'accounts.User'
    ...
    
    後で、次のいずれかでユーザーモデルを参照できます
    from django.conf import settings
    User = settings.AUTH_USER_MODEL
    
    # OR
    
    from django.contrib.auth import get_user_model
    User = get_user_model()
    
    accounts.admin 新規ユーザモデルを管理サイトに登録する
    from django.contrib import admin
    from django.contrib.auth.admin import UserAdmin
    from accounts.models import User
    
    admin.site.register(User, UserAdmin)
    
    スーパーユーザーの作成
    ./manage.py createsuperuser
    
    そして、最後に、テストの終点は、上記の設定ですべてのエンドポイントがデフォルトで認証を必要とすると宣言するように、私たちが後でログインにおいてするつもりであるように、我々は特定の見解でこれを越えることができます
    現在の認証されたユーザーオブジェクトを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']
    
    
    # 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 })
    
    さて、このエンドポイントを無効にしようとすると、403エラーが発生します.導入について説明したように、ログインする必要があります.

    ログイン表示


    ログイン終了点はPOSTリクエストですusername and password リクエスト本文で.
    許可クラスのデコレータを使用してログインビューをパブリックにするAllowAny , また、ビューを飾る@ensure_csrf_cookie ログイン成功の場合、djangoが応答でCSRFクッキーを送るのを強制します
    ログイン成功ならば、
  • access_token を返します.
  • エーrefreshtoken httponlyクッキーで.
  • エーcsrftoken 通常のクッキーでは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
    
    
    そしてここでトークンを生成する関数があります.セキュリティのために別の秘密を使ってリフレッシュトークンに署名してください
    # 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
    
    


    DRF用カスタム認証クラス


    Django RESTフレームワークはカスタム認証スキームを作成することを簡単にしますofficial docs
    最初に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)
    
    
    そのクラスを作成したら、project.settings そして、REST_FRAMEWORK セクションappname.filename.classname
    
    REST_FRAMEWORK = {
        'DEFAULT_AUTHENTICATION_CLASSES': (
            'accounts.authentication.SafeJWTAuthentication',
        ),
        'DEFAULT_PERMISSION_CLASSES': (
            'rest_framework.permissions.IsAuthenticated',
        )
    }
    
    
    今私たちはaccess_token そして、我々が再訪問することができる認証方法をセットしてくださいprofile 今回はエンドポイントですがAuthorization ヘッダー

    リフレッシュトークンビュー


    トークンが期限切れになったり、何らかの理由で新しいトークンを必要とするときはいつでもrefresh_token エンドポイント.
    このビューは、AlloAny 我々には、ありませんaccess_token しかし、それは2他のものによって保護されます
  • 有効なrefresh_token httoponlyクッキーに送られます.
  • CSRFトークンは、2つの条件が満たされた場合、上記のクッキーが妥協しないことを確認しますaccess_token そして、それを送り返してください.
  • if the 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})
    
    

    取り消す


    パズルの最後の部分は、長い寿命を持っているリフレッシュトークンを無効にする方法であり、トークンをブラックリストまたはトークンのUUIDを割り当てるかもしれないし、ペイロードにそれを置くかもしれないし、ユーザーにリンクし、データベースに保存するときには、データベース内のUUIDをペイロード内の値と一致しないように変更する場合、アプリケーションに必要なものを選ぶことができます