파이썬/장고 웹서비스 개발 완벽 가이드 with 리액트 강의를 듣고 정리한 글입니다.
DRF는 기본적으로 세션인증과 토큰인증을 지원한다.
그러나 SPA, Android, iOS기반의 클라이언트의 인증을 지원하려면 세션인증은 사용할 수 없다. 또한 DRF의 기본 토큰인증은 랜덤문자열로 구성되어 있기에 어떤 유저인지 알 수 없고 토큰 유효기간이 없다.
JWT 인증은 토큰 인증을 기반으로 하며 위에서 언급한 기본 토큰인증의 단점을 보완했다. 본 포스팅에서는 JWT 인증의 특징과 사용 방법을 정리해본다.
단순한 랜덤 문자열
>>> import binascii
>>> import os
>>> binascii.hexlify(os.urandom(20)).decode()
'ec90f85721dc5f75b6eec47d199e3476c301633f'
헤더.내용.서명
key/value
형식usernames/password
를 통해 재인증 (Token 유효기간 내에 갱신 시, username/password가 아닌 기존의 Token을 이용해 갱신이 가능)일반 토큰
8df73dafbde4c669dc37a9ea7620434515b2cc43
JSON Web Token
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFza2RqYW5nbyIsImV4cCI6MTUxNTcyMTIxMSwiZW1haWwiOiIifQ.Zf_o3S7Q7-cmUzLWlGEQE5s6XoMguf8SLcF-2VdokJQ
Payload를 변조해서 서버로 보냄 → 서버에서는 Signature 와 Payload값이 일치하지 않으므로, 무결하지 않다고 판단 후 거부가 가능하다.
>>> from base64 import b64decode
>>> b64decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9')
b'{"typ":"JWT","alg":"HS256"}'
>>> b64decode('eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFza2RqYW5nbyIsImV4cCI6MTUxNTcyMTIxMSwiZW1haWwiOiIifQ==')
b'{"user_id":1,"username":"askdjango","exp":1515721211,"email":""}'
JWT는 만료시간이 있고, Refresh를 지원합니다.
https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage
강의에서는 djangorestframework-jwt 를 기반으로 설명하였으나 지원 종료된 라이브러리이기에 djangorestframework-simplejwt기반으로 정리하였다.
poertry add djangorestframework-simplejwt
# 프로젝트/settings.py
INSTALLED_APPS = [
# ...
'rest_framework_simplejwt',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
# 'rest_framework.authentication.BasicAuthentication',
# 'rest_framework.authentication.SessionAuthentication',
# 'rest_framework.authentication.TokenAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
# 프로젝트/urls.py
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
TokenVerifyView
)
urlpatterns = [
# ...
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]
from rest_framework import generics, status
from rest_framework.response import Response
from . import serializers
from .authentication import AUTH_HEADER_TYPES
from .exceptions import InvalidToken, TokenError
class TokenViewBase(generics.GenericAPIView):
permission_classes = ()
authentication_classes = ()
serializer_class = None
www_authenticate_realm = 'api'
def get_authenticate_header(self, request):
return '{0} realm="{1}"'.format(
AUTH_HEADER_TYPES[0],
self.www_authenticate_realm,
)
def post(self, request, *args, **kwargs):
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)
class TokenObtainPairView(TokenViewBase):
"""
Takes a set of user credentials and returns an access and refresh JSON web
token pair to prove the authentication of those credentials.
"""
serializer_class = serializers.TokenObtainPairSerializer
token_obtain_pair = TokenObtainPairView.as_view()
class TokenRefreshView(TokenViewBase):
"""
Takes a refresh type JSON web token and returns an access type JSON web
token if the refresh token is valid.
"""
serializer_class = serializers.TokenRefreshSerializer
token_refresh = TokenRefreshView.as_view()
class TokenObtainSlidingView(TokenViewBase):
"""
Takes a set of user credentials and returns a sliding JSON web token to
prove the authentication of those credentials.
"""
serializer_class = serializers.TokenObtainSlidingSerializer
token_obtain_sliding = TokenObtainSlidingView.as_view()
class TokenRefreshSlidingView(TokenViewBase):
"""
Takes a sliding JSON web token and returns a new, refreshed version if the
token's refresh period has not expired.
"""
serializer_class = serializers.TokenRefreshSlidingSerializer
token_refresh_sliding = TokenRefreshSlidingView.as_view()
class TokenVerifyView(TokenViewBase):
"""
Takes a token and indicates if it is valid. This view provides no
information about a token's fitness for a particular use.
"""
serializer_class = serializers.TokenVerifySerializer
token_verify = TokenVerifyView.as_view()
class TokenBlacklistView(TokenViewBase):
"""
Takes a token and blacklists it. Must be used with the
`rest_framework_simplejwt.token_blacklist` app installed.
"""
serializer_class = serializers.TokenBlacklistSerializer
token_blacklist = TokenBlacklistView.as_view()
쉘> http POST http://서비스주소/api-jwt-auth/ username="유저명" password="암호"
{
"token": "인증에 성공할 경우, 토큰응답이 옵니다."
}
쉘> http POST http://서비스주소/api-jwt-auth/verify/ token="토큰"
{
"token": "검증에 성공할 경우, 검증한 토큰 응답이 옵니다."
}
쉘> http http://서비스주소/post/ "Authorization: JWT {{토큰}}" # djangorestframework-jwt
쉘> http http://서비스주소/post/ "Authorization: Bearer {{토큰}}" # djangorestframework-simplejwt
username/password
를 통해 인증받아야만 합니다.settings.JWT_AUTH
의 JWT_EXPIRATION_DELTA
참조 → 디폴트 5분쉘> http http://서비스주소/post/ "Authorization: Bearer 토큰"`
HTTP/1.0 401 Unauthorized
{
"detail": "Signature has expired."
}
쉘> http POST http://서비스주소/api/token/refresh/ refresh="토큰"
{
"token": "갱신받은 JWT 토큰"
}
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=2),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'UPDATE_LAST_LOGIN': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'JWK_URL': None,
'LEEWAY': 0,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
'JTI_CLAIM': 'jti',
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}