DRF(Django REST framework) JWT토큰 발급하기(with simplejwt)

antoliny0919
202464

DRF에서 JWT토큰 발급을 구현하려면 어떻게 해야할까?

no-img
DRF, Django에서 직접적으로 JWT토큰과 관련된 로직을 제공해주진 않지만 DRF공식문서 API Guide Authentication페이지에 JSON Web Token Authentication섹션을 보면 알 수 있듯이 DRF는 친절하게도 JWT토큰을 통한 인증을 구현하는데 djangorestframework-simplejwt라는 패키지를 권장한다.
 
 
simplejwt패키지를 사용하여 JWT토큰 발급하기
 

JWT토큰 발급하기

 
지금까지 알게된것은 그저 simplejwt라는 패키지를 통해서 JWT토큰 로직을 쉽게 구현할 수 있다는것 뿐이지만 놀랍게도 그게 다라는 것, 즉 JWT토큰을 구현하는데 필요한 준비는 다 했다고 말할 수 있다.
simplejwt의 Getting started섹션을 보고 천천히 따라해보자
일단 당연한 절차인 패키지를 사용하기 위해 설치를 진행한다.
sh
COPY

$ pip install djangorestframework-simplejwt
설치를 완료했다면 이제 simplejwt와 관련된 설정을 할 차례다.
일단 가장 먼저 settings.py INSTALLED_APPS에 'rest_framework_simplejwt'를 추가하고
no-img
DRF설정에서 DEFAULT_AUTHENTICATION_CLASSES로 아래와 같은 경로형태의 문자열을 추가하자
no-img
일단은 DEFAULT_AUTHENTICATION_CLASSES라는 값에 대해 의미 그대로 기본 인증 클래스를 JWTAuthentication이라는 클래스로 설정했구나 라고 생각하고 다음 스텝으로 넘어가자
DEFAULT_AUTHENTICATION_CLASSES ??
인증과 관련된 기본 클래스를 설정하는 부분이다.(일단 지금 당장은 이렇게만 알고 있자)위 로직은 DRF View Authentication에 대한 이해가 필요하다.
그 다음은 simplejwt에서 제공하는 View들을 urls에 추가하는 단계이다.
no-img
아마 Class형태의 View를 작성해봤다면 위 코드가 어떠한 동작을 가지게 되는지 간단하게 예상할 수 있을거라고 생각한다.
간단하게 설명하자면 '/api/token/' url로 클라이언트가 접근했을때 TokenObtainPairView라는 클래스 View의 요청한 HTTP method에 따른 메서드가 호출된다.
놀랍게도 DRF에서 JWT토큰 발급을 구현하는건 이 세 단계가 끝이다.
이제 실제로 사용해보면서 JWT토큰이 잘 생성되고 응답받을 수 있는지 확인해보자.
친절하게도 simplejwt문서 Getting started페이지의 Usage섹션을 보면 사용방법 또한 파악할 수 있다.
no-img
'/api/token/'경로로 POST method요청을 보낼때 HTTP Request Message Body에 인증을 진행할 유저의 아이디와 패스워드를 담아 보내기만 하면 된다.
no-img
위와 같이 POST메서드를 통해 username과 password를 body에 담아서 HTTP Request Message를 서버에 전달했을때
HTTP Response로 생성된 refresh토큰과 access토큰이 있는걸 확인할 수 있다.
위 access토큰은 보기에는 굉장히 복잡한 문자열이지만 저 토큰만으로 request를 보낸 유저가 누구인지 판별할 수 있다.
만약 특정유저의 정보를 가져오는 기능이 있고 해당 기능은 특정 권한이 존재해야만 가능하다고 가정해보자
일단 접근한 유저의 권한을 파악하기 전에 먼저 어떤 유저가 접근했는지부터 알아야한다.

JWT토큰 테스트 해보기

python
COPY

# urls.py

urlpatterns = [
    path('api/user/', UserView.as_view(), name='token_obtain_pair'),
]

# views.py

class UserView(views.APIView):
  
  def get(self, request):
    print(request.user)
    
    return Response(status=status.HTTP_200_OK)
필자는 테스트를 위해 UserView를 따로 만들었고
UserView의 get method는 오직 테스트를 위한 목적으로 request.user만 출력하고 상태코드 200인 응답을 반환한다.
여기서 주목할 점은 request.user로 어떤 값이 출력되는것인가 이다.
아래와 같이 아무런 설정없이 request를 보내보면
no-img
no-img
request.user로 AnonymousUser라는 값이 출력되는걸 확인할 수 있다.
AnonymousUser는 의미 그대로 익명 사용자로 인증을 하지 않은 클라이언트를 뜻한다.
이번에는 JWT토큰을 발급받은 유저가 접근한다고 가정해보자.
해당 유저는 로그인에 성공하여 access토큰을 소유함으로써 자신이 누구인지 서버로부터 증명할 수 있는 상태이다.
그렇기 때문에 해당 유저는 자신을 증명하기 위해 request를 보낼때 토큰 또한 같이 보내야 서버에서 해당 토큰을 통해 유저를 파악할 수 있다.
그렇다면 request를 보낼때 토큰을 어떻게 전달해야 하는가?
 

