[Django 를 이용한 사이드 프로젝트 myBox] 007. refreshToken 으로 새로운 accessToken 발급하기 (feat. simple-jwt)

토큰 유효성 검사 및 재발급의 필요

앞선 포스팅에서 simple-jwt 를 이용해 회원가입, 로그인, 로그아웃 로직을 구현해보았다. (포스팅 이동)

로그인 시 발급받은 accessToken 은 보안 상 굉장히 짧은 유효기간을 가진다. myBox 프로젝트에서는 10분으로 설정해놓았었다.

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10), # 이 부분
    "REFRESH_TOKEN_LIFETIME": timedelta(hours=1),
    "ROTATE_REFRESH_TOKENS": False,
    "BLACKLIST_AFTER_ROTATION": False,
	...
}

즉, 1) 클라이언트 요청 헤더 속 accessToken 이 만료된 토큰인지 확인해야 하는 과정이 필요하며, 2) 만료 시 쿠키에 저장된 refreshToken 정보를 통해 재발급을 해주어야 한다.

ROTATE_REFRESH_TOKENS 를 False 로 설정했기 때문에 3) access만 재발급하고 기존 refreshToken 은 건드리지 않는다.

그리고 4) 만료된 refreshToken 으로 access 재발급을 요청한다면, 401 에러를 반환하여 클라이언트에서 재로그인하도록 한다.

 

자, 그럼 4가지 요구사항을 구현해보자!

로직 구현

1) 클라이언트 요청 헤더 속 accessToken 이 만료된 토큰인지 확인

해당 작업은 따로 로직을 추가해줄 필요가 없다. 로그인, 로그아웃 로직을 구현하면서 settings.py 에 이미 JWT 인증을 설정해놨기 때문이다. API 요청이 오면 middleware가 만료여부를 판단할 것이다.

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    # 이 부분이다.
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
    ...
}

유효한 accessToken 인 경우 View 로직을 진행할 거고, 만료된 토큰이라면 401 에러를 반환한다.

만료된 accessToken 요청일 때 응답

 


2, 3) 만료 시 쿠키에 저장된 refreshToken 정보를 통해 accessToken 만 재발급

그렇다면 이젠 쿠키에 저장했던 refreshToken 을 통해 새로운 accessToken 을 발급받아야 한다.

simple-jwt 에서 역시나 기본 TokenRefreshView, TokenRefreshSerializer 를 제공한다.

View

TokenRefreshView 를 보면 로그인포스팅에서 보았던 TokenObtainPairView 등과 동일하게 TokenViewBase 를 상속하고 있다.

그럼 뭐다? TokenViewBase의 post 메서드를 오버라이딩하자!

class TokenRefreshView(TokenViewBase):
    """
    Takes a refresh type JSON web token and returns an access type JSON web
    token if the refresh token is valid.
    """

    _serializer_class = api_settings.TOKEN_REFRESH_SERIALIZER


token_refresh = TokenRefreshView.as_view()

JWTRefreshView 를 만들고 TokenRefreshView 를 상속했다.

serializer_class 에는 밑에서 구현할 JWTRefreshSerializer 를 주었고,

쿠키의 refreshToken 정보를 serializer 에게 주고, JsonResponse 를 반환하도록 post 메서드를 오버라이드했다.

class JWTRefreshView(TokenRefreshView):
    serializer_class = JWTRefreshSerializer

    def post(self, request: Request, *args, **kwargs) -> JsonResponse:
        token_data = {"refresh": request.COOKIES.get("refresh", "")}
        serializer = self.get_serializer(data=token_data)

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

        json_response = JsonResponse(
            {
                "code": status.HTTP_200_OK,
                "message": "new accessToken",
                "token": {"access": serializer.validated_data["access"]},
            },
            status=status.HTTP_200_OK,
        )
        return json_response

Serializer

