[Django/DRF] JWT 사용 시 refresh token을 secure cookie로 보내는 방법

조오닭·2024년 12월 10일
0

서비스를 출시하려면 https 적용이 필수다.
그리고 로그인에 관련해서도 https와 연관지어 좀 더 안전하게 처리 해야하는데...
우리는 로그인을 JWT 방식으로 처리하기 때문에 refresh token을 secure cookie에 넣을 필요가 있었다.

오늘은 이론보다, 로그인할 때와 access token을 갱신할 때의 코드를 말해보겠다!
참고로 내 프로젝트 내 DRF에서 사용되는 로그인 라이브러리는...
allauth(소셜 로그인)djangorestframework-simplejwt(jwt 적용) 이다.


cookie_max_age = 3600 * 24 * 1

class setSecureCookieView(APIView):
	refresh_token = ""
	response = Response(status=status.HTTP_200_OK)
  	response.set_cookie(
      	key='refresh_token',
      	value=refresh_token,
      	httponly=True,
        secure=True,
        max_age=cookie_max_age,
   	    samesite='Lax',
   	    domain='.mysite.com'
    )
	return response

DRF에서는 return 객체를 Response를 주로 사용한다.
이때 Response 객체에 set_cookie를 통해 설정하면 된다.

  • key - 쿠키에 들어갈 이름이다.
  • value - 해당 쿠키에 들어갈 실질적인 값이다. 나는 refresh token을 넣어주었다.
  • httponly - 스크립트 접근을 차단할 수 있도록 하는 보안 설정이다.
  • secure - HTTPS 연결에만 쿠키를 전송하도록 한다. 이것이 곧 secure cookie 설정!
  • max_age - 쿠키의 만료 시간을 초 단위(second)로 계산하여 설정한다.
  • samesite - 쿠키를 요청한 사이트에 제약을 둔다. Strict, Lax, None을 주로 사용한다.
  • domain - 쿠키가 적용되는 도메인을 설정한다.

2. [응용] 소셜 로그인 (OAuth)


from allauth.socialaccount.providers.apple import views as apple_view

# 소셜 로그인 - callback api
class AppleCallbackAPIView(APIView):
    permission_classes = (AllowAny,)
    def get(self, request, *args, **kwargs):
    	# 생략된 부분 - 로그인 처리 로직
        view = AppleLogin.as_view()
        accept = view(request)
        accept_data = accept.data  # client에 보낼 로그인 성공 데이터
        refresh_token = accept_data.pop("refresh_token")
        # secure cookie 설정
		response = Response(accept_data, status=status.HTTP_200_OK)
        response.set_cookie(
        	key='refresh_token',
            value=refresh_token,
            httponly=True,
            secure=True,
            max_age=cookie_max_age,
            samesite='Lax',
            domain='.mysite.com'
		)
        return response

# 소셜 로그인 - 로그인 완료 api
class AppleLogin(SocialLoginView):
    adapter_class = apple_view.AppleOAuth2Adapter
    callback_url = APPLE_CALLBACK_URI
    client_class = OAuth2Client
[전체적으로 이해하기 위해선 allauth 라이브러리를 사용해야 한다.]

DRF, allauth에서 OAuth를 처리하기 위해선 두 가지 API 클래스로 나눠야 한다.

Callback apiLogin api로, 용도가 조금 다르다.

  • Callback api(AppleCallbackAPIView)
    • client(FE)에서 받은 code가 유효한 지 검증하고 사용자의 정보를 가져옴.
    • 해당 사용자가 회원가입 대상자인지 혹은 회원인지 판별.
    • 로그인을 완료시켜 JWT의 경우 access와 refresh token을 client에게 다시 전송.
  • Login api(AppleLogin)
    • 회원의 로그인 처리를 돕는 역할
    • 회원 정보와 allauth 라이브러리의 연결점. (site 관리, oauth 토큰 관리 등)
    • 사용자의 로그인 정보(회원정보, access/refresh token)를 반환

위의 설명을 보면 알 수 있듯이,
client에게 전달해줄 refresh token은 Login api(AppleLogin)에 있다.
따라서 Login api를 호출하여 받은 데이터 중 refresh_token을 추출한다.

