왓챠피디아 클론 WatchB 개발기: 유저 인증 (백엔드 2편)

mynghn·2022년 8월 11일
1

🪙 JWT 관련 API 컬렉션

지난 1편에서는 WatchB가 현실의 유저를 구분하고 인증하는데 개념적으로 필요한 쟝고 모델과 API 로직을 구현해봤고,
이제 실제 유저가 프론트엔드에서 API 요청을 보낼 때 백엔드에 있는 유저 인스턴스와의 연결을 인증할 방법이 필요하다.

인증 방식으로는 세션 인증과 달리 별도로 서버에 세션 정보를 저장할 필요가 없고 오로지 토큰만으로 stateless하게 인증이 가능하다는 장점이 있는 JWT 기반 인증을 사용해보기로 했다.

그리고 프론트엔드에서 발급받은 JWT를 보관할 방법으로는 access 토큰은 Redux 상태값으로, refresh 토큰은 HTTP Only 쿠키로 관리하는 방식을 택했다. 이와 관련해 자세한 내용은 이후 프론트엔드편에서 풀어보겠다.
(@yaytomato님의 포스팅을 상당 부분 참고했다🙏)

이제 프론트엔드 쪽의 사정을 제쳐두면,
백엔드 단에선 refresh 토큰은 HTTP Only 쿠키에 담아서 발급하면 된다는 것만 기억하면 되겠다.

Simple JWT와 인증 백엔드

JWT 관련 API 작업은 Simple JWT 라이브러리를 사용해 진행했는데,
크게 두 가지의 역할을 해준다.

  • DRF 규격의 JWT 인증 백엔드 제공
  • 토큰 발급 관련 뷰 모음 제공

여기서 DRF의 인증이 어떻게 작동하는지 간략하게 짚고 넘어가보자.
일종의 미들웨어처럼 요청이 뷰 함수에 도달하기 전에 그 내용을 보고 유저 인증 여부를 따지는 레이어를 하나 더 두는 방식인데,
DRF의 인증 백엔드를 거친 이후에는 뷰 함수 안에서 인자로 받은 request를 가지고 request.userrequest.auth 속성을 통해 인증 결과에 접근할 수 있다.

  • 세션 인증과 같이 유저를 하나 특정하는 인증 방식의 경우 request.user에 쟝고 유저 인스턴스가 들어있고
  • 토큰을 사용하는 경우와 같이 유저를 특정하지 않는 인증 방식을 사용하면 request.auth에 해당 인증 정보가 들어있다.
  • 인증에 실패한 경우 기본적으로 두 속성 모두 None
  • Simple JWT 인증의 경우에는 request.user에는 요청에 담긴 access 토큰의 소유자 유저가 들어있고 request.auth에는 해당 토큰이 들어있다.

쟝고 세팅 모듈에 다음과 같이 설정하면 Simple JWT의 JWTAuthentication을 DRF 인증 백엔드로 사용할 수 있다.

REST_FRAMEWORK = {
    ...
    'DEFAULT_AUTHENTICATION_CLASSES': (
        ...
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    )
    ...
}

그리고 JWTAuthentication이 작동하는 방식은 다음과 같다.

from rest_framework import authentication


class JWTAuthentication(authentication.BaseAuthentication):
	...
	def authenticate(self, request):
    	header = self.get_header(request)
        if header is None:
            return None

        raw_token = self.get_raw_token(header)
        if raw_token is None:
            return None

        validated_token = self.get_validated_token(raw_token)

        return self.get_user(validated_token), validated_token

간단하게 요청 헤더를 보고 JWT access 토큰이 없으면 인증 실패, 있으면 인증 성공이다. 인증 성공 시 리턴한 (유저, 토큰) 튜플은 DRF가 request에 담아 뷰 함수로 전달할 것이다.

그러니 JWT를 통해 API 요청을 인증하고 싶다면 요청 헤더에 정확한 형태로 JWT access 토큰을 실어 보내면 되겠다.

형태 역시 쟝고 세팅 모듈에서 설정 가능한데, 기본 설정대로 간다면 다음과 같은 포맷의 key와 value를 요청 헤더에 추가하면 된다.

{"Authorization": f"Bearer {accessToken}"}