JWT access토큰 전달하기

 
simplejwt Getting started섹션을 보면 request header에 Authorization키로 'Bearer {access_token}'형식 값을 추가해서 전달하기만 하면 된다는걸 알 수 있다.
no-img
이번에는 request 헤더에 Authorization키로 위에서 설명한 형식대로 값을 추가하여 테스트를 해보면
no-img
테스트에 사용된 토큰
테스트에 사용된 토큰은 이전에 토큰 발급할때 사용한 antoliny0919유저로 진행했다. 이전 과정에서 토큰 발급할때 사용된 이미지의 request body를 확인해보자.
no-img
이번에는 AnonymousUser가 아닌 토큰을 발급받았던 유저 antoliny0919가 출력된걸 확인할 수 있다.
no-img
참고로 해당 값은 Django의 User모델 객체로써 이제 서버입장에서 request를 보낸 유저가 인증을 한 유저인지 아닌지 인증을 했다면 어떤 유저인지 정확히 파악할 수 있게 되었다.
이렇게 DRF에서 JWT토큰 발급은 Django의 장점이자 단점인 굉장한 깊이의 추상화 덕분인지 설치하고 복붙 딸깍 몇번하면 JWT토큰 발급/인증 로직을 구현할 수 있다.
이 과정까지만 알아도 로직을 완성하는데 무리가 없지만 필자같이 피곤한 사람들은 이러한 마법같은 현상에 매번 의문을 품고 판도라의 상자를 열려고 한다.
 
 
simplejwt 소스코드를 통해 로직 이해하기

POST메서드 추적하기

 
필자는 urls.py에 아래와 같은 코드를 추가했다.
python
COPY

urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
그리고 해당 '/api/token/'경로로 아이디와 비밀번호를 담은 HTTP request message를 보냈을때 refresh, access 토큰이 응답으로 주어지는걸 확인할 수 있었다.
그러면 TokenObtainPairView의 post메서드를 확인하면 위와같은 과정이 있지 않을까?
python
COPY

class TokenObtainPairView(TokenViewBase):
    """
    Takes a set of user credentials and returns an access and refresh JSON web
    token pair to prove the authentication of those credentials.
    """

    _serializer_class = api_settings.TOKEN_OBTAIN_SERIALIZER
TokenObtainPairView는 settings.py에 있는 TOKEN_OBTAIN_SERIALIZER라는 값을 _serializer_class의 값으로 가지게 되고 TokenViewBase를 상속받은 형태이다.
필자는 settings.py에 JWT토큰관련 설정을 따로 커스텀 하지 않았기 때문에 simplejwt의 기본 설정이 적용된다.
python
COPY

DEFAULTS = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
    "ROTATE_REFRESH_TOKENS": False,
    "BLACKLIST_AFTER_ROTATION": False,
    "UPDATE_LAST_LOGIN": False,
    "ALGORITHM": "HS256",
    "SIGNING_KEY": settings.SECRET_KEY,
    "VERIFYING_KEY": "",
    "AUDIENCE": None,
    "ISSUER": None,
    "JSON_ENCODER": None,
    "JWK_URL": None,
    "LEEWAY": 0,
    "AUTH_HEADER_TYPES": ("Bearer",),
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
    "USER_ID_FIELD": "id",
    "USER_ID_CLAIM": "user_id",
    "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
    "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
    "TOKEN_TYPE_CLAIM": "token_type",
    "JTI_CLAIM": "jti",
    "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
    "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
    "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
    "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
    #-------------------------------------------------------------------------------------------------------
    "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
    #-------------------------------------------------------------------------------------------------------
    "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
    "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
    "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
    "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
    "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
    "CHECK_REVOKE_TOKEN": False,
    "REVOKE_TOKEN_CLAIM": "hash_password",
}
위 DEFAULT설정에서 'TOKEN_OBTAIN_SERIALIZER'라는 키를 확인할 수 있고
해당키의 값으로 "rest_framework_simplejwt.serializers.TokenObtainPairSerializer" 경로같이 생긴 문자열이 있는걸 알 수 있다.
python
COPY

class TokenObtainPairView(TokenViewBase):
    """
    Takes a set of user credentials and returns an access and refresh JSON web
    token pair to prove the authentication of those credentials.
    """

    _serializer_class = "rest_framework_simplejwt.serializers.TokenObtainPairSerializer"
즉 우리가 따로 settings.py에 simplejwt관련 설정을 하지 않았다면
_serializer_class의 값은 "rest_framework_simplejwt.serializers.TokenObatinPairSerializer"가 된다.
TokenObtainPairView는 이게 끝이기 때문에 실질적인 로직이 담겨있을 부모 클래스 TokenViewBase를 확인해보자.
python
COPY

