web/django

Django Access Token 재발급 API

민사민서 2024. 5. 20. 20:54

토큰에는 두 종류

- access token (유효기간 짧은, 인가용 token)

- refresh token (유효기간 긴, access token 재발급용 token)

 

토큰 재발급 과정

  1. 로그인 인증에 성공한 클라이언트는 Refresh Token과 Access Token 두 개를 서버로부터 받는다.
  2. 클라이언트는 Refresh Token과 Access Token을 로컬에 저장해놓는다.
  3. 클라이언트는 헤더에 Access Token을 넣고 API 통신을 한다. (Authorization)
  4. 일정 기간이 지나 Access Token의 유효기간이 만료되었다.
    1. Access Token은 이제 유효하지 않으므로 권한이 없는 사용자가 된다.
    2. 클라이언트로부터 유효기간이 지난 Access Token을 받은 서버는 401 (Unauthorized) 에러 코드로 응답한다.
    3. 401를 통해 클라이언트는 invalid_token (유효기간이 만료되었음)을 알 수 있다.
  5. Access Token 대신 Refresh Token을 넣어 API를 재요청한다.
  6. Refresh Token으로 사용자의 권한을 확인한 서버는 응답쿼리 헤더에 새로운 Access Token을 넣어 응답한다.
  7. 만약 Refresh Token도 만료되었다면 서버는 동일하게 401 error code를 보내고, 클라이언트는 재로그인해야한다.

Access Token 재발급 API

class TokenRefreshRequestSerializer(serializers.Serializer):
    refresh = serializers.CharField()

accounts/request_serializer.py 에 serializer 추가

 

class TokenRefreshView(APIView):
    @swagger_auto_schema(
        operation_id="토큰 재발급",
        operation_description="access 토큰을 재발급 받습니다.",
        request_body=TokenRefreshRequestSerializer,
        responses={200: UserProfileSerializer},
    )
    def post(self, request):
        refresh_token = request.data.get("refresh")
        
        if not refresh_token:
            return Response(
                {"detail": "no refresh token"}, status=status.HTTP_400_BAD_REQUEST
            )

        try:
            RefreshToken(refresh_token).verify()
        except:
            return Response(
                {"detail": "please signin again."}, status=status.HTTP_401_UNAUTHORIZED
            )
            
        new_access_token = str(RefreshToken(refresh_token).access_token)
        response = Response({"detail": "token refreshed"}, status=status.HTTP_200_OK)
        response.set_cookie("access_token", value=str(new_access_token), httponly=True)
        return response

 

- 사용자의 요청 body로부터 refresh 토큰의 값을 가져옴, 그리고 그 값이 없을 경우 400 에러를 반환

- refresh_token이 유효하지 않은 경우, ValidationError가 발생하여 except 구문이 동작하고, 401 에러를 반환

(refresh_token이 유효하지 않다는 것은 사용자가 로그아웃된 상태라는 것을 의미하기 때문)

- 마지막으로, refresh_token이 유효한 상태인 것을 확인하면 새로운 access_token을 발급하고, 이를 쿠키에 담아서 사용자에게 응답으로 보냄

 

from django.urls import path
from .views import SignUpView, SignInView, TokenRefreshView

app_name = 'account'
urlpatterns = [
    # CBV url path
    path("signup/", SignUpView.as_view()),
    path("signin/", SignInView.as_view()),
    path("refresh/", TokenRefreshView.as_view()),
]

accounts/urls.py 에 추가

 

cf) verify 함수의 내부 로직

BlacklistMixin class 내의 verify 함수의 생김새는 아래와 같다

if "rest_framework_simplejwt.token_blacklist" in settings.INSTALLED_APPS:

    def verify(self, *args, **kwargs) -> None:
        self.check_blacklist()

        super().verify(*args, **kwargs)  # type: ignore

    def check_blacklist(self) -> None:
        """
        Checks if this token is present in the token blacklist.  Raises
        `TokenError` if so.
        """
        jti = self.payload[api_settings.JTI_CLAIM]

        if BlacklistedToken.objects.filter(token__jti=jti).exists():
            raise TokenError(_("Token is blacklisted"))

verify 함수에선 check blacklist 함수가 실행되고, 여기서 만약 건네 받은 token 이 저희 sqlite 데이터베이스 내의 blacklist 에 저장되어있으면 Token Error 가 발생

 

Refresh Token 보안 문제?

- 로그아웃에서 가장 중요한 로직은 바로 refresh token을 더이상 사용할 수 없도록 관리해 주는 것입니다.

- refresh token은 유효기간이 상당히 긴 편입니다. (어떤 서비스에서는 연 단위로 설정하기도 합니다…)

- 만약에 그 상태에서 누군가가 refresh token을 훔쳐가게 되면 이를 이용해서 access token을 발급받고 명의를 도용하는 등 보안상 위험한 일이 발생할 수도 있습니다.

- 그렇기 때문에 사용자가 로그아웃을 하면, 사용자가 쓰던 refresh token을 누군가가 사용할 수 없도록 안전하게 관리해야 하고, 이를 위해서 blacklist라는 것을 사용하게 됩니다

- 단순히 프론트에서 쿠키를 삭제하는 것으로만 로그아웃을 구현한다면, 로그인 당시 발급 되었던 토큰을 누군가가 탈취해서 다른 사람이 악용하고자 했을 때 백엔드는 이 유저가 로그아웃 했다는 사실을 알 수 있는 방법이 없음

 

- 일반적인 상황에서는 data 에 담아서 보내지 않는 refresh token 을 로그아웃을 하는 경우에 요청의 data 에 넣어서 보냄

- 백엔드에서는 전달받은 refresh token을 더 이상 사용하지 않는 BlackList에 등록!!

 

logout/ 이라는 URI로 refresh token을 담아 POST 요청을 보내면 대응되는 refresh token 삭제하도록 구현 ㄱㄱ

class LogoutRequestSerializer(serializers.Serializer):
    refresh = serializers.CharField()
class LogoutView(APIView):
    @swagger_auto_schema(
        operation_id="로그아웃",
        operation_description="refresh token을 삭제합니다.",
        request_body=LogoutRequestSerializer,
        responses={204: "No Content", 400: "Bad Request", 401: "Unauthorized"},
    )
    def post(self, request):
        refresh_token = request.data.get("refresh")

        if not refresh_token:
            return Response(
                {"detail": "no refresh token"}, status=status.HTTP_400_BAD_REQUEST
            )

        try:
            RefreshToken(refresh_token).verify()
        except:
            return Response(
                {"detail": "please signin"}, status=status.HTTP_401_UNAUTHORIZED
            )
        
        RefreshToken(refresh_token).blacklist()
        return Response(status=status.HTTP_204_NO_CONTENT)

이런 API를 추가로 만들었다