Django Rest Framework - JWT

nikevapormax·2022년 6월 29일
1

TIL

목록 보기
61/116
post-custom-banner

Django Rest Framework

🪙 JSON Web Tokens

  • 토큰인증 방식의 대표 주자로, HEADER.PAYLOAD.VERIFY_SIGNATURE 의 형태로 이루어져 있다. (온점을 기준으로 세 개로 나뉨!)
eyJhb.....pXVCJ9.eyJzdW....MDIyfQ.SflKx....w5c

>>> HEADER : eyJhb.....pXVCJ9
    PAYLOAD : eyJzdW....MDIyfQ
    VERIFY_SIGNATURE : SflKx....w5c
  • HEADER
    • JWT를 검증하는데 필요한 정보를 가진 데이터
    • VERIFY_SIGNATURE에 사용한 암호화 알고리즘토큰 타입, key의 id 등의 정보를 가지고 있다.
    • 암호화 ❌
HEADER 정보
{
  "typ": "JWT",    # 토큰 타입
  "alg": "HS256"   # 알고리즘
}
  • PAYLOAD
    • 실질적으로 인증에 필요한 데이터를 저장하는 부분
    • 데이터 각각의 필드를 클레임(claim)이라 하며, 대부분의 경우 클레임에 username 또는 user_id를 포함한다.
    • 인증 시 payload에 있는 username을 가져와서 사용자의 정보를 인증한다.
    • 토큰 발행시간(iat)토큰 만료시간(exp)이 담겨 있으며, exp-iat가 0이라면 다시 토큰을 발급받아야 한다.
    • 암호화 ❌
PAYLOAD 정보
{
  "token_type": "access", # 토큰의 종류
  "exp": 1656293275, # 토큰의 만료시간 (Numeric Date)
  "iat": 1656293095, # 토큰의 발행시간 (Numeric Date)
  "jti": "2b45ec59cb1e4da591f9f647cbb9f6a3", # json token id 
  "user_id": 1 # 실제 사용자의 id값
}
  • VERIFY SIGNATURE
    • HEADERPAYLOAD는 암호화 되지 않았다.
      • Json → UTF-8 → Base64 형식으로 변환된 데이터
      • headerpayload 의 생성 자체는 너무 쉽고 누구나 만들 수 있는 데이터
    • 이로 인해 HEADERPAYLOAD 만으로는 토큰의 진위여부가 불가능하다.
    • 따라서 JWT의 구조에서 가장 마지막에 있는 VERIFY SIGNATURE 를 통해 토큰 자체의 진위여부를 판단한다.
    • Base64UrlEncodingHEADERPAYLOAD의 정보를 합친 뒤, SECRET_KEY 를 이용하여 Hash 를 생성하여 암호화를 진행한다.
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
	SECRET_KEY
)

DRF에서 JWT 사용하기

simplejwt 설치

  • 아래의 명령어를 입력해 모듈을 설치해 준다.
$ pip install djangorestframework-simplejwt

settings.py 설정

  • settings.py의 REST_FRAMEWORK의 인증 방식에 아래 내용을 추가한다.
'DEFAULT_AUTHENTICATION_CLASSES': [
	...
    'rest_framework_simplejwt.authentication.JWTAuthentication',
],
  • settings.py의 INSTALLED_APPS에 아래 내용을 추가한다.
INSTALLED_APPS = [
    ...
    'rest_framework_simplejwt',
    ...
]

user/urls.py 설정

  • user/urls.py에 아래의 내용을 추가한다.
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    ...
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    ...
]
  • 위의 세팅들을 다 마친 후, 포스트맨으로 로그인을 진행해 보았다.

JWT 정보 확인

  • jwt.io에 접속해 확인할 수 있다.
    • 위의 결과에서 나온 refresh 토큰을 넣어봤더니 아래와 같은 정보가 나왔다.

JWT 옵션 설정

  • settings.py에 아래의 코드를 추가해주면 된다.
    • 여기서 실질적으로 서비스를 운용하는데 주석이 달린 두 가지 코드만 있더라도 문제는 없다.
    • 유효시간을 설정할 때는 너무 길게 하지 않는 것이 좋다. 길게 한다면 해킹의 위험이 있기 때문이다.
    • Access 토큰 : 어플을 사용하는 도중 계속 새로고침된다.
    • Refresh 토큰 : 평소에 어플을 쓰다가 로그인이 풀리는 경우 Refresh 토큰의 인증 기간이 끝난 것이다.
from datetime import timedelta
...

SIMPLE_JWT = {
	# Access 토큰 유효 시간 설정하기
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
	# Refresh 토큰 유효 시간 설정하기
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),

    '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),
}
  • 다른 부분도 마찬가지이지만 SECRET_KEY의 경우, github에 올라가지 않도록 .env 파일 혹은 .gitignore를 잘 활용해 주어야 한다.