class TokenViewBase(generics.GenericAPIView):
    permission_classes = ()
    authentication_classes = ()

    serializer_class = None
    _serializer_class = ""

    www_authenticate_realm = "api"

    def get_serializer_class(self) -> Serializer:
        """
        If serializer_class is set, use it directly. Otherwise get the class from settings.
        """

        if self.serializer_class:
            return self.serializer_class
        try:
            return import_string(self._serializer_class)
        except ImportError:
            msg = "Could not import serializer '%s'" % self._serializer_class
            raise ImportError(msg)

    def get_authenticate_header(self, request: Request) -> str:
        return '{} realm="{}"'.format(
            AUTH_HEADER_TYPES[0],
            self.www_authenticate_realm,
        )

    def post(self, request: Request, *args, **kwargs) -> Response:
        serializer = self.get_serializer(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except TokenError as e:
            raise InvalidToken(e.args[0])

        return Response(serializer.validated_data, status=status.HTTP_200_OK)
TokenViewBase에는 post메서드가 존재한다.
그 뜻은 '/api/token/' POST메서드로 request를 보내면 위 TokenViewBase의 post메서드가 동작하게 된다.
코드를 보면 self.get_serializer(data=request.data)로 serializer과정이 먼저 진행된다.
self.get_serializer는 TokenViewBase의 부모 클래스인 GenericAPIView의 메서드인데
아래와 같이 코드를 보면 self.get_serializer_class()를 호출하는걸 확인할 수 있다.
python
COPY

    def get_serializer(self, *args, **kwargs):
        """
        Return the serializer instance that should be used for validating and
        deserializing input, and for serializing output.
        """
        serializer_class = self.get_serializer_class()
        kwargs.setdefault('context', self.get_serializer_context())
        return serializer_class(*args, **kwargs)
그렇다면 일단 먼저 확인해야할 코드는 TokenViewBase의 get_serializer_class메서드이다.
python
COPY

    def get_serializer_class(self) -> Serializer:
        """
        If serializer_class is set, use it directly. Otherwise get the class from settings.
        """

        if self.serializer_class:
            return self.serializer_class
        try:
            return import_string(self._serializer_class)
        except ImportError:
            msg = "Could not import serializer '%s'" % self._serializer_class
            raise ImportError(msg)
get_serializer_class메서드를 보면 가장먼저 self.serializer_class의 값을 우선하여 serializer_class로 지정하려 하지만 해당값은 따로 지정하지 않는 이상 None이기때문에 첫번째 조건문은 실패하게 된다.
그 다음은 import_string함수의 호출로 이전에 봤던 TokenObtainPairView에서 설정했던 self._serializer_class의 값을 인자로 전달한다.
 

경로형태 문자열

 
참고로 DEFAULT에서 봤듯이 해당 값은 문자열 형태이다.
python
COPY

   ...
   "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
   ...
하지만 get_serializer_class에서 호출하는 import_string함수는 importlib내장모듈을 통해 위와같은 문자열을 파싱하여 해당경로의 파일을 동적으로 임포트하고 마지막 "."이후에 있는 TokenObtainPairSerializer클래스를 반환하게 된다. --> cache_import의 return문에 해당.
python
COPY

def cached_import(module_path, class_name):
    # Check whether module is loaded and fully initialized.
    if not (
        (module := sys.modules.get(module_path))
        and (spec := getattr(module, "__spec__", None))
        and getattr(spec, "_initializing", False) is False
    ):
        module = import_module(module_path)
    return getattr(module, class_name)

def import_string(dotted_path):
    """
    Import a dotted module path and return the attribute/class designated by the
    last name in the path. Raise ImportError if the import failed.
    """
    try:
        module_path, class_name = dotted_path.rsplit(".", 1)
    except ValueError as err:
        raise ImportError("%s doesn't look like a module path" % dotted_path) from err

    try:
        return cached_import(module_path, class_name)
    except AttributeError as err:
        raise ImportError(
            'Module "%s" does not define a "%s" attribute/class'
            % (module_path, class_name)
        ) from err
일단은 import_string함수와 관련된 내용은 이번 포스트 범위 밖이기 때문에 간단하게 경로형태의 문자열은 마지막 "."이후에 있는 클래스를 얻는다고 생각하면 된다.
이제 다시 get_serializer메서드로 돌아가서 이제는 serializer_class의 값을 알 수 있다.
python
COPY

    def get_serializer(self, *args, **kwargs):
        """
        Return the serializer instance that should be used for validating and
        deserializing input, and for serializing output.
        """
        serializer_class = self.get_serializer_class()
        kwargs.setdefault('context', self.get_serializer_context())
        return serializer_class(*args, **kwargs)
바로 TokenObtainPairSerializer라는 클래스로 return문을 보면 알 수 있듯이 바로 해당 클래스의 객체를 생성하는 serializer 과정이 시작된다.
그리고 인자로 **kwargs에는 클라이언트가 HTTP request message body에 담은 request.data를 전달한다.
커스텀 유저 모델을 사용했을때
커스텀 유저를 사용하고 USERNAME_FIELD를 username이 아닌 다른값으로 설정했다면 **kwargs의 값이 필자와 다를 수 있다.
 

Serialization

 
python
COPY

    def post(self, request: Request, *args, **kwargs) -> Response:
        serializer = self.get_serializer(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except TokenError as e:
            raise InvalidToken(e.args[0])

        return Response(serializer.validated_data, status=status.HTTP_200_OK)
serializer객체를 생성하였고 이제 serializer.is_valid(raise_exception=True)구문을 실행할 차례다.
위 is_valid메서드를 호출하면 serializer의 유효성검증 과정이 시작되는데 결론적으로 해당 serializer의 클래스인
TokenObtainPairSerializer의 validate메서드를 거치게 된다.
python
COPY

class TokenObtainPairSerializer(TokenObtainSerializer):
    token_class = RefreshToken

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]:
        data = super().validate(attrs)

        refresh = self.get_token(self.user)

        data["refresh"] = str(refresh)
        data["access"] = str(refresh.access_token)

        if api_settings.UPDATE_LAST_LOGIN:
            update_last_login(None, self.user)

        return data
TokenObtainPairSerializer의 validate메서드에서는 먼저 부모 클래스의 validate를 먼저 수행한다.
data = super().validate(attrs) --> TokenObtainSerializer의 validate
python
COPY

class TokenObtainSerializer(serializers.Serializer):
    username_field = get_user_model().USERNAME_FIELD
    token_class: Optional[Type[Token]] = None

    default_error_messages = {
        "no_active_account": _("No active account found with the given credentials")
    }

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

        self.fields[self.username_field] = serializers.CharField(write_only=True)
        self.fields["password"] = PasswordField()

    def validate(self, attrs: Dict[str, Any]) -> Dict[Any, Any]:
        authenticate_kwargs = {
            self.username_field: attrs[self.username_field],
            "password": attrs["password"],
        }
        try:
            authenticate_kwargs["request"] = self.context["request"]
        except KeyError:
            pass

        self.user = authenticate(**authenticate_kwargs)

        if not api_settings.USER_AUTHENTICATION_RULE(self.user):
            raise exceptions.AuthenticationFailed(
                self.error_messages["no_active_account"],
                "no_active_account",
            )

        return {}
먼저  __init__메서드를 보면
self.fields[self.username_field] = serializers.CharField(write_only=True),
self.fields["password"] = PasswordField()
전달된 request.data --> username과 password가 serializer 필드값이 되며 역직렬화가 진행되어 필드타입에 따른 검증 과정을 거친다.(올바른 형태의 데이터를 보냈는가?)
이와중에 username같은 경우는 이전에 클래스 변수로 username_field값을 사용하는데 username_field=get_user_model().USERNAME_FIELD 위와 같은 코드로 get_user_model()을 통해 settings.py에 입력된 유저 모델을 먼저 가져오고 해당 유저 모델의 USERNAME_FIELD값을 username_field로 설정하게 된다.
 
즉 커스텀 유저모델을 사용하고 커스텀 유저모델의 USERNAME_FIELD를 사용했을지라도 정상적으로 동작하게 되는 이유가 된다.
python
COPY


    def validate(self, attrs: Dict[str, Any]) -> Dict[Any, Any]:
        authenticate_kwargs = {
            self.username_field: attrs[self.username_field],
            "password": attrs["password"],
        }
        try:
            authenticate_kwargs["request"] = self.context["request"]
        except KeyError:
            pass

        self.user = authenticate(**authenticate_kwargs)

        if not api_settings.USER_AUTHENTICATION_RULE(self.user):
            raise exceptions.AuthenticationFailed(
                self.error_messages["no_active_account"],
                "no_active_account",
            )

        return {}
validate함수에서는 이전 __init__에서 생성했던 필드들을 통해 authenticate_kwargs를 생성하고 Django의 유저 인증 함수인 authenticate함수를 통해 유저를 파악한다.
python
COPY

self.user = authenticate(**authenticate_kwargs)
정상적인 request message가 왔다면(존재하는 유저) username과 password가 일치하는 유저를 반환하게 됨으로써 self.user에 토큰을 요청한 유저를 파악하고 저장하게 된다.
그리고 마지막으로 빈객체를 반환함으로써 TokenObtainSerializer의 validate는 종료되고 다시 TokenObatinPairSerializer의 validate영역으로 돌아오게 된다.
python
COPY

class TokenObtainPairSerializer(TokenObtainSerializer):
    token_class = RefreshToken

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]:
        data = super().validate(attrs)

        refresh = self.get_token(self.user)

        data["refresh"] = str(refresh)
        data["access"] = str(refresh.access_token)

        if api_settings.UPDATE_LAST_LOGIN:
            update_last_login(None, self.user)

        return data