TokenRefreshSerializer 는 refresh 를 body 로 받아 새로운 accessToken 을 만들고 refresh, access 토큰 정보를 반환한다.

이 때 ROTATE_REFRESH_TOKENS 가 True 이면 refresh 토큰 또한 재발급한다. 이때 BLACKLIST_AFTER_ROTATION 가 True 이면 기존 refreshToken 을 블랙리스트 처리한다.

하지만 이번 프로젝트 세팅은 둘다 False 이므로 access 토큰만 재발급한다.

class TokenRefreshSerializer(serializers.Serializer):
    refresh = serializers.CharField()
    access = serializers.CharField(read_only=True)
    token_class = RefreshToken

    def validate(self, attrs: Dict[str, Any]) -> Dict[str, str]:
        refresh = self.token_class(attrs["refresh"])

        data = {"access": str(refresh.access_token)}

        if api_settings.ROTATE_REFRESH_TOKENS:
            if api_settings.BLACKLIST_AFTER_ROTATION:
                try:
                    # Attempt to blacklist the given refresh token
                    refresh.blacklist()
                except AttributeError:
                    # If blacklist app not installed, `blacklist` method will
                    # not be present
                    pass

            refresh.set_jti()
            refresh.set_exp()
            refresh.set_iat()

            data["refresh"] = str(refresh)

        return data

JWTRefreshView 는 TokenRefreshView 를 상속해 만들었다.

로그아웃View 인 JWTLogoutView 와 마찬가지로 refreshToken 을 쿠키에서 가져오기 때문에 refresh 값에 대한 required 를 False 로 변경했다.

class JWTRefreshSerializer(TokenRefreshSerializer):
    refresh = serializers.CharField(required=False, write_only=True)

 

urls.py

refresh 관련된 url 을 추가해주자

urlpatterns = [
    path("signup/", SignUpView.as_view(), name="signup"),
    path("login/", JWTLoginView.as_view(), name="login"),
    path("logout/", JWTLogoutView.as_view(), name="logout"),
    path("token/refresh/", JWTRefreshView.as_view(), name="token_refresh"), # 추가
]

테스트

drf-spectacular 를 적용했기 때문에 요청이 가능한 API docs 가 있다. 그걸 이용해 테스트할거다.

테스트를 위해 우선 settings.py 에서 accessToken 과 refreshToken 의 유효기간을 짧게 조정해주었다.

SIMPLE_JWT = {
    # "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10),
    # "REFRESH_TOKEN_LIFETIME": timedelta(hours=1),
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=1),
    "REFRESH_TOKEN_LIFETIME": timedelta(minutes=5),
    "ROTATE_REFRESH_TOKENS": False,
    "BLACKLIST_AFTER_ROTATION": False,
    ...
}

먼저, api/v1/schema/swagger-ui 로 접속해 로그인 요청하고 access, refresh 토큰을 얻어보자

email, password 로 login 요청
로그인 성공으로 얻은 accessToken

캡쳐는 못했지만 쿠키에 refreshToken 이 저장되어 있다.

이제 얻은 accessToken 을 복사하여 우측 상단의 Authorize 버튼을 눌러 인증을 진행하자

accessToken 으로 인증시도
인증 성공

 

캡쳐의 하단을 보면 쿠키에 refresh 정보가 저장되어 있다. 이를 이용해 새로운 accessToken 을 요청했다.

refresh 요청

200 코드와 함께 정상적으로 새로운 accessToken 이 응답에 포함되어 있음을 확인할 수 있다.

만료된 refreshToken 으로 refresh 요청을 한다면, 아래처럼 401 에러를 반환한다. 이를 보고 클라이언트는 로그인 화면으로 사용자를 이동시켜야 한다.

이 때 만료된 쿠키에 대해서는 클라이언트 쪽에서 삭제하거나 다른 방식으로 관리할테니 서버 측에서는 blacklist 처리할 필요가 없다.

 

top
bottom