Django Rest Framework 사용자 정의 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


소개


웹 응용 프로그램의 안전성은 매우 중요하다. 생산 과정에서 이 일을 독립적으로 완성하는 것은 커다란 책임이다. 이것이 바로 내가 Django를 좋아하는 이유이다. 왜냐하면 그곳의 대부분의 보안 빈틈을 해결했기 때문이다. 그러나 백엔드를 모든 유형의 클라이언트 (SPA, 모바일, 데스크톱 등) 를 연결하기 위해restful API로 구축하고 싶을 때 나의 문제가 발생한다
Django Rest Framework(DRF)는 서로 다른 내장authentication classes, 영패 인증 또는JWT을 제공하는 것이 제 용례의 발전 방향이지만, 클라이언트에서 영패를 어떻게 저장해야 할지 걱정입니다.
모든 사람들이 영패를 로컬 저장소에 저장하지 말라고 한다. 왜냐하면XSS attacks, 영패를 httponly 쿠키에 저장하는 것이 가장 좋지만, 쿠키도 CSRF attack에 개방되고, DRF는 모든 APIVIew에 대해 CSRF 보호를 사용하지 않는다. 그렇다면 무엇이 가장 좋은 방법일까.
한동안 나는 그것을 사용해 왔다. 왜냐하면 모든 사람, 특히 이동 분야(내 반응은 로컬), 우리는 안전한 저장소를 가지고 있고, 만약 장치가 (루트 안드로이드나 탈옥 ios) 가 아니라면, 모든 응용 프로그램은 샌드박스이고, 영패는 대부분의 상황에서 안전하기 때문이다.
이 문제는 웹 클라이언트(SPA)에 여전히 존재하기 때문에 저는 유용한 실현 가능성을 제기했습니다. 저는 여기에 그것을 기록하여 더욱 경험이 많은 개발자로부터 피드백을 얻을 수 있도록 하겠습니다. 저는 제 실현을 다음과 같은 단계로 요약할 수 있습니다.

  • 사용자는 사용자 이름과 비밀번호가 있는 POST 요청을 보내서 로그인하고 서버에서 세 가지 일을 합니다.
  • 생성access_token, 짧은 수명 jwt(5분 가능), 응답체에 전송
  • 생성refresh_token, 장수명 jwt(일), httponly만 있는 쿠키에 보내기 때문에 클라이언트javascript
  • 에서 접근할 수 없습니다
  • CSRF 토큰이 포함된 일반 쿠키 보내기
  • 개발자는 모든 안전하지 않은 보기(POST, UPDATE, PUT, Delete)가 내장된 Django CSRF의 보호를 받도록 해야 합니다. 왜냐하면 위에서 언급한 바와 같이 DRF는 기본적으로 사용하지 않기 때문입니다.

  • 클라이언트에서 개발자는 다음과 같이 주의해야 합니다.
  • 클라이언트에서 요청마다 자동으로 쿠키에 새로 고침 영패가 포함됩니다(서버cors 헤더 설정에 클라이언트 도메인이 화이트리스트에 포함되는지 확인하십시오.)
  • 헤더에 있는 access_token 을 발송합니다.
  • POST 요청이 진행 중인 경우 Authorization 헤더에 CSRF 토큰
  • 을 보냅니다.
  • 새로운 X-CSRFTOKEN이 필요할 때, 영패 단점을 새로 고치기 위해 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
    
    프로젝트 설정에서 응용 프로그램을 사용하고 설정을 추가합니다
    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의 건의에 따라 사용자 정의 사용자 모델로 프로젝트를 시작하는 것을 좋아합니다. 설령 내가 지금 그것을 사용하지 않더라도access_token에서 사용자 모델 정의
    from django.contrib.auth.models import AbstractUser
    
    class User(AbstractUser):
        pass
    
    accounts.models에서 사용자 모델 정의
    ...
    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()
    
    project.settings.py에서 관리 사이트에 새 사용자 모델 등록
    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 오류를 받을 것입니다. 로그인한 다음, 요청 헤더에access\u 영패를 보내야 합니다.

    로그인 뷰


    로그인 단점은post 요청으로 요청 본문 accounts.adminusername 이 있습니다.
    사용 권한 클래스 장식기password를 사용하여 로그인 보기를 공개하고 AllowAny 장식 보기를 사용합니다. 로그인에 성공하면 Django가 응답에서 CSRF 쿠키를 보내도록 합니다.
    로그인에 성공하면 다음과 같은 이점을 얻을 수 있습니다.
  • 응답체의 @ensure_csrf_cookie.
  • HTTPCookie의 경우access_token만 해당합니다.
  • 일반 쿠키 refreshtoken 를 사용하면 자바스크립트에서 읽을 수 있고 필요할 때 다시 보낼 수 있습니다
  • # 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는 세션 인증에서만 CSRFrest_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)
    
    
    클래스를 만든 후 csrftoken 로 이동하여 project.settings 부분에서 이렇게 활성화합니다 REST_FRAMEWORK
    
    REST_FRAMEWORK = {
        'DEFAULT_AUTHENTICATION_CLASSES': (
            'accounts.authentication.SafeJWTAuthentication',
        ),
        'DEFAULT_PERMISSION_CLASSES': (
            'rest_framework.permissions.IsAuthenticated',
        )
    }
    
    
    현재 우리는 appname.filename.classname 인증 방법을 설치했고 access_token 노드에 다시 접근할 수 있지만 이번에는 profile 헤더를 설정할 것이다

    영패 보기 새로 고침


    언제든지 영패가 만료되거나 어떤 이유로 새로운 영패가 필요하면 우리는 Authorization 단점이 필요하다.
    이 뷰는 refresh_token 에 대한 라이센스가 필요하지만 다른 두 가지 측면에서 보호됩니다.
  • httopoly 쿠키에서 발송된 유효AlloAny.
  • CSRF 영패입니다. 따라서 상기 쿠키가 손상되지 않았는지 확인합니다. 이 두 가지 조건이 충족되면 서버는 새로운 유효access_token를 생성하여 다시 보냅니다.
  • 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})
    
    

    영패 취소


    수수께끼의 마지막 부분은 영패 리셋을 취소하는 방법입니다. 영패 리셋의 생명 주기가 매우 길기 때문에 영패를 블랙리스트에 넣거나 영패에 uid를 분배하여 부하에 넣은 다음 사용자에게 연결시켜 데이터베이스에 저장할 수 있습니다. 취소나 로그아웃할 때 데이터베이스에 있는 uid를 변경하여 부하의 값과 일치하지 않게 하면 응용 프로그램에 적합한 내용을 선택할 수 있습니다.

    좋은 웹페이지 즐겨찾기