Pre-requirements
JWT 인증과정
JWT는 JSON Web token의 약자로, JSON 객체형태로 정보를 안전하게 전송하는 웹 토큰이다(자세한 설명은 여기).

JWT도 토큰 기반 인증 방식이기에 인증 과정도 토큰 방식과 동일하다. JWT 내부에 디지털 서명이 포함되어 이 서명이 유효하다면 유저의 권한에 따라 응답을 반환한다. JWT이 어떤 과정을 통해 공격자(해커)에게 탈취된다면 공격자가 유저를 가장하고 서버에 접근할 수도 있다. 이를 방지하기 위해 JWT은 주로 몇 분에서 몇 시간정도의 짧은 만료기간을 가진다. 이는 유저에게 주기적으로 토큰을 재발급받아야 하는 번거로움을 준다.
Refresh 토큰
토큰 재발급 과정을 단순화하기 위해 토큰 기반 인증 방식은 접근을 위한 Access 토큰과 Access 토큰의 재발급을 위한 Refresh 토큰을 최초 인증 과정에서 발급하기도 한다.

JWT 저장 및 관리
발급된 JWT은 클라이언트에 저장된다. 클라이언트는 JWT를 안전하게 관리하기 위해 다양한 전략을 사용할 수 있다.
Web Storage
클라이언트가 웹인 경우, 브라우저는 Web Storage라는 저장 공간을 가진다. Web Storage에는 localStorage와 sessionStorage가 있고, 모두 key-value형태로 데이터를 관리한다. Javascript로 손쉽게 접근가능에 XSS에 취약하다는 단점이 있다. 짧은 만료기한을 가지는 Access 토큰을 저장할 수 있겠지만, 긴 만료기한을 가지는 Refresh 토큰은 저장하기에 적합하지 않다.
localStorage.setItem("localKey", "my local value");
let localData = localStorage.getItem("localKey");
sessionStorage.setItem("sessionKey", "my session value");
let sessionData = sessionStorage.getItem("sessionKey")
Http Cookie
Http Cookie는 Http 요청(Request) 시 같이 전송하는 작은 데이터이다. 쿠키는 보통 서버에서 응답(Response)와 함께 전송되며, 클라이언트는 전달받은 쿠키에 데이터를 저장했다가 동일한 서버에 대해 요청(Request) 시 쿠키를 함께 전송한다. 쿠키 역시 Javascript로 손쉽게 접근가능하기에 XSS에 취약하고, CSRF을 통해 공격자의 서버로 쿠키가 전달 될 수도 있다.
document.cookie = "yummy_cookie=choco";
document.cookie = "tasty_cookie=strawberry";
쿠키에는 다양한 옵션을 적용할 수 있어 이런 공격들에 대해 어느정도 대응할 수 있다. 만료기한을 적용해 자동으로 쿠키를 삭제하거나, 쿠키의 전송 여부를 요청 경로나 도메인에 따라 제한할 수도 있다. 특히, 쿠키에 HttpOnly 옵션을 적용하면 브라우저에서 Js를 통해 열람할 수 없어 보안을 높일 수 있다.
구현 목적
이 포스팅은 drf 프로젝트에서 JWT 인증 방식을 도입하는 과정을 다룬다. Refresh 토큰을 HttpOnly 쿠키를 통해 클라이언트에 전달하고, 재발급 요청 시 쿠키가 서버로 전달되도록 구현하고자 한다. 이를 위해 drf에 사용할 수 있는 django-rest-framework-simplejwt 라이브러리를 일부 수정할 것이다.
수정 사항
django-rest-framework-simplejwt는 MIT 라이센스이기에 수정하여 사용할 수 있다.

위의 그림은 django-rest-framework-simplejwt의 기본 인증 과정을 나타냈다. JWT와 관련된 요청 시 발급된 토큰을 모두 응답의 body에 담아 반환한다. 이는 클라이언트에서 Refresh 토큰을 관리하기 까다로울 수 있어 Refresh 토큰은 서버에서 HttpOnly 쿠키를 통해 전송하도록 변경할 것이다.

Requirements
- Django==4.2.5
- djangorestframework==3.14.0
- djangorestframework-simplejwt==5.3.0
로그인
rest_framework_simplejwt/views.py

django-rest-framework-simplejwt는 TokenViewBase라는 GrenericAPIView를 부모 클래스로 하는 여러 view들로 이뤄져 있다. 각 view들은 그에 대응하는 serializer를 참조하고 있다.
# rest_framework_simplejwt/views.py
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)
소스코드를 보면 get_serializer를 통해 serializer를 가져온 뒤, serializer의 is_valid()를 통해 요청의 데이터를 검증한다. 검증에 실패하면 예외를 발생시키고 그에 따른 응답을 반환한다. 검증이 통과되면 정상 응답을 반환한다. 결국 serializer에 담겨 있는 data가 어떤 것인지 확인해볼 필요가 있다.
rest_framework_simplejwt/serializers.py