이제 유저가 누군지 알았으니 해당 유저의 JWT토큰을 발급해 반환하기만 하면 된다.
 

토큰 Payload 설정하기

 
위와 같은 과정은 get_token이라는 메서드에서 진행되는데 인자로 보낸값으로 이전 TokenObtainSerializer에서 진행했던 authentication의 결과값인 self.user가 인자로 전달된다.
python
COPY

refresh = self.get_token(self.user)

@classmethod
def get_token(cls, user: AuthUser) -> Token:
    return cls.token_class.for_user(user)  # type: ignore
get_token메서드는 클래스변수 token_class의 for_user메서드를 호출한다.
token_class는 TokenObtainPairSerializer의 코드를 보면 알 수 있듯이 RefreshToken이라는 클래스다.
결론은 RefreshToken.for_user(user)호출로 보면 되고 실질적으로 RefreshToken의 부모 클래스인 Token클래스의 for_user가 호출된다(RefreshToken은 for_user메서드를 오버라이딩 하지 않았다.)
python
COPY


class Token:
    """
    A class which validates and wraps an existing JWT or can be used to build a
    new JWT.
    """

    token_type: Optional[str] = None
    lifetime: Optional[timedelta] = None

    def __init__(self, token: Optional["Token"] = None, verify: bool = True) -> None:
        """
        !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error
        message if the given token is invalid, expired, or otherwise not safe
        to use.
        """
        if self.token_type is None or self.lifetime is None:
            raise TokenError(_("Cannot create token with no type or lifetime"))

        self.token = token
        self.current_time = aware_utcnow()

        # Set up token
        if token is not None:
            # An encoded token was provided
            token_backend = self.get_token_backend()

            # Decode token
            try:
                self.payload = token_backend.decode(token, verify=verify)
            except TokenBackendError:
                raise TokenError(_("Token is invalid or expired"))

            if verify:
                self.verify()
        else:
            # New token.  Skip all the verification steps.
            self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}

            # Set "exp" and "iat" claims with default value
            self.set_exp(from_time=self.current_time, lifetime=self.lifetime)
            self.set_iat(at_time=self.current_time)

            # Set "jti" claim
            self.set_jti()
    ...
    ...
            
    @classmethod
    def for_user(cls, user: AuthUser) -> "Token":
        """
        Returns an authorization token for the given user that will be provided
        after authenticating the user's credentials.
        """
        user_id = getattr(user, api_settings.USER_ID_FIELD)
        if not isinstance(user_id, int):
            user_id = str(user_id)

        token = cls()
        token[api_settings.USER_ID_CLAIM] = user_id

        if api_settings.CHECK_REVOKE_TOKEN:
            token[api_settings.REVOKE_TOKEN_CLAIM] = get_md5_hash_password(
                user.password
            )

        return token

    _token_backend: Optional["TokenBackend"] = None