JWT 발급/갱신 API

JWT 인증 백엔드는 위와 같은 Simple JWT의 방식을 그대로 사용했지만,
토큰 발급 관련 뷰의 경우 Simple JWT의 기본 구현이 access 토큰과 refresh 토큰을 같이 응답 본문에 담는 형태였기 때문에 WatchB의 방식에 맞게 refresh 토큰은 따로 쿠키에 담는 형태로 커스텀 작업이 필요했다.

WatchB에서 토큰 발급과 관련해 구현할 뷰는 단순 발급갱신 두 가지였고,
응답에서 refresh 토큰을 빼내 다시 쿠키에 담은 뒤 응답을 재조립하는 작업이 공통적으로 필요했다. 코드 반복을 줄이고자 이 기능을 일종의 Mixin 클래스로 구현해 재사용해봤다.

from django.conf import settings
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework_simplejwt.settings import api_settings as simplejwt_settings
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView


class JWTResponseMixin:
    def post(self, request: Request, *args, **kwargs) -> Response:
        # 1. leave only access token in payload
        response = super().post(request, *args, **kwargs)
        refresh_token = response.data.pop("refresh")
        # 2. set refresh token in cookie
        response.set_cookie(
            key=settings.JWT_REFRESH_TOKEN_COOKIE_KEY,
            value=refresh_token,
            max_age=simplejwt_settings.REFRESH_TOKEN_LIFETIME.total_seconds(),
            secure=not settings.DEBUG,
            httponly=True,
        )

        return response


class JWTObtainPairView(JWTResponseMixin, TokenObtainPairView):
    pass


class JWTRefreshView(JWTResponseMixin, TokenRefreshView):
    def post(self, request: Request, *args, **kwargs) -> Response:
        request.data.update(
            {"refresh": request.COOKIES.get(settings.JWT_REFRESH_TOKEN_COOKIE_KEY)}
        )
        return super().post(request, *args, **kwargs)

유저의 식별자(WatchB의 경우 이메일)와 패스워드를 요청에 담아 토큰을 발급받는 방식이기 때문에 Simple JWT의 토큰 발급 뷰는 POST 요청으로 구현되었다.

따라서 post() 메소드가 정의된 위의 JWTResponseMixin을 Simple JWT의 뷰 앞 순서로 함께 상속 받으면 커스텀 뷰 클래스에서 별도 구현 없이도 post()가 불리면 Mixin에 의해 refresh 토큰을 쿠키에 담는 후처리가 동작한다.

상속 순서에 따라 JWTResponseMixin.post에서 가리키는 super().post()가 Simple JWT의 TokenObtainPairView.post() 메소드가 된다.
(참고: Python의 MRO)

토큰 갱신 뷰의 경우,
앞서 발급 당시 refresh 토큰을 쿠키에 담아 발급했기 때문에 갱신 요청 시에도 쿠키에 담겨 refresh 토큰이 들어올 것이다.
따라서 해당 요청의 쿠키로부터 refresh 토큰을 빼내 요청 본문에 담아 주는 전처리를 추가로 구현한 것이다.

# @settings.py
SIMPLE_JWT = {
    ...
    "ROTATE_REFRESH_TOKENS": True,
}

그리고 쟝고 세팅 모듈에서 ROTATE_REFRESH_TOKENS 변수를 True로 두면 Simple JWT의 토큰 갱신 뷰를 거칠 때 refresh 토큰도 새로 발급한다.

추가로 쿠키 설정 관련하여,

  • refresh 토큰 쿠키의 key로 사용할 문자열은 쟝고 세팅 모듈에 정의해두고 사용했다.
  • 쿠키의 수명 역시 쟝고 세팅 모듈의 Simple JWT 세팅 사전에 따로 정의
    : access 토큰은 기본값인 5분으로 두고 refresh 토큰은 30일로 설정
  • 프로덕션 환경에선 쿠키에 secure=True 옵션을 줘서 https 통신에서만 보내지도록
  • httponly=True 옵션을 주면 쿠키의 HTTP Only 속성이 켜짐
  • 쟝고 세팅 모듈에 JWT_REFRESH_TOKEN_COOKIE_KEY 변수 값 설정으로 refresh 토큰 쿠키 이름 문자열 관리
