본문 바로가기
Backend

[Django] JWT token 이용한 User 식별 (Authentication, Token Verify)

by 개발하는 디토 2022. 11. 27.

JWT 개념

🪙 공식 문서

session 방식은 서버에서 로그인 관련 정보를 관리, 요청이 들어왔을 때 탐색한다. → 불필요한 스토리지 탐색 생긴다.

JWT는 토큰 자체에 유저의 정보를 담고 있기 때문에 별도의 데이터 및 저장 공간이 필요하지 않는다.

JWT의 구조

xxxxx.yyyyy.zzzzz (헤더.페이로드.시그니처)

헤더 Header

Header는 JWT의 메타 정보를 나타낸다. 토큰의 타입을 정의하고 어떤 signing 알고리즘이 쓰였는지 나타낸다. 이러한 JSON 정보는 Base64Url 방식으로 인코딩 되어 JWT의 첫번째 영역에 들어간다.

{
  "typ": "JWT",
  "alg": "HS256"
}

페이로드 Payload

토큰의 만료 시간, 유저 정보 등 실질적 데이터를 담는 영역이다. 유저의 인증 정보(아이디, 비밀번호 등)가 아닌 유저를 특정할 수 있는 값이 들어간다. Base64Url 방식으로 인코딩 되어 JWT의 두 번째 영역으로 들어간다.

{
  "token_type": "access",
  "exp": 1649145719,
  "jti": "1foo2jwt3id4",
  "user_id": 123
}

 

서명 Signature

Payload에 담긴 유저 정보는 인코딩만 되어있을 뿐, 안전하게 암호화되어 있지 않다. 다만 페이로드에 담긴 정보는 유저를 특정할 수 있는 user id만 담겨있기 때문에 사실 user id가 노출되는 것은 보안상 그렇게 큰 문제는 아니라 한다. 따라서 Signature는 변조의 문제를 해결하기 위해 사용한다.

 

Signature를 만들기 위해

  1. 인코딩 된 Header
  2. 인코딩 된 Payload
  3. Secret

3가지가 필요한데 Simple JWT에서는 Django 프로젝트의 secret_key를 기본 Secret으로 사용한다고 한다.

Signature는 이러한 3가지 정보를 합쳐서 Header에서 지정한 알고리즘을 이용해 정보를 해싱한 것이다. 이후 토큰을 받을 때 Header, Payload, Secret 3가지 값을 이용해 Decode를 진행한다. 3가지 중 어느 하나라도 일치하지 않으면 Decode한 Signature와 토큰 발급 시 만든(Encode한) Signature는 완전히 다른 값을 갖게 된다고 한다. 이를 통해 변조를 막는 것이다.

 

JWT 검증

  1. 서버가 클라이언트로부터 토큰을 전달받는다.
  2. 서버는 자신이 가지고 있던 Secret과 전달받은 토큰에서 Header, Payload를 해싱해 Signature를 만든다.
  3. 서버의 Signature와 클라이언트로부터 받은 토큰의 Signature가 일치하는지 확인한다.
  4. 일치하면 요청한 클라이언트(유저)를 인증된 유저로 처리하고, 일치하지 않으면 토큰이 변조되었으므로 클라이언트를 인증 처리하지 않는다.

 


구현

Token Decoding 이해하기

카카오 로그인 이후 TokenObtainView를 이용해 만든 my_access_token을 jwt.io에서 디코딩해보았다.

jwt.io에서 내가 발급한 token을 decoding한 결과

 

user_id로 내가 만든 User 모델의 pk가 나옴을 확인할 수 있다. 이러한 user_id를 pk로 사용해 내가 만든 User 모델 object에 접근할 수 있을 것이다.

 

 

Token을 이용한 User Authentication (인가)

settings.py

settings.py에 저장해둔 SIMPLE_JWT 세팅을 이용해서 jwt decode를 진행할 수 있다. Django secret key를 SIGNING_KEY로 사용한다.

# settings.py
SIMPLE_JWT = {
	# ...
    # 암호화 알고리즘
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,

    'AUTH_HEADER_TYPES': ('Bearer',), # defulat value
}

 

views.py

시도

  1. access_token을 받아온다.
    • 쿠키 방식은 안전하지 않으므로 Header를 통해 받는다.
  2. jwt.decode(access_token, 시크릿, algorithms=[해싱 알고리즘]) 한 것을 payload로 받는다.
    • 주의) algorithms = [] 대괄호로 한 번 더 묶어 받는다.
  3. 받은 payload에서 user_id에 접근해 이 id를 가지고 User 모델 오브젝트를 얻는다.
  4. User 모델 오브젝트를 serialize해 Response로 보낸다.
  5. 새로운 access_token을 쿠키에 저장한다.

예외 처리

  • 2번에서 access_token 만료로 인해 에러가 난다면?
    • simple_jwt의 TokenRefreshSerializer에 refresh token 넘겨 access_token을 발급받고 다시 jwt.decode 과정을 진행한다. 별로 바람직하지는 않지만 일단은 쿠키에서 refresh token을 가져오게끔 했다.
  • refresh_token마저 만료되었다면?
    • 로그인 만료되었다는 Response를 보낸다. ⇒ silent 갱신 구현해봄직함
  • 그 외 에러
    • 로그인 실패했다고 Response 보낸다.

 