위 코드는 Token클래스의 일부인데 for_user함수를 보면 api_settings.USER_ID_FIELD값을 통해 user객체의 특정 필드값을 사용하게 된다.
간단하게 토큰을 통해 유저를 파악하기 위해 user를 구별할 수 있는 필드를 설정한다고 생각하면 된다.
참고로 api_settings는 이번 포스트에서 벌써 두 번 마주치게 되었는데 지금까지 포스트 내용을 잘 따라갔다면 api_settings는 simplejwt의 settings파일의 DEFAULT값이다.
특별히 본인 프로젝트 settings.py에 해당값을 커스텀하지 않았다면 기본값인 "id"를 사용하게 된다.
python
COPY

"USER_ID_FIELD": "id",
참고로 위 id값이 JWT토큰의 Payload부분의 값중 하나가 되고 나중에 "Authorization"헤더로 토큰만 전달되었을때 유저를 파악하는 기준이 되는 중요한 부분이다.
그리고 token=cls()로 Token클래스를 생성한다.
그렇기때문에 이번에는 Token클래스의 __init__매직메서드를 확인할 차례다.
python
COPY

class Token:
    """
    A class which validates and wraps an existing JWT or can be used to build a
    new JWT.
    """

    token_type: Optional[str] = None
    lifetime: Optional[timedelta] = None

    def __init__(self, token: Optional["Token"] = None, verify: bool = True) -> None:
        """
        !!!! IMPORTANT !!!! MUST raise a TokenError with a user-facing error
        message if the given token is invalid, expired, or otherwise not safe
        to use.
        """
        if self.token_type is None or self.lifetime is None:
            raise TokenError(_("Cannot create token with no type or lifetime"))

        self.token = token
        self.current_time = aware_utcnow()
        ...
토큰의 __init__매직메서드는 가장 먼저 token_type, lifetime을 먼저 확인한다.
위 두 값은 토큰에게 있어서 필수이며 우리는 현재 RefreshToken을 생성하는 과정중에 있다는걸 기억해야한다.
python
COPY

class RefreshToken(BlacklistMixin, Token):
    token_type = "refresh"
    lifetime = api_settings.REFRESH_TOKEN_LIFETIME
    ...
위와 같이 RefreshToken에 해당 필드값이 설정되어 있다.
lifetime은 말 그대로 토큰의 유효기간을 의미하며 api_settings값을 사용한다.(따로 설정하지 않았다면 유효기간은 1일)
python
COPY

        self.token = token
        self.current_time = aware_utcnow()

        # Set up token
        if token is not None:
            # An encoded token was provided
            token_backend = self.get_token_backend()

            # Decode token
            try:
                self.payload = token_backend.decode(token, verify=verify)
            except TokenBackendError:
                raise TokenError(_("Token is invalid or expired"))

            if verify:
                self.verify()
        else:
            # New token.  Skip all the verification steps.
            self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}

            # Set "exp" and "iat" claims with default value
            self.set_exp(from_time=self.current_time, lifetime=self.lifetime)
            self.set_iat(at_time=self.current_time)

            # Set "jti" claim
            self.set_jti()
그 다음으로 Token의 __init__매직메서드에서 token값의 존재 유무에 따라 다른 로직을 적용하는데
우리가 생성하려고 하는 RefreshToken은 for_user에서 Token클래스를 생성할때 아무런 인자도 전달하지 않았다.
python
COPY

   @classmethod
    def for_user(cls, user: AuthUser) -> "Token":
        ...
        token = cls()
        ...