django-rest-framework-simplejwt의 serializer들도 TokenObtainSerializer를 부모 클래스로 하는 여러 자식 serializer들로 이뤄져 있다. 조금 다른 점은, 각 자식 serializer들이 validate()를 override하여 구현되어 있다는 점이다.
TokenObtainSerializer의 validate()는 초기 요청에 담기는 유저 식별 정보를 검증하는 함수로, 토큰에 대한 유효성 검사는 하지 않는다. 따라서, 각 토큰 유형에 따라 serializer도 다르게 구현하고, 해당 토큰에 대한 검사를 가진 validate()가 구현되어 있다.
# rest_framework_simplejwt/serializers.py
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()를 통해 유저 식별 정보를 검증하고, 해당 정보를 토대로 JWT를 발급한다. 이때, Refresh 토큰과 Access 토큰을 serializing하여 그대로 전달한다.
구현
serializer에서 전달되는 데이터가 Refresh 토큰과 Access 토큰임을 알았으니 이를 쿠키로 저장하게 변경하도록 한다.
class LogInView(generics.GenericAPIView):
permission_classes = ()
authentication_classes = ()
serializer_class = JWTLogInSerializer
def post(self, request):
account_serializer = self.get_serializer(data=request.data)
try:
account_serializer.is_valid(raise_exception=True)
except TokenError as e:
raise InvalidToken(e.args[0])
data = account_serializer.validated_data
refresh_token = data.pop('refresh')
res = Response(data, status=status.HTTP_200_OK)
return set_refresh_cookie(res, refresh_token)
# urls.py
urlpatterns = [
...,
path('api/auth/login/', LogInView.as_view(), name="로그인"),
...,
]
set_refresh_cookie()
cookie를 갱신해주는 함수를 따로 구현하였다. 쿠기의 만료기한은 Refresh 토큰의 만료기한과 동일하게 설정하였다. path는 '/api/auth'로 하여 인증에 대한 요청에만 쿠키가 전송되도록 하였다.
from rest_framework.response import Response
from rest_framework_simplejwt.settings import api_settings
def set_refresh_cookie(response: Response, refresh_token: str) -> Response:
response.set_cookie(
key='refresh',
value=refresh_token,
path='/api/auth',
httponly=True,
# secure=True,
max_age=api_settings.REFRESH_TOKEN_LIFETIME)
return response
토큰 재발급
토큰이 어떻게 재발급되는지 확인하기 위해 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
요청 데이터의 "refresh"의 값을 가져와 토큰 객체를 생성하고, 새로운 Access 토큰을 발급한다. 이를 구현과정에서 활용하도록 한다.
구현
Refresh 토큰이 쿠키로 이동했기 때문에 쿠키를 읽어내는 과정이 필요하다. 요청의 쿠키를 확인하여 쿠키에 Refresh 토큰이 없거나, 토큰이 유효하지 않다면 401 응답을 반환한다.
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import RefreshToken
class RefreshView(views.APIView):
def post(self, request):
if 'refresh' not in request.COOKIES:
return Response(data={"message": "refresh 토큰이 없습니다."}, status=status.HTTP_401_UNAUTHORIZED)
try:
refresh_token = RefreshToken(request.COOKIES['refresh'])
except InvalidToken as e:
return Response(data={"message": "유효하지 않은 refresh 토큰입니다."}, status=status.HTTP_401_UNAUTHORIZED)
res = Response({"access": str(refresh_token.access_token)}, status=status.HTTP_200_OK)
if api_settings.ROTATE_REFRESH_TOKENS:
if api_settings.BLACKLIST_AFTER_ROTATION:
try:
refresh_token.blacklist()
except AttributeError:
pass
refresh_token.set_jti()
refresh_token.set_exp()
refresh_token.set_iat()
res = set_refresh_cookie(res, str(refresh_token))
return res
urlpatterns = [
...,
path('api/auth/login/', LogInView.as_view(), name="로그인"),
path('api/auth/refresh/', RefreshView.as_view(), name="토큰 갱신"),
...,
]
로그아웃
구현
로그아웃은 간단하게 쿠키를 삭제한 응답을 반환하도록 구현하였다.
class LogOutView(views.APIView):
def post(self, request):
res = Response({"message": "성공적으로 로그아웃됐습니다."}, status=status.HTTP_200_OK)
res.delete_cookie('refresh', path='/api/auth')
return res
urlpatterns = [
...,
path('api/auth/login/', LogInView.as_view(), name="로그인"),
path('api/auth/refresh/', RefreshView.as_view(), name="토큰 갱신"),
path('api/auth/logout/', LogOutView.as_view(), name="로그아웃"),
...,
]
최종 확인
확인 과정은 '로그인' -> '갱신' -> '로그아웃 -> '갱신' 순으로 진행한다. 첫 갱신은 로그인 과정에서 얻은 Refresh 토큰이 쿠키에 저장됐기 때문에 새 Access 토큰이 반환될 것이다. 다음 갱신은 Refresh 토큰을 담은 쿠키가 삭제됐기 때문에 비인가 응답이 반환될 것이다.
로그인 -> 갱신



로그아웃 -> 갱신


참고자료
- https://jwt.io/
- https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies
- https://www.linkedin.com/advice/0/what-best-practices-storing-tokens-web-browsers
- https://stackoverflow.com/questions/57650692/where-to-store-the-refresh-token-on-the-client
- https://stackoverflow.com/questions/9353630/check-if-httponly-cookie-exists-in-javascript