view = AppleLogin.as_view()
accept = view(request)
accept_data = accept.data  # client에 보낼 로그인 성공 데이터
refresh_token = accept_data.pop("refresh_token")

그리고 Response 객체에 set_cookie()를 통해 refresh_token을 secure cookie로 설정하는 건 동일하다.

3. [응용] Access token refresh


[전체적으로 이해하기 위해선 simplejwt 라이브러리를 사용해야 한다.]

이 역시 Response 객체에 secure cookie 설정하는 방법은 동일하다.
하지만 access token을 refresh할 때 simplejwt에서 제공하는 View(TokenRefreshView)와 Serializer(TokenRefreshSerializer)을 상속받아 커스텀할 필요가 있어 소개한다.

# access token 갱신 - views.py
from rest_framework_simplejwt.views import TokenRefreshView

class CustomTokenRefreshView(TokenRefreshView):
    serializer_class = CustomTokenRefreshSerializer

    def finalize_response(self, request, response, *args, **kwargs):
        if response.data.get('detail') == "Refresh Token is required":
            response = Response(status=status.HTTP_400_BAD_REQUEST)
        elif response.data.get('refresh'):
            refresh_token = response.data.pop('refresh')
            cookie_max_age = 3600*24*jwt_refresh_time.days
            response.set_cookie(
                key='refresh_token',
                value=refresh_token,
                httponly=True,
                secure=True,
                max_age=cookie_max_age,
                samesite='Lax',
                domain='.mysite.com'
            )
        return super().finalize_response(request, response, *args, **kwargs)

View에는 finalize_response()을 통해 serializer로부터 온 값을 핸들링한다.
만약 serializer로부터 'Refresh Token is required'라는 메세지가 온다면, client로부터 온 secure cookie에 담긴 refresh token이 없다는 뜻이므로 400 BAD REQUEST를 전송하였다.
반대로 serializer로부터 온 데이터 중 refresh token이 있다면, 이를 body에서 제외시키고 secure cookie로 적용해야 한다.

# access token 갱신 - serializers.py
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.serializers import TokenRefreshSerializer

class CustomTokenRefreshSerializer(TokenRefreshSerializer):
    refresh = None

    def validate(self, attrs):
        jwt_settings = getattr(settings, "SIMPLE_JWT")
        jwt_access_time = jwt_settings.get("ACCESS_TOKEN_LIFETIME")
        jwt_refresh_time = jwt_settings.get("REFRESH_TOKEN_LIFETIME")

        attrs['refresh'] = self.context['request'].COOKIES.get('refresh_token')
        if attrs['refresh']:
            data = super(CustomTokenRefreshSerializer, self).validate(attrs)
            if data.get('refresh'):
                self.refresh = data['refresh']
            data.update({
                "access_expires_at": datetime.now()+jwt_access_time,
                "refresh_expires_at": datetime.now()+jwt_refresh_time
            })
            return data
        else:
            raise InvalidToken("Refresh Token is required")

Serializer에는 client로부터 온 cookie 내 refresh token을 보고, 이를 부모 클래스의 validate()를 통해 검증한다.

참고로 Access token을 refresh하는 로직은 부모 클래스 내에 존재한다.
하지만 부모 클래스에서는 refresh token이 secure cookie가 아닌 body에 담긴 형태를 처리할 수 있기 때문에 부모 클래스에서 처리할 수 있도록 cookie 내에 있는 refresh_token을 attrs['refresh']에 담았다.

부모 클래스인 TokenRefreshSerializer을 보면 이해될 것이다.

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

4. 마무리


Refresh token을 secure cookie에 적용하는 일은 쉬웠다.

하지만 사용 중인 라이브러리가 제공하는 형태를 깨고 커스텀하는 작업은 생소했다.
DRF를 처음에 사용할 땐 python의 고급 문법(?)을 사용할 일이 없다보니 이론에 접근하는 게 조금 오버리소스가 아닌가 생각했다.
하지만 이렇게 라이브러리를 뜯어보고 상속받아서 내가 원하는 대로 결과를 이해하고 얻기 위해선 필요하다고 깨닫는 순간이었다.

(이래서 탑다운 공부법이 좋은 것 같다.)

profile
백엔드 응애

0개의 댓글

관련 채용 정보