# @settings.py
SIMPLE_JWT = {
    "REFRESH_TOKEN_LIFETIME": timedelta(days=30),
    ...
}
JWT_REFRESH_TOKEN_COOKIE_KEY = "refreshtoken"

Refresh 토큰 쿠키 삭제 API

마지막으로 사용자가 클라이언트에서 로그아웃하는 경우에 브라우저에 쿠키로 남아있는 refresh 토큰을 삭제해주고자 했는데, 이를 위한 API를 별도로 만들었다.

작동 원리는 간단한데 로그아웃 시점에 해당 API로 요청을 보내면 만료 시점이 이미 지난 쿠키를 refresh 토큰 쿠키와 같은 key로 발급해주는 것이다. 그러면 브라우저는 같은 key의 만료된 쿠키를 받아 기존의 refresh 토큰 쿠키를 덮어쓰고 쿠키는 만료되어 해당 쿠키는 바로 사라진다.

from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView


class RefreshTokenExpireView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request: Request) -> Response:
        response = Response(status=HTTP_200_OK)
        response.set_cookie(
            key=settings.JWT_REFRESH_TOKEN_COOKIE_KEY,
            value="",
            expires="Mon, 1 Jan 1900 00:00:00 GMT",
        )
        return response

로그아웃 요청은 로그인 상황에서 하는 것을 보장하기 위해 IsAuthenticated 권한으로 설정해뒀고 어차피 만료되어 사라질 쿠키이므로 HTTP Only 등 불필요한 설정은 따로 하지 않았다.

✅ 마지막으로, TODO

적절한 CSRF 보안 처리

전 편에서 설명한 대로 DRF는 기본적으로 모든 뷰에 csrf_exempt 처리를 해 쟝고의 CSRF 보안을 무력화시킨다. 그러나 이는 세션 인증을 제외하고는 쿠키를 통한 인증 처리가 없다는 가정 하에 설계된 것이다.

WatchB에서 refresh 토큰을 쿠키에 담기로 결정한 이상 CSRF 보안에 대해 원점에서 다시 생각해볼 필요가 생긴다.

AllowAny 권한

현재 회원가입 API와 유저 검색 API, JWT 발급/갱신 API는 특정 권한 설정 없이 AllowAny로 열려 있는 상황인데 이 상황이 아주 바람직하진 않다.

프론트엔드가 따로 있기 때문에 현재 적용된 AllowAny의 실질적인 의미는,
아무 주체나 이들 요청을 할 수 있게 하고 싶은 게 아니라 프론트엔드 서버로부터 오는 경우엔 유저 로그인이 되어 있지 않아도 괜찮다는 뜻이기 때문.

프론트엔드 서버를 식별할 방법을 고안하거나 차선책으로는 스로틀링이라도 구현해둬야 마음이 편하겠다.

유저 검색 조건 다양화

당초 유저 검색 API를 기획하면서는 날짜 비교를 통한 검색 등 쟝고의 다양한 모델 쿼리 API를 최대한 지원하고자 했다. 하지만 작업의 규모는 큰데 반해 실제로는 기능이 필요한 건 아니라는 점에서 중간에 계획을 폐기했는데 이후 기회가 된다면 추가를 고려하고 있다.

ROTATE_REFRESH_TOKENS

JWT 갱신 시에 refresh 토큰까지 같이 갱신하는 방식은 유저가 지속적으로 서비스를 사용한다는 가정 하에 주기적인 토큰 갱신과 함께 유저에게 끊기지 않는 로그인 경험을 제공한다는 장점이 분명하다.

하지만 access 토큰과 달리 만료 기간이 긴 refresh 토큰은 갱신 시마다 새로 발급하면 아직 만료되지 않은 refresh 토큰들이 계속 쌓여간다는 것을 의미한다. 물론 토큰 발급에 비용이 드는 것은 아니지만 실제 사용자보다 훨씬 많은 수의 토큰이 외부에 존재한다는 건 고려해볼만한 문제다.

지금 방식을 그대로 유지한다고 해도 어찌 됐든 야기할 수 있는 문제점과 그럼에도 얻을 수 있는 장점이 충분히 고려된 결정이어야 할 것.

0개의 댓글