T.I.L July 03, 2023 - [DRF] JWT refresh 토큰 쿠키에 저장하고 활용하는 로직 구성 중 발생한 트러블

2023. 7. 3. 22:42T.I.L (Today_I_Learned)

TokenRefreshView의 403Forbidden error

refresh 토큰을 쿠키에 저장하여 관리하는 작업을 진행하던 도중 발생한 문제

  1. refresh 토큰을 쿠키에 저장
  2. access 토큰이 만료되는 시점에 쿠키에 저장된 refresh 토큰을 사용
  3. access 토큰 재발급 및 local storage에 저장

2번 과정을 진행하기 위해 simple JWT의 TokenRefreshView를 사용하였음

여기에서 permission_classes = [IsAuthenticated]로 설정하고 진행하자 403Forbidden error가 발생

 

 

문제1

최초 구상한 로직에 허점을 발견

최초 구상한 로직은 access 토큰의 만료 시간이 지나기 전에 사용자가 로그인이 된 상태(아직은 access 토큰의 인증 효력이 남아있는 상태)에서 사용자임을 인증한 사용자만이 refresh 토큰으로 access 토큰의 재발급이 가능하도록 하는 로직이었음

애초에 refresh 토큰을 사용하여 재발급을 받는 행위 자체가 만료된 access 토큰을 재발급 받기 위함이었기에 사용자가 재발급을 받기 위해 access 토큰으로 본인임을 인증을 할 필요가 없는 당위성을 가짐.

또한, refresh 토큰을 쿠키에 따로 저장하는 이유가 보안상의 이유로 따로 보관한 것이라서 사용자에 대한 인증을 access 토큰으로 할 이유가 굳이 없었음.

 

위의 이유들을 근거로 permission_classes = [AllowAny]로 설정하고 진행을 해보니 운이 좋게도 access 토큰 재발급이 잘 진행 됨.

 

 

 

문제2

하지만 굳이 말도 안되는 모종의 이유로 사용자 본인임을 굳이 access 토큰으로 permission_classes = [IsAuthenticated]인 상황을 해결해야 한다면 어떻게 해결을 해야하는 것인가에 대한 의문이 들었고, 실제 permission_classes = [IsAuthenticated]인 상황에서는 문제를 해결하지 못함

 

문제가 될 수 있는(고려해야 하는) 조건들이 많았음

부끄럽지만 최종 프로젝트를 진행하는 중간까지도 JWT 토큰에 대한 이해도가 낮았기에 simple JWT와 장고에서 제공하는 기본 JWT 기능을 한 프로젝트에서 동시에 사용함.

JWT 토큰의 발급처는 중요하지 않지만 두개의 발급처를 둔 만큼 발급처의 설정과 환경을 일치시켜야 문제가 발생하지 않는다는 것을 알게 되었고 이와 관련된 여러 세팅과 속성들의 일치를 확인해야 했음.

 

문제가 될 수 있었던 부분들(코드)

#1 DEFAULT_AUTHENTICATION_CLASSES의 선언된 순서에 대해서 고려해야 했음
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework.authentication.SessionAuthentication',
        'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
}


#2 아래 두가지의 JWT 발급처의 속성값들이 일치하는지에 대해 고려해야 했음
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=14),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'ALGORITHM': 'HS256',

    'SIGNING_KEY': SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUTH_HEADER_TYPES': ('Bearer',),
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
    'JTI_CLAIM': 'jti',
    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
    "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
    'COOKIE_SAMESITE': 'Lax',
    'COOKIE_SECURE': True,

    "TOKEN_OBTAIN_SERIALIZER": "users.serializers.CustomTokenObtainPairSerializer",
}

JWT_AUTH = {
    'JWT_ALLOW_REFRESH': True,
    'JWT_EXPIRATION_DELTA': timedelta(minutes=60),
    'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=14),
    'JWT_COOKIE_SECURE': True,
    'JWT_COOKIE_SAMESITE': 'Lax',
}


#3 프로젝트 users/views.py 와 users/serializers.py에 흩어져 있는 JWT 토큰과 관련된 코드들에 대해 고려해야 했음

확인을 해봤지만 다행히 세팅값들은 모두 일치하였고 관련 코드들의 로직도 일치해서 발급된 access 토큰의 인증 시 인증이 되지 않는 경우는 발생하지 않을 것으로 판단 됨

다음으로 확인해 본 것은 request 값, 이유는 request로 들어가는 값이 제대로 되고있지 않아서 그런 것인가?에 근거함

 

request 값 확인을 위해 작성해 본 코드

class CustomTokenRefreshView(TokenRefreshView):
    permission_classes = [AllowAny]

    def post(self, request, *args, **kwargs):
        print("user", request.user)
        return super().post(request, *args, **kwargs)

'''
출력값

user AnonymousUser
HTTP POST /users/login/refresh/ 200 [0.01, 127.0.0.1:63956]
'''

보시다시피 위 코드의 출력값에 AnonymousUser가 찍히는 것을 확인할 수 있었음

그래서 TokenRefreshView를 추적하여 클래스의 메소드 들을 확인해 봄

TokenRefreshView는 평소에 사용하던 APIView와는 인증을 위해 값을 전달하는 방법에서 차이가 있었음.

APIView는 headers에 **Authrorization: Bearer <access_token>**의 일반적인 형식으로 값을 실어서 보냈다면

TokenRefreshView는 Bearer realm="{}"'.format(self.www_authenticate_realm)' 의 형식으로 실어서 보내야 한다고 함

아직 TokenRefreshView의 형식이 어떻게 되는지 정확히는 알지 못하지만 우선 중요한 것은 미리 정의된 상수를 사용하여 정적인 인증 헤더를 반환하는 역할을 한다고 함

즉, 특정한 인증 헤더 형식을 사용한다는 것

더 공부해야 할 영역인 것은 확실하나 우선 이 문제를 해결하는 데에는 아래의 코드가 사용됨

 

해결한 코드

from rest_framework_simplejwt.authentication import JWTAuthentication

class CustomTokenRefreshView(TokenRefreshView):
    authentication_classes = [JWTAuthentication]
    permission_classes = [IsAuthenticated]

authentication_classes = [JWTAuthentication]로 인증을 진행하자 인증이 잘 통과되었음

 

결론

문제 2번은 굳이 해결해 본 경우이고 문제 1번의 해결이 더 주요했다고 생각함

JWT토큰에서 access 토큰과 refresh 토큰이 가지는 근본적인 존재 이유에 대해 이해하게 된 트러블 슈팅이었음.