그렇기 때문에 RefreshToken에서 Token클래스의 __init__매직메서드에서는 token값이 None이되고 else로직을 택하게 된다.
python
COPY

        else:
            # New token.  Skip all the verification steps.
            self.payload = {api_settings.TOKEN_TYPE_CLAIM: self.token_type}

            # Set "exp" and "iat" claims with default value
            self.set_exp(from_time=self.current_time, lifetime=self.lifetime)
            self.set_iat(at_time=self.current_time)

            # Set "jti" claim
            self.set_jti()
else에서는 JWT토큰의 Payload부분을 채우는 과정이 담겨있다.
먼저 토큰타입을 추가하고 set_exp, set_iat, set_jti 메서드를 각각 호출하는데
exp(expiration) --> 만료시간
python
COPY

    def set_exp(self, claim: str = "exp", from_time: Optional[datetime] = None, lifetime: Optional[timedelta] = None,) -> None:
        ...
        self.payload[claim] = datetime_to_epoch(from_time + lifetime)
iat(issued at) --> 발급시간
python
COPY

    def set_iat(self, claim: str = "iat", at_time: Optional[datetime] = None) -> None:
        ...
        self.payload[claim] = datetime_to_epoch(at_time)
jti(JWT id) --> 토큰 고유 아이디
python
COPY

    def set_jti(self) -> None:
        ...
        self.payload[api_settings.JTI_CLAIM] = uuid4().hex
메서드 이름만 봐도 무슨 동작을 하는지 쉽게 유추할 수 있다.
이렇게 Token클래스의 __init__매직메서드 마지막 부분에서는 Payload부분 데이터를 채우는 과정이 담겨있다.
조금 길었지만 이제 드디어 for_user 클래스메서드의 token=cls()과정이 끝났고 token변수에는 생성된 RefreshToken클래스의 객체가 할당된다.
python
COPY

    @classmethod
    def for_user(cls, user: AuthUser) -> "Token":
        """
        Returns an authorization token for the given user that will be provided
        after authenticating the user's credentials.
        """
        user_id = getattr(user, api_settings.USER_ID_FIELD)
        if not isinstance(user_id, int):
            user_id = str(user_id)

        token = cls()
        token[api_settings.USER_ID_CLAIM] = user_id

        if api_settings.CHECK_REVOKE_TOKEN:
            token[api_settings.REVOKE_TOKEN_CLAIM] = get_md5_hash_password(
                user.password
            )

        return token
마지막으로 토큰 Payload에 USER_ID_CLAIM값으로 user_id값을 추가한다.
이제 서버입장에서 JWT토큰이 주어졌을때 해당 토큰을 해독한뒤 페이로드 부분에 user_id값을 보면 어떤 유저의 토큰인지 알 수 있게된다.
 
그리고 CHECK_REVOKE_TOKEN은 토큰이 취소되었는지의 여부를 확인하기 위한 값인데 기본값으로 False이다.
이렇게 RefreshToken이 생성되고 다시 TokenObtainPairSerializer로 돌아와 refresh변수값이 된다.
잠깐의 과정을 되짚어보면 사실 RefreshToken클래스는 RefreshToken의 특징이 될 속성만 가지고(token_type, lifetime ...)
Token클래스의 __init__매직메서드를 통해 기본적인 JWT토큰을 생성하는 과정을 거쳤다.
하지만 생각해보면 Token클래스에서 JWT토큰을 생성했다기 보단 JWT토큰의 구성요소인 Payload부분을 완성했다고 볼 수 있다.
그럼 도대체 언제 우리가 응답받은 복잡한 문자열형태인 JWT토큰을 생성하는걸까?
 

Token 인코딩

 
python
COPY

class TokenObtainPairSerializer(TokenObtainSerializer):
    token_class = RefreshToken

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]:
        data = super().validate(attrs)

        refresh = self.get_token(self.user)

        data["refresh"] = str(refresh)
        data["access"] = str(refresh.access_token)
        print(data["refresh"], 'it is refresh turn')

        if api_settings.UPDATE_LAST_LOGIN:
            update_last_login(None, self.user)

        return data
refresh토큰을 생성한 이후 코드를 보면 data객체에 "refresh", "access"키로 str함수를 호출하며 인자로 refresh를 전달하는걸 확인할 수 있는데 이 부분이 놀랍게도 진짜 JWT토큰을 만드는 과정이 된다.
 
변수 refresh가 어떤 값을 가지고 있는지 우리는 알 수 있다.
RefreshToken클래스의 객체를 담고 있는데 str()함수를 호출하기 때문에 해당 클래스의 __str__매직메서드를 호출한다는 뜻이다.
그렇다면 RefreshToken의 __str__매직메서드를 확인해보면 된다.
하지만 RefreshToken에는 __str__매직메서드가 구현되어있지 않기 때문에 부모인 Token클래스의 __str__매직메서드를 호출한다.
python
COPY

class Token:
    ...
    def __str__(self) -> str:
        """
        Signs and returns a token as a base64 encoded string.
        """
        return self.get_token_backend().encode(self.payload)
    ...