jwt decode를 사용해 구현하였다. 일반적으로는 Token Verification은 permission_classes = [IsAuthenticated,]일 텐데, 일단 카카오 로그인을 사용하면서 username, password를 가지고 사용하는 authentication을 사용할 수가 없어서 일단은 저렇게 AllowAny로 냅뒀다. 대신 권한이 필요한 서비스마다 Header에 토큰 등을 받아서 인증을 할 예정이다.

# accounts.views.py

# simplejwt
from rest_framework_simplejwt.serializers import (TokenObtainPairSerializer, TokenRefreshSerializer)
from rest_framework_simplejwt.tokens import RefreshToken # to refresh token
from rest_framework_simplejwt.exceptions import TokenError # invoke error during token verification
import jwt
from DearOneYearLetter.settings import SIMPLE_JWT
# model
from .models import User
from .serializers import UserSerializer
# rest_framework
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny, IsAuthenticated

# ...
class VerifyUser(APIView):
    permission_classes = [AllowAny,]
    # find user from access token info
    def get_user(self, access_token):
        payload = jwt.decode(access_token, SIMPLE_JWT['SIGNING_KEY'], algorithms = [SIMPLE_JWT['ALGORITHM']]) # django sccret key, ['HS256'] 
        print(payload.get('user_id'))
        user_pk = payload.get('user_id')
        user = User.objects.get(pk=user_pk)
        return user

    # read user info
    def get(self, request):
        try:
            print("\n\n\n")
            # header 잘 넘어오는지 확인
            for a in request.headers:
                print(a)
            access_token = request.headers.get('Authorization')
            print(access_token)
            access_token = access_token.split(' ')[-1]
            payload = jwt.decode(access_token, SIMPLE_JWT['SIGNING_KEY'], algorithms = [SIMPLE_JWT['ALGORITHM']]) # django sccret key, ['HS256'] 

            user_pk = payload.get('user_id')
            user = User.objects.get(pk=user_pk)

            serializer = UserSerializer(user)
            res = Response(data = serializer.data, status = status.HTTP_200_OK)
            return res

        except(jwt.exceptions.ExpiredSignatureError): # access token has been expired
            try:
                serializer = TokenRefreshSerializer(data ={'refresh':request.COOKIES['my_refresh_token']})
                if serializer.is_valid(raise_exception=True):
                    access_token = serializer.validated_data['access']
                    refresh_token = request.COOKIES.get('refresh_token', None)
                    
                    payload = jwt.decode(access_token, SIMPLE_JWT['SIGNING_KEY'], algorithms = [SIMPLE_JWT['ALGORITHM']]) # django sccret key, ['HS256'] 

                    user_pk = payload.get('user_id')
                    user = User.objects.get(pk=user_pk)
                    serializer = UserSerializer(user)

                    res = Response(data = serializer.data, status = status.HTTP_200_OK)
                    res.set_cookie('my_access_token', access_token)
                    res.set_cookie('my_refresh_token', refresh_token)
                    return res
            except(TokenError): # refresh token is also expired
                return Response({'msg': "Login expired."}, status = status.HTTP_200_OK)

            raise jwt.exceptions.InvalidTokenError
        except(jwt.exceptions.InvalidTokenError):
            return Response( {'msg': "Login expired. Invalid token."}, status = status.HTTP_200_OK)

 

위의 코드는 User 모델의 모든 정보를 보내주지만 사실 그것보다는 필요한 정보만 보내주는 것이 옳다. 나의 경우 유저 식별을 이메일로 하므로 저기서 data = serializer.email 이런 식으로 이메일만 보내줄 수 있을 것이다.

 

 

후기

Header가 계속 Front에서 제대로 안 넘어와서 헤맸다. 로직 자체는 안 어려운데 Front와 연결할 때 시간을 다 썼다. refresh_token 역시 쿠키가 아닌 헤더에서 처음부터 받게끔 해도 좋을 것 같다.

 


 

도움 받은 자료

쿠키, 세션 vs. JWT

 

인증 방식 : Cookie & Session vs JWT

1. HTTP 특성 HTTP는 인터넷 상에서 데이터를 주고 받기 위한 서버/클라이언트 모델을 따르는 프로토콜입니다. 클라이언트가 서버에게 요청을 보내면 서버는 응답을 보냄으로써, 데이터를 교환합

tecoble.techcourse.co.kr

 

토큰 기반의 로그인 인증 방법 이해하기 (JWT)

프로젝트를 진행하며 로그인 / 회원가입 부분을 담당하여 개발하게 되었습니다. 로그인을 구현하기 위해 공부한 내용을 정리하고 공유하고자 하는 목적으로 글을 작성합니다. 우선 사용자 로그

hoime.tistory.com

 

 

JWT decode

 

How to decode and verify simple-jwt-django-rest-framework token

i am trying to verify and decode simple-jwt-django-rest-framework token. I know we can use verify api of simple-jwt. But i want to decode and verify in my views . Below is the current code i am try...

stackoverflow.com

 

[Django][Web] Django의 simple-jwt를 이용한 로그인 기능 구현(2)

simple-jwt에 대한 두번째입니다! 오늘은 받은 access, refresh토큰 기반으로 유저를 식별하는 방법에 대해 소개해드릴려고 합니다. 즉, 로그인이 되어있다고 가정했을때 서버에서 토큰을 기반으로 유

velog.io

 

댓글