DRF를 통해 프로젝트 진행 중 JWT를 발급해야 할 상황이 생겼다.
토이 프로젝트이고 돈도 없다보니 데이터베이스를 AWS MySQL 프리티어로 사용하고 있는데, 아무래도 이게 성능도 안좋고 느리다보니 세션을 통해 인증과 인가를 하게 되면 매번 데이터베이스를 때리기 때문에 좋지 않다고 판단했다.
DRF JWT 키워드로 서치를 많이 해봤는데, 대부분은 djangorestframework-jwt나 django-rest-authtoken 라이브러리를 사용하는 것 같았다.
처음엔 나 역시 이것들을 사용하려고 했는데, 2가지 이유로 그만뒀다.
첫 번째는, 토큰의 만료시간을 제외하곤 커스터마이징하기가 쉽지 않아보였다.
우선, 위 라이브러리들은 기본적으로 username과 password를 입력 받아 토큰을 생성해준다.
하지만, 우리 서비스는 오직 카카오와 구글의 OAuth를 통해서만 로그인, 회원가입이 가능하도록 구현하였으며 로컬 로그인 기능은 빼버렸다.
즉, 비밀번호는 우리 데이터베이스에 없다.
결국, 토큰을 위해 입력 받을 값을 비밀번호에서 다른 값으로 변경해줘야하는데 이미 짜여져있는 라이브러리에서 이걸 바꾸는건 쉽지 않았다.
두 번째로는, 백엔드 쪽에서 OAuth 기능을 신경 쓸 필요가 없었다.
서비스에서 OAuth 기능을 제공하긴 하지만 이건 백이 아닌 프론트쪽에서 처리해주기 때문에, 위 라이브러리들이 OAuth와 연동될 수 있다는 장점은 우리에게 그다지 큰 이점은 아니었다.
이런 상황에서, 맞지도 않는 라이브러리를 억지로 맞춰서 썼다간 추후 추가될 기능들에 맞춰 일일히 커스터마이징하는게 더 어려울 것이라는 판단이 들어서 결국 PyJWT를 이용해 토큰 관련 기능들을 직접 구현해주기로 했다.
우선 PyJWT 라이브러리를 다운 받는다.
pip install pyjwt
이후, 로그인 및 회원가입 API를 구현하기 위한 accounts 앱을 생성해줬다.
python3 manage.py startapp accounts
이후, 토큰을 발급하고 복호화하기 위한 함수를 관리해주기 위해서 accounts앱 폴더 내에 tokens.py 파일을 만들고 토큰 발급을 위한 함수를 적어줬다.
# tokens.py
import jwt
import datetime
from decouple import config
def generate_token(payload, type):
if type == "access":
# 2시간
exp = datetime.datetime.utcnow() + datetime.timedelta(hours=2)
elif type == "refresh":
# 2주
exp = datetime.datetime.utcnow() + datetime.timedelta(weeks=2)
else:
raise Exception("Invalid tokenType")
payload['exp'] = exp
payload['iat'] = datetime.datetime.utcnow()
encoded = jwt.encode(payload, config("JWT_SECRET_KEY"), algorithm=config("JWT_ALGORITHM"))
return encoded
파라미터로 JWT에 싣을 payload값과 토큰의 종류를 받아 각각 다른 exp(만료시각)을 설정해준다.
access토큰은 2시간, refresh토큰은 2주로 설정해주었다.
만약 type값으로 access나 refresh가 아닌 다른 값이 들어오면 에러를 일으키도록 해주었다.
이후, payload값에 앞서 설정한 exp와 iat(발급시각)을 추가해주고 미리 설정해둔 JWT 시크릿키와 암호화 방식을 설정해준 후 JWT를 생성해 리턴해준다.
# views.py
from rest_framework.response import Response
from rest_framework import status
from rest_framework.decorators import action
from rest_framework import viewsets
from .token import *
class AuthViewSet(viewsets.GenericViewSet):
@action(methods=['POST'], detail=False)
def signin(self, request):
email = request.data['email']
provider = request.data['provider']
try:
user = User.objects.get(
email=email,
provider=provider
)
# payload에 넣을 값 커스텀 가능
payload_value = user.id
payload = {
"subject": payload_value,
}
access_token = generate_token(payload, "access")
data = {
"results": {
"access_token": access_token
}
}
return Response(data=data, status=status.HTTP_200_OK)
except User.DoesNotExist:
data = {
"results": {
"msg": "유저 정보가 올바르지 않습니다.",
"code": "E4010"
}
}
return Response(data=data, status=status.HTTP_401_UNAUTHORIZED)
except Exception as e:
print(e)
data = {
"results": {
"msg": "정상적인 접근이 아닙니다.",
"code": "E5000"
}
}
return Response(data=data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
tokens.py에서 정의해준 토큰 생성 함수는 views.py에서 사용해준다.
처음 설명했던 로직처럼, request에 담겨온 사용자의 ID(email)와 provider로 등록된 유저 정보가 있는지 확인 후, 없다면 유저 정보가 올바르지 않다고 리턴해준다.
프론트에서 이 응답을 받으면 가입된 유저가 없다고 판단하고 회원가입 페이지로 넘겨줄 것이다.
반대로, 해당 정보를 가진 유저가 데이터베이스에 존재한다면 그 유저의 pk값을 payload에 담아 앞서 정의한 generate_token 함수를 통해 토큰을 생성해주고 리턴해준다.
프론트에서는 API 호출을 통해 얻은 이 토큰을 쿠키나 로컬 스토리지에 저장해 이후의 인증 / 인가에 사용해줄 수 있다.
JWT는 세션과는 다르게 토큰 자체에 유저 정보를 담을 수 있기 때문에 이곳에 담고 싶은 값을 넣을 수 있다.
JWT 발급을 직접 구현해준 이유가 여기에 있다.
현재는 테스트로 사용자의 pk값만 담아놨지만, 사용자의 프로필 이미지 url이나 닉네임 등도 payload에 담아 토큰에 담아줄수도 있으며 이 값은 프론트에서 복호화해 유용히 사용할 수 있을 것이다.
# urls.py
from django.urls import path, include
from .views import *
from rest_framework.routers import DefaultRouter
routers = DefaultRouter()
routers.register('auth', AuthViewSet, basename='auth')
urlpatterns = [
path('', include(routers.urls)),
]
url은 DefaultRouter를 이용해 구성해줬고, 정의된 url로 포스트맨을 통해 request를 날려보면
토큰이 정상적으로 발급되는 것을 확인할 수 있다.
Refresh Token?
tokens.py에서는 access토큰과 refresh토큰 모두 발급할 수 있도록 함수를 정의해봤지만, 정작views.py를 보면 refresh토큰은 발급하지 않는다.
그 이유는 refresh토큰 역시 데이터베이스에 저장되어야하기 때문이다.
JWT에 대해 공부해본 사람이라면 알겠지만, JWT는 유의미한 정보를 담은 토큰 자체가 서버가 아닌 사용자 쪽에 저장되기 때문에 세션을 활용할 때보다는 더 높은 위험에 노출되어 있다.
따라서, 토큰의 탈취 및 변조의 위혐을 낮추기 위해서 유효기간이 짧은 access토큰과 유효기간이 긴 refresh토큰을 각각 발급하고, refresh 토큰은 서버의 데이터베이스에 저장해준다.
이후, access 토큰이 만료될 때마다 refresh 토큰을 확인해 해당 토큰이 유효하다면 access 토큰을 다시 발급해준다.
이렇게 함으로써, 누군가 사용자의 브라우저에 저장된 access 토큰을 탈취하더라도 금방 토큰이 만료되기 때문에 무언가 이상한 짓(?)을 할 시간과 피해를 줄여줄 수 있다.
이러한 방식은 JWT 기반의 인증 방식에 내재하는 위험을 낮추어줄 순 있지만 궁극적으로 refresh 토큰 확인을 위해서 데이터베이스에 접근하는 작업이 필요하다.
우리 서비스에서 JWT를 사용을 결심하게 된 가장 큰 이유인 데이터베이스 I/O를 낮추자는 목적이 희석된 것이다.
이러한 이유로 현재는 refresh 토큰을 발급하지 않고 2주 가량의 유효기간을 가진 access 토큰을 발급해주는 방식으로 코드를 작성해놨다.
# tokens.py
import jwt
import datetime
from decouple import config
def generate_token(payload, type):
if type == "access":
# 2주
exp = datetime.datetime.utcnow() + datetime.timedelta(weeks=2)
elif type == "refresh":
# 2주 (사용 안함)
exp = datetime.datetime.utcnow() + datetime.timedelta(weeks=2)
else:
raise Exception("Invalid tokenType")
payload['exp'] = exp
payload['iat'] = datetime.datetime.utcnow()
encoded = jwt.encode(payload, config("JWT_SECRET_KEY"), algorithm=config("JWT_ALGORITHM"))
return encoded
'프레임워크 > Django' 카테고리의 다른 글
[Django] Word Count 만들기 - 2 (0) | 2021.02.16 |
---|---|
[Django] Word Count 만들기 - 1 (0) | 2021.02.15 |
[Django] MTV 패턴 (0) | 2021.02.15 |
[Django] Hello World 페이지 만들기 (0) | 2021.02.15 |
[Django] django 프로젝트 및 앱 폴더 생성 (0) | 2021.02.15 |