Custom JWT

  • 토큰에 담긴 사용자의 정보를 의미하는 claim 을 커스터마이징해보자.

user/jwt_claim_serializer.py 생성

  • 파일을 생성한 후 아래의 내용을 작성해준다.
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

# TokenObtain == Access Token 으로 생각하면 됨! 
# 즉, 이곳에서 claim에 어떤 정보를 담고 싶은지에 대한 커스터마이징을 진행하면 됨!
class SeasonTokenObtainPairSerializer(TokenObtainPairSerializer):
    @classmethod
    # database에서 조회된 user의 정보가 user로 들어오게 된다. (요청한 user의 정보)
    def get_token(cls, user):
		# 가지고 온 user의 정보를 바탕으로 token을 생성한다.
        token = super().get_token(user)

        # 로그인한 사용자의 클레임 설정하기.
        token['id'] = user.id
        token['username'] = user.username
        token['email'] = user.email

        return token

user/views.py 내용 추가

  • 아래의 내용을 추가해준다.
from user.jwt_claim_serializer import SeasonTokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView

# TokenObtainPairView : urls.py에서 import했고, 토큰을 발급받기 위해 사용
class SeasonTokenObtainPairView(TokenObtainPairView):
    # serializer_class에 커스터마이징된 시리얼라이저를 넣어 준다.
    serializer_class = SeasonTokenObtainPairSerializer

urls.py에 커스터마이징된 시리얼라이저 등록

  • 맨 아래 줄에 있는 코드를 추가한다.
    • views.py에서 등록한 SeasonTokenObtainPairView를 사용하며, 이에 따른 url을 생성해 준 것이다.
from django.urls import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

from . import views
from user.views import SeasonTokenObtainPairView

urlpatterns = [
    path('', views.UserView.as_view()),
    path('login/', views.UserAPIView.as_view()),
    path('logout/', views.UserAPIView.as_view()),
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('api/season/token/', SeasonTokenObtainPairView.as_view(), name='season_token'),
]
  • 이제 커스터마이징 시리얼라이저에 내가 추가로 등록한 내용이 잘 받아와 지는지 포스트맨과 jwt.io 사이트를 통해서 확인해보도록 하겠다.
  • 아까 내가 추가했던 정보들이 빨간 박스에 잘 나오고 있는 것을 알 수 있다.

Access Token

  • JWT를 이용해 인증된 사용자만 접근할 수 있는 view를 만들어 보자.
  • 유효한 access 토큰을 가진 사용자라면 인가된 사용자만 볼 수 있는 정보를 확인할 수 있습니다.

user/views.py에 추가

from rest_framework_simplejwt.authentication import JWTAuthentication

# 인가된 사용자만 접근할 수 있는 View 생성
class OnlyAuthenticatedUserView(APIView):
    permission_classes = [permissions.IsAuthenticated]
		
    # JWT 인증방식 클래스 지정하기
    authentication_classes = [JWTAuthentication]

    def get(self, request):
		# Token에서 인증된 user만 가져온다.
        user = request.user
        print(f"user 정보 : {user}")
        if not user:
            return Response({"error": "접근 권한이 없습니다."}, status=status.HTTP_401_UNAUTHORIZED)
        return Response({"message": "Accepted"})

user/urls.py 추가

path('api/authonly/', views.OnlyAuthenticatedUserView.as_view()),

포스트맨으로 확인

  • 현재 상황은 토큰은 있으나, 토큰이 유효하지 않은 상태이다. 아래 VALUE에 들어있는 토큰값의 유효기간이 끝났기 때문에 로그인을 다시 하고 토큰값을 새로 넣어준 뒤 다시 시도해보도록 하겠다.
  • 다시 시도해보았더니 토큰값이 잘 적용된 것을 볼 수 있다.

Refresh Token

  • 만약 Access Token의 유효 기간이 끝났다면, 인가(Authenticate)를 담당하는 토큰이 더 이상 효력을 발생하기 힘드니까 다시 토큰을 발급 받아야 한다.
  • 지금까지는 포스트맨으로 해서 그냥 로그인을 다시 했는데, 그렇게 하지 않고 Refresh Token을 사용하면 갱신된 Access Token을 받아올 수 있게 된다.
  • 보통 JWT를 이용한 사용자 인증 과정은 access 토큰의 유효시간(exp)가 만료되면 refresh 토큰을 body 에 넣어서 서버에게 새로운 access 토큰을 받는 루틴으로 인증 / 인가 과정을 구현하게 된다.
  • 아래의 결과와 같이, refresh token을 post 방식으로 보내주면 access token이 돌아오게 된다.