Token클래스의 __str__매직메서드의 반환값은 get_token_backend()를 먼저 호출하고 리턴받은 오브젝트의 encode메서드를 호출하며 인자로 Payload를 전달하는 형식인데 일단 먼저 get_token_backend함수를 살펴보면 _token_backend속성의 getter함수 역할을 하는 함수라는걸 알 수 있다.
python
COPY

    @property
    def token_backend(self) -> "TokenBackend":
        if self._token_backend is None:
            self._token_backend = import_string(
                "rest_framework_simplejwt.state.token_backend"
            )
        return self._token_backend

    def get_token_backend(self) -> "TokenBackend":
        # Backward compatibility.
        return self.token_backend
그뜻은 결론적으로 반환되는값은 _token_backend속성인데 해당 값은 None일때 기본값으로 "rest_framework_simplejwt.state.token_backend"를 가지게 된다.
이제는 위같은 패턴이 어떻게 동작하는지 알고있다.
python
COPY

token_backend = TokenBackend(
    api_settings.ALGORITHM,
    api_settings.SIGNING_KEY,
    api_settings.VERIFYING_KEY,
    api_settings.AUDIENCE,
    api_settings.ISSUER,
    api_settings.JWK_URL,
    api_settings.LEEWAY,
    api_settings.JSON_ENCODER,
)
token_backend는 TokenBackend클래스를 생성하며 여러 settings값들을 인자로 보내주고 있는데 위와 같은 여러 세팅값들에 대한 설명은 simple-jwt공식문서 settings페이지를 통해 자세히 확인할 수 있다.
일단은 간단하게 설명하자면 위 TokenBackend는 토큰에 적용되는 디테일한 설정값이라고 생각하면된다.
토큰에 어떤 알고리즘을 적용할지 서명 키로는 어떤 값을 사용할지 생성된 토큰을 어떻게 확인해야할지(해독)또 토큰의 만료기간에 여유를 조금 준다던지 등등 토큰에 대한 디테일한 설정을 다루는 클래스라고 생각하면 된다.
python
COPY

class Token:
    ...
    def __str__(self) -> str:
        """
        Signs and returns a token as a base64 encoded string.
        """
        return self.get_token_backend().encode(self.payload)
    ...
Token클래스의 __str__매직메서드는 get_token_backend()반환값으로 TokenBackend클래스의 객체를 생성하며 해당 객체의 encode메서드를 호출하는걸 알 수 있다. 그리고 인자로 self.payload를 전달한다.
python
COPY

    def encode(self, payload: Dict[str, Any]) -> str:
        """
        Returns an encoded token for the given payload dictionary.
        """
        jwt_payload = payload.copy()
        if self.audience is not None:
            jwt_payload["aud"] = self.audience
        if self.issuer is not None:
            jwt_payload["iss"] = self.issuer

        token = jwt.encode(
            jwt_payload,
            self.signing_key,
            algorithm=self.algorithm,
            json_encoder=self.json_encoder,
        )
        if isinstance(token, bytes):
            # For PyJWT <= 1.7.1
            return token.decode("utf-8")
        # For PyJWT >= 2.0.0a1
        return token
위 코드는 TokenBackend클래스의 encode메서드다. 먼저 payload.copy()를 통해 encode함수를 호출할때 인자로 보낸 self.payload를 복사한다. 그리고 token = jwt.encode()로 PyJWT라이브러리의 함수를 사용하여 우리가 응답받을 jwt token을 생성한다.
no-img
여기서 pip list를 통해 설치된 패키지들을 확인해보면 simplejwt만 설치했을 뿐인데 PyJWT도 설치되어있는걸 확인할 수 있다.
이로써 simplejwt는 JWT토큰을 생성하는 역할이라기 보단 Django REST framework환경에서 JWT토큰을 만들기위한 설정을 편하게 하고 일반적인 토큰 형태를 제공해주며 로직까지 구현된 View를 제공해주는 패키지인 것이다.
 
jwt.encode함수 호출부분에서 매개변수로 전달된 값들을 보면 익숙하게 느껴질 부분들이 있을거라고 생각한다.
가장 먼저 jwt_payload는 Token클래스의 __init__매직메서드에서 JWT토큰의 Payload부분이 될 여러값들을 채웠으며 JWT토큰의 데이터를 담는 Payload부분이 된다. (user_id, exp, iat, jti ...)
python
COPY

token_backend = TokenBackend(
    api_settings.ALGORITHM,
    api_settings.SIGNING_KEY,
    api_settings.VERIFYING_KEY,
    api_settings.AUDIENCE,
    api_settings.ISSUER,
    api_settings.JWK_URL,
    api_settings.LEEWAY,
    api_settings.JSON_ENCODER,
)
그리고 나머지 self.signing_key, self.algorithm, self.json_encoder는 TokenBackend클래스를 만들때 우리가 전달해준 인자중 하나이며 signing_key는 말 그대로 서명키로 JWT토큰의 Signature값이 되고 algorithm은 토큰에 적용될 암호화 알고리즘이 무엇인지를 결정한다.
(위와 같은 내용은 JWT토큰의 구조에 대해 어느정도 알아야한다.)
python
COPY

