파이썬/장고 웹서비스 개발 완벽 가이드 with 리액트 강의를 듣고 정리한 글입니다.

DRF는 기본적으로 세션인증과 토큰인증을 지원한다.
그러나 SPA, Android, iOS기반의 클라이언트의 인증을 지원하려면 세션인증은 사용할 수 없다. 또한 DRF의 기본 토큰인증은 랜덤문자열로 구성되어 있기에 어떤 유저인지 알 수 없고 토큰 유효기간이 없다.

JWT 인증은 토큰 인증을 기반으로 하며 위에서 언급한 기본 토큰인증의 단점을 보완했다. 본 포스팅에서는 JWT 인증의 특징과 사용 방법을 정리해본다.

1. Token 인증과 JWT 인증


1.1. DRF의 Token


단순한 랜덤 문자열

  • 각 User와 1:1 매칭
  • 유효기간이 없습니다.
>>> import binascii
>>> import os
>>> binascii.hexlify(os.urandom(20)).decode()
'ec90f85721dc5f75b6eec47d199e3476c301633f'

1.2. JWT (JSON Web Token)


  • 데이터베이스를 조회하지 않아도, 로직만으로 인증이 가능
  • 포맷: 헤더.내용.서명
    • 서버에서 토큰 발급 시에 비밀키로 서명을 하고, 발급 시간을 저장
    • 서명은 암호화가 아닙니다. 누구라도 열어볼 수 있기에, 보안성 데이터는 넣지 말고, 최소한의 필요한 정보만 넣기.
  • Claim : 담는 정보의 한 조각. key/value형식
    • djangorestframework-jwt에서는 Payload에 user_id, username, email 이름의 claim을 사용
  • 위변조가 불가 → 비밀키를 소중히
    • 장고에서는 settings.SECRET_KEY를 활용하거나, 별도로 JWT_SECRET_KEY 설정을 합니다.
  • 갱신(Refresh)메커니즘을 지원
    • Token 유효기간 내에 갱신하거나, usernames/password를 통해 재인증 (Token 유효기간 내에 갱신 시, username/password가 아닌 기존의 Token을 이용해 갱신이 가능)
  • 이미 발급된 Token을 폐기(Revoke)하는것은 불가

1.3. Token과 JWT


일반 토큰

8df73dafbde4c669dc37a9ea7620434515b2cc43

JSON Web Token
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFza2RqYW5nbyIsImV4cCI6MTUxNTcyMTIxMSwiZW1haWwiOiIifQ.Zf_o3S7Q7-cmUzLWlGEQE5s6XoMguf8SLcF-2VdokJQ

  • Header를 base64 인코딩
  • Payload를 base64 인코딩
  • Signature = Header/Payload를 조합하고, 비밀키로 서명한 후, base64 인코딩

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":""}'

1.4. JWT의 Life cycle


JWT는 만료시간이 있고, Refresh를 지원합니다.

1.5. Token은 안전한 장소에 보관하기


  • 일반 Token / JWT 토큰 여부에 상관없습니다.
  • 스마트폰 앱은, 설치된 앱 별로 안전한 저장공간이 제공되지만, 웹브라우저에는 없습니다.
    • Token은 앱 환경에서만 권장하기도 합니다.
    • 웹 클라이언트 환경에서는 세션 인증이 나은 선택일 수 있습니다. 단 장고/웹클라이언트가 같은 호스트명을 가져야 함.
  • 통신은 필히 https !!! → Let's Encrypt(https보급을 위해 등장한 무료 인증 기관)에서 SSL인증서를 발급받아 서비스하자

https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage

2. djangorestframework-jwt


강의에서는 djangorestframework-jwt 를 기반으로 설명하였으나 지원 종료된 라이브러리이기에 djangorestframework-simplejwt기반으로 정리하였다.

2.1. Installation


poertry add djangorestframework-simplejwt

2.2. 셋업


# 프로젝트/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'),
]

2.3. rest_freamwork_jwt의 뷰 구현


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()

2.4. HTTPie를 통한 JWT 발급


  • 인증에 실패할 경우, "400 Bad Request" 응답
  • 모든 연결에서는 서버와 HTTPS 통신을 권장
  • 발급받은 JWT Token을 jwt.io 서비스를 통해 검증해보세요
> http POST http://서비스주소/api-jwt-auth/ username="유저명" password="암호"
{
    "token": "인증에 성공할 경우, 토큰응답이 옵니다."
}> http POST http://서비스주소/api-jwt-auth/verify/ token="토큰"
{
    "token": "검증에 성공할 경우, 검증한 토큰 응답이 옵니다."
}

2.5. 발급받은 JWT Token으로 포스팅 목록 API 요청


  • DRF Token에서는 인증헤더 시작문자열로 Token을 썼지만, 이제 JWT 사용.
  • 매 요청시마다 인증을 수행합니다.
> http http://서비스주소/post/ "Authorization: JWT {{토큰}}"  # djangorestframework-jwt> http http://서비스주소/post/ "Authorization: Bearer {{토큰}}"  # djangorestframework-simplejwt

2.6. JWT Token 유효시간이 지났다면?


  • JWT Token 유효기간 내에 갱신을 해야만 합니다.
    • 유효기간이 지난 Token은 아래와 같이 "401 Unauthorized" 응답
    • 유효기간 내에는 Token만으로 갱신 가능
    • 유효기간이 지나면 다시 username/password를 통해 인증받아야만 합니다.
  • 유효기간
    • settings.JWT_AUTHJWT_EXPIRATION_DELTA참조 → 디폴트 5분
> http http://서비스주소/post/ "Authorization: Bearer 토큰"`
HTTP/1.0 401 Unauthorized
{
    "detail": "Signature has expired."
}

2.7. JWT Token 갱신받기


  • Token 유효기간 내에만 가능
  • settings.JWT_AUTH의 JWT_ALLOW_REFRESH 설정은 디폴트 False
    • True 설정에서만 갱신 지원. False인 경우에는 orig_iat 필드를 찾을 수 없다는 응답
> http POST http://서비스주소/api/token/refresh/ refresh="토큰"
{
    "token": "갱신받은 JWT 토큰"
}

2.8. djangorestframework-jwt의 주요 settings


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),
}

0개의 댓글