유화제작 프로젝트 로그인에 적용

  • 먼저 JWT Token을 사용하기 위해 이전에 사용했던 django session 기반 로그인 및 로그아웃 관련 코드를 views.py 및 urls.py에서 삭제하였다.
  • user/views.py
from django.shortcuts import render
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import permissions
from rest_framework import status
from django.contrib.auth import authenticate, login, logout

from user.models import User as UserModel
from user.serializers import UserSerializer

from rest_framework.permissions import IsAuthenticated

from user.jwt_claim_serializer import SeasonTokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.authentication import JWTAuthentication

# TokenObtainPairView : urls.py에서 import했고, 토큰을 발급받기 위해 사용
class SeasonTokenObtainPairView(TokenObtainPairView):
    # serializer_class에 커스터마이징된 시리얼라이저를 넣어 준다.
    serializer_class = SeasonTokenObtainPairSerializer

class UserView(APIView):
    
    # DONE 회원 정보 조회
    def get(self, request):
        data = UserModel.objects.get(id=request.user.id)
        return Response(UserSerializer(data).data, status=status.HTTP_200_OK)

    # DONE 회원가입
    def post(self, request):
        user_serializer = UserSerializer(data=request.data)
        
        if user_serializer.is_valid():
            user_serializer.save()
            return Response(user_serializer.data, status=status.HTTP_200_OK)
        return Response(user_serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    # DONE 회원 정보 수정
    def put(self, request):
        user = UserModel.objects.get(id=request.user.id)
        user_serializer = UserSerializer(user, data=request.data, partial=True)

        if user_serializer.is_valid():
            user_serializer.save()
            return Response(user_serializer.data, status=status.HTTP_200_OK)
        return Response(user_serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    # DONE 회원 탈퇴
    def delete(self, request):
        user = UserModel.objects.get(id=request.user.id)
        if user:
            user.delete()
            return Response({"message": "회원탈퇴 성공"}, status=status.HTTP_200_OK)
        return Response({"message": "회원탈퇴 실패"}, status=status.HTTP_400_BAD_REQUEST)
      
    
# 인가된 사용자만 접근할 수 있는 View 생성
class OnlyAuthenticatedUserView(APIView):
    permission_classes = [permissions.IsAuthenticated]
		
    # JWT 인증방식 클래스 지정하기
    authentication_classes = [JWTAuthentication]

    def get(self, request):
        # Token에서 인증된 user만 가져온다.
        user = request.user
        print(f"user 정보 : {user}")
        if not user:
            return Response({"error": "접근 권한이 없습니다."}, status=status.HTTP_401_UNAUTHORIZED)
        return Response({"message": "Accepted"})
  • 그리고 아래와 같이 api.js를 작성해 백앤드와 프론트앤드를 연결해 주었다.
async function handleSignup() {
    const signupData = {
        username: document.getElementById("floatingInput").value,
        password: document.getElementById("floatingPassword").value,
        email: document.getElementById("floatingInputEmail").value,
        fullname: document.getElementById("floatingInputFullname").value,
    }

    const response = await fetch(`http://127.0.0.1:8000/user/`, {
        headers: {
            Accept: "application/json",
            'Content-type': 'application/json'
        },
        method: "POST",
        body: JSON.stringify(signupData)
    })

    response_json = await response.json()

    if (response.status == 200) {
        window.location.replace(`http://127.0.0.1:5500/login.html`)
    } else {
        alert(response.status)
    }
}

async function handleLogin() {
    const loginData = {
        username: document.getElementById("floatingInput").value,
        password: document.getElementById("floatingPassword").value,
    }

    const response = await fetch(`http://127.0.0.1:8000/user/api/token/`, {
        headers: {
            Accept: "application/json",
            'Content-type': 'application/json'
        },
        method: "POST",
        body: JSON.stringify(loginData)
    })

    response_json = await response.json()
    console.log(response_json.access)

    if (response.status == 200) {
        localStorage.setItem("access", response_json.access);
        localStorage.setItem("refresh", response_json.refresh);

        const base64Url = response_json.access.split('.')[1];
        const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
        const jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) {
            return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
        }).join(''));

        localStorage.setItem("payload", jsonPayload);
        // window.location.replace(`http://127.0.0.1:5500/index.html`)
    } else {
        alert(response.status)
    }
}
  • 로그인을 진행하면 토큰값을 아래와 같이 확인할 수 있다.

  • 이 다음으로 해야 할 작업으로는, 로그인에 해당하는 몇몇 조건들을 다는 것이다.

profile
https://github.com/nikevapormax
post-custom-banner

0개의 댓글