class Token:
    ...
    def __str__(self) -> str:
        """
        Signs and returns a token as a base64 encoded string.
        """
        return self.get_token_backend().encode(self.payload)
    ...
이렇게 Token클래스의 __str__매직메서드는 JWT토큰을 반환한다.
그렇다면 access토큰은 어떻게 발급하는걸까??
 

access토큰 발급하기

python
COPY

class TokenObtainPairSerializer(TokenObtainSerializer):
    token_class = RefreshToken

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]:
        ...
        data["refresh"] = str(refresh)
        data["access"] = str(refresh.access_token)
        ...
        return data
access토큰은 더 간단하다. JWT토큰의 개념상 access토큰을 발급하는건 refresh토큰의 역할이다.
그렇기 때문에 refresh토큰에 access_token이라는 메서드를 통해 access_token을 생성할 수 있다.
python
COPY

class RefreshToken(BlacklistMixin, Token):
    ...
    access_token_class = AccessToken
    ....
    
    @property
    def access_token(self) -> AccessToken:
        """
        Returns an access token created from this refresh token.  Copies all
        claims present in this refresh token to the new access token except
        those claims listed in the `no_copy_claims` attribute.
        """
        access = self.access_token_class()

        # Use instantiation time of refresh token as relative timestamp for
        # access token "exp" claim.  This ensures that both a refresh and
        # access token expire relative to the same time if they are created as
        # a pair.
        access.set_exp(from_time=self.current_time)

        no_copy = self.no_copy_claims
        for claim, value in self.payload.items():
            if claim in no_copy:
                continue
            access[claim] = value

        return access
정확히는 access_token의 설정값이 담겨있는 AccessToken클래스의 객체를 생성하며 str()함수로 refresh토큰과 같은 과정을 거쳐 JWT토큰이 된다.
이렇게 TokenObatinPairSerializer가 data객체에 "refresh", "access"키 값으로 각 JWT토큰값을 가지며 data객체를 반환함으로써 길고 길었던 serializer의 validate과정이 끝나게 된다.
python
COPY

class TokenViewBase(generics.GenericAPIView):
        ...
        def post(self, request: Request, *args, **kwargs) -> Response:
        serializer = self.get_serializer(data=request.data)

        try:
            serializer.is_valid(raise_exception=True)
        except TokenError as e:
            raise InvalidToken(e.args[0])

        return Response(serializer.validated_data, status=status.HTTP_200_OK)
지금까지 validate과정은 serializer.is_valid(raise_exception=True)이 코드 한 줄을 시작으로 username, password를 통해 유저를 파악하고 토큰관련 설정값을 설정하고 PyJWT를 통해 토큰을 생성하는 과정을 거쳐왔다.
 
그리고 드디어 별다른 문제가 없었다면 TokenObatinPairSerializer의 validate함수가 성공적으로 마무리되면서 반환된 data객체는 serializer.validated_data에 담기게되고 해당값을 Response로 반환하기 때문에 아래와같은 응답을 HTTP Response Message로 받을 수 있었던 것이다.
no-img
simplejwt의 Getting started를 보며 따라했을때는 간단한 설정 몇가지만 추가했을뿐인데 쉽게 로직을 구현할 수 있었다.
이제는 이 모든것이 simplejwt내부에 존재하는 코드들의 도움으로 가능했다는걸 알았다.
simplejwt의 소스코드를 보며 여러 설정값을 다룰때 api_settings에서 값을 받아왔는데 해당 설정을 커스텀하려면 본인의 Django프로젝트 settings.py에 아래와 같은 값을 오버라이딩 하고 본인 프로젝트 상황에 맞게 값들을 커스터마이징 하면 된다.
python
COPY


SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": datetime.timedelta(hours=1),
    "REFRESH_TOKEN_LIFETIME": datetime.timedelta(days=14),
    "ROTATE_REFRESH_TOKENS": False,
    "BLACKLIST_AFTER_ROTATION": False,
    "UPDATE_LAST_LOGIN": False,

    "ALGORITHM": "HS256",
    "SIGNING_KEY": SECRET_KEY,
    "VERIFYING_KEY": "",
    "AUDIENCE": None,
    "ISSUER": None,
    "JSON_ENCODER": None,
    "JWK_URL": None,
    "LEEWAY": 0,

    "AUTH_HEADER_TYPES": ("Bearer",),
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
    "USER_ID_FIELD": "id",
    "USER_ID_CLAIM": "user_id",
    "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",

    "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
    "TOKEN_TYPE_CLAIM": "token_type",
    "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",

    "JTI_CLAIM": "jti",

    "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
    "SLIDING_TOKEN_LIFETIME": datetime.timedelta(minutes=5),
    "SLIDING_TOKEN_REFRESH_LIFETIME": datetime.timedelta(days=1),

    "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
    "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
    "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
    "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
    "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
    "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
}
만약 처음부터 위 코드를 보며 토큰을 커스텀하려 했다면 머리가 멍해질 수 있겠지만
지금까지의 과정을 토대로 simplejwt가 돌아가는 과정을 어느정도 이해했다면 키값 몇개정도는 익숙하게 느껴질 수 있으며 어떻게 커스터마이징 해야하는지 감이 잡힐거라고 생각한다.