서비스를 출시하려면 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를 통해 설정하면 된다.
set_cookie( )의 파라미터
key
- 쿠키에 들어갈 이름이다.value
- 해당 쿠키에 들어갈 실질적인 값이다. 나는 refresh token을 넣어주었다.httponly
- 스크립트 접근을 차단할 수 있도록 하는 보안 설정이다.secure
- HTTPS 연결에만 쿠키를 전송하도록 한다. 이것이 곧 secure cookie 설정!max_age
- 쿠키의 만료 시간을 초 단위(second)로 계산하여 설정한다.samesite
- 쿠키를 요청한 사이트에 제약을 둔다. Strict, Lax, None을 주로 사용한다.domain
- 쿠키가 적용되는 도메인을 설정한다.
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
DRF, allauth에서 OAuth를 처리하기 위해선 두 가지 API 클래스로 나눠야 한다.
Callback api
와 Login 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로 설정하는 건 동일하다.
이 역시 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
Refresh token을 secure cookie에 적용하는 일은 쉬웠다.
하지만 사용 중인 라이브러리가 제공하는 형태를 깨고 커스텀하는 작업은 생소했다.
DRF를 처음에 사용할 땐 python의 고급 문법(?)을 사용할 일이 없다보니 이론에 접근하는 게 조금 오버리소스가 아닌가 생각했다.
하지만 이렇게 라이브러리를 뜯어보고 상속받아서 내가 원하는 대로 결과를 이해하고 얻기 위해선 필요하다고 깨닫는 순간이었다.
(이래서 탑다운 공부법이 좋은 것 같다.)