[day-51] JWT

Joohyung Park·2024년 3월 19일
0

[모두연] 오름캠프

목록 보기
83/95

JWT를 사용한 로그인 프로세스를 알아보자.

JWT?

정의

JSON 형식의 토큰으로, 사용자의 인증 정보를 안전하게 전송하는 데에 사용된다. 세 부분으로 구성되어 있다.

  • 헤더 : 토큰 유형과 해싱 알고리즘 정보를 담고 있음
  • 내용 : 토큰에 담길 실제 데이터로, 일반적으로 사용자 ID, 만료 시간 등의 정보를 포함
  • 서명 : 헤더와 페이로드를 비밀키로 해싱하여 생성된 값, 토큰의 무결성을 보장

사용하는 이유

  • Stateless : 서버에 세션 정보를 저장할 필요가 없어 확장성이 좋다.
  • Portable : 토큰 자체에 인증 정보가 포함되어 있어 다양한 도메인/서비스에서 사용할 수 있다.
  • Self-contained : 토큰 크기가 작고, 필요한 정보를 직접 담고 있어 추가적인 외부 데이터 소스가 필요 없다.

로그인 실습

사전 준비

# 폴더 구성 및 설치
mkdir jwt
cd jwt

python -m venv venv
# source venv/Scripts/activate
.\venv\Scripts\activate

pip install -r requirements.txt
django-admin startproject config .
python manage.py startapp accounts
  • djangorestframework : RESTful API 개발
  • dj-rest-auth : 인증 및 사용자 관리 구현(로그인, 회원가입 등)
  • django-allauth : 다양한 인증 및 회원가입 옵션을 제공
  • djangorestframework-simplejwt : JWT 인증 구현

사용자 정의 모델 생성 및 관리

프로젝트를 진행하다보면 Django의 기본 유저 모델인 auth.User를 사용할 수도 있지만 추가적으로 커스텀하고자 하는 욕구가 있을 수 있다.

이때 사용하는 것이 managers.py이고, 사용자 정의 매니저를 생성하여 모델의 객체 생성 및 관리 로직을 변경할 수 있다.

# accounts > managers.py

from django.contrib.auth.base_user import BaseUserManager
from django.utils.translation import gettext_lazy as _


class CustomUserManager(BaseUserManager):

    def create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError(_("The Email must be set"))
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save()
        return user

    def create_superuser(self, email, password, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        extra_fields.setdefault("is_active", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError(_("Superuser must have is_staff=True."))
        if extra_fields.get("is_superuser") is not True:
            raise ValueError(_("Superuser must have is_superuser=True."))
        return self.create_user(email, password, **extra_fields)
  • create_user(self, email, password, **extra_fields) : 일반 사용자를 생성하는데에 사용되며 emailpassword를 필수 입력 필드로 지정하였다.
    extra_fields에는 추가적인 사용자 필드가 포함되며 메서드 내에서 email을 정규화하고, 사용자 모델 인스턴스를 생성한 후, 비밀번호를 설정하고 저장하는 코드이다.
  • create_superuser(self, email, password, **extra_fields) : 관리자를 생성하는 데 사용하며 extra_fields를 자동으로 True로 설정한다. 최종적으로 create_user 메서드를 호출하여 슈퍼유저를 생성한다.

모델 정의

# accounts > models.py

from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _

from .managers import CustomUserManager

GENDER_CHOICES = (
    ('male', '남자'),
    ('female', '여자'),
)

class CustomUser(AbstractUser):
    username = None
    email = models.EmailField(_('email address'), unique=True)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    objects = CustomUserManager()

    gender = models.CharField(max_length=6, choices=GENDER_CHOICES, blank=True)
    date_of_birth = models.DateField(blank=True, null=True)
    

    def __str__(self):
        return self.email

User를 상속받는 방법이 2가지가 있는데 초급자의 경우는 AbstractUser를 상속받는 것이 좋다고 한다. 나머지 1개는 BaseUser인데 처음부터 전부 만들어야 하기 때문이다.

objects = CustomUserManager() 는 이전에 정의한 CustomUserManager를 사용자 모델의 객체 관리자로 설정한다는 의미이다.

유저 이름 대신 이메일을 사용하도록 하고 있으며, 성별 선택 옵션, 생년월일을 추가해 주었다.

세팅 수정(settings.py)

accounts 앱을 추가하고, AUTH_USER_MODEL = "accounts.CustomUser" 라는 코드를 추가하여 사용자 정의 User를 사용할 것임을 명시한다.

이후, 마이그레이트를 진행한다.

추가 세팅 수정

  • admin 페이지에 모델 등록
# accounts > admin.py

from django.contrib import admin
from accounts.models import CustomUser

admin.site.register(CustomUser)
  • 설치된 라이브러리 추가
INSTALLED_APPS = [
...
    # 설치한 라이브러리들
    'rest_framework',
    'rest_framework.authtoken',
    'dj_rest_auth',
    'django.contrib.sites',
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'dj_rest_auth.registration',
...
]
# JWT 설정 추가
# settings.py 맨 아래
from datetime import timedelta

# dj-rest-auth
REST_USE_JWT = True # JWT 사용 여부
JWT_AUTH_COOKIE = 'my-app-auth' # 호출할 Cookie Key 값
JWT_AUTH_REFRESH_COOKIE = 'my-refresh-token' # Refresh Token Cookie Key 값

# django-allauth
SITE_ID = 1 # 해당 도메인 id
ACCOUNT_UNIQUE_EMAIL = True # User email unique 사용 여부
ACCOUNT_USER_MODEL_USERNAME_FIELD = None # 사용자 이름 필드 지정
ACCOUNT_USERNAME_REQUIRED = False # User username 필수 여부
ACCOUNT_EMAIL_REQUIRED = True # User email 필수 여부
ACCOUNT_AUTHENTICATION_METHOD = 'email' # 로그인 인증 수단
ACCOUNT_EMAIL_VERIFICATION = 'none' # email 인증 필수 여부

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),  # AccessToken 유효 기간 설정
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),  # RefreshToken 유효 기간 설정
}

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}

ACCESS 토큰의 지속 시간을 테스트용으로 5분으로 바꾼후 정상 작동하면 60분으로 바꾸는 식으로 한다고 한다.

REFRESH 토큰의 경우 재인증시 필요한 토큰인데 시간을 줄이면 더 높은 보안을 유지할 수 있다.

항상 이렇게 전부 정의할 필요는 없고 바꾸고자 하는 부분만 정의하면 된다.

이후 마이그레이트를 진행한다.

URL 정의

config앱과 accounts앱의 url을 정의한다.

# config > urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("accounts.urls")),
]
# accounts > urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("join/", include("dj_rest_auth.registration.urls")),
    path("", include("dj_rest_auth.urls")),
]

이 부분은 직접 선언한 것이 아닌 dj_rest_auth에서 제공하는 회원가입, 인증 관련 URL을 포함시킨 것이다.

서버 실행

만약, 서버 실행 후 No module named 'pkg_resources'에러를 마주친다면 pip install --upgrade setuptoolspip install --upgrade distribute를 해주자.

이메일로 회원가입, 로그인 시 정상 작동함을 볼 수 있다.

accounts앱 정의

accounts앱의 url과 view를 정의해야 한다.

# accounts > urls.py

from django.contrib import admin
from django.urls import path, include
from .views import example_view

urlpatterns = [
    path("test/", example_view),
    path("join/", include("dj_rest_auth.registration.urls")),
    path("", include("dj_rest_auth.urls")),
]

accounts/test 경로로 접속하면 example_view 함수가 실행된다.

# accounts > views.py

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response


@api_view(["GET"])
@permission_classes([IsAuthenticated])
def example_view(request):
    # request.user는 인증된 사용자의 정보를 담고 있습니다.
    print(request.data)
    content = {"message": "Hello, World!", "user": str(request.user)}
    return Response(content)

GET 요청이고 인증된 사용자라면 응답 데이터를 content라는 딕셔너리로 생성하고 Response클래스를 사용하여 응답을 JSON 형식으로 반환한다.

CROS 패키지 설치

pip install django-cors-headers

둘 이상의 도메인 간에 리소스를 공유해야 하는 경우에 사용하는 패키지이다.

Django 프로젝트의 settings.py 파일에 특정 도메인에서만 리소스에 접근할 수 있도록 허용하거나, 특정 HTTP 메서드에 대해서만 CORS를 허용하도록 설정할 수 있다.

INSTALLED_APPS = [
...
    'corsheaders',
...
]

MIDDLEWARE = [
...
    'corsheaders.middleware.CorsMiddleware',
    
]

CORS_ALLOW_ALL_ORIGINS = True

CORS_ALLOW_ALL_ORIGINS 설정은 모든 출처(origin)에서 Django 서버로의 CORS 요청을 허용한다는 뜻이다. 실제 서비스에서는 특정 출처만 허용하는 것이 좋다고 한다.

로그인 구현

# accounts > login.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>login</title>
</head>
<body>
    <form action="" method="">
        이메일 : <input type="text" name="email"><br>
        패스워드 : <input type="password" name="password"><br>
        <input id="login" type="button" value="로그인">
    </form>
    <script>
        const login = document.querySelector('#login');
        login.addEventListener('click', (e) => {
            e.preventDefault(); // submit의 기본동작을 막는다.
            const email = document.querySelector('input[name="email"]').value;
            const password = document.querySelector('input[name="password"]').value;
            const data = {
                email: email,
                password: password
            }
            console.log(data)


            // fetch를 이용해서 서버에 POST 요청을 보낸다.
            fetch('http://127.0.0.1:8000/accounts/login/', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data)
            })
            .then(response => response.json())
            .then(data => {
                console.log(data)
            })

            // 로그인이 되는 로직 100줄

            // form을 없애는 코드
            // document.querySelector('form').remove();
            // document.write('이호준님 환영합니다!')

            // 또는 /home으로 리다이렉트 되는 코드
            // 리다이렉트 될 때 주의할 점: 토큰 값은 어딘가에 유지가 되고 있어야 로그인을 확인할 수 있습니다.
            // window.location.href = 'http://....
        })
    </script>
</body>
</html>

이메일과 비밀번호로 로그인을 할 수 있는 간단한 폼이 구현되어 있다.

JavaScript 부분에서는 로그인 버튼을 누르면 실행되는 여러가지를 정의하고 있다.

e.preventDefault()로 폼의 submit 동작을 막고 있다.

JavaScript에서 직접 비동기 통신을 하기 위함인데 이를 통해 페이지 새로고침 없이 데이터를 전송하고 서버 응답을 받아 처리할 수 있다.

폼에서 이메일과 비밀번호를 data로 받고 fetch로 Django에 POST 요청을 보낸다.

JSON.stringify(data)로 서버와 통신을 위해 data를 JSNO 형식으로 바꿔준다.

fetch 함수를 사용하여 서버로 로그인 요청을 보내고 응답을 받으면 then 메서드로 응답(Promise)을 JSON 형식으로 파싱한다. 이후, 콘솔창에 출력하는 코드이다.

회원가입 구현

# register.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>register</title>
</head>
<body>
    <form action="http://127.0.0.1:8000/accounts/join/" method="post">
        이메일 : <input type="text" name="email"><br>
        패스워드1 : <input type="password" name="password1"><br>
        패스워드2 : <input type="password" name="password2"><br>
        <input type="button" value="회원가입">
    </form>
    <script>
        const register = document.querySelector('input[type="button"]');
        register.addEventListener('click', (e) => {
            e.preventDefault(); // submit의 기본동작을 막는다.
            const email = document.querySelector('input[name="email"]').value;
            const password1 = document.querySelector('input[name="password1"]').value;
            const password2 = document.querySelector('input[name="password2"]').value;
            const data = {
                email: email,
                password1: password1,
                password2: password2
            }
            console.log(data)
            fetch('http://127.0.0.1:8000/accounts/join/', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data)
            }).then(response => response.json())
            .then(data => {
                console.log(data)
            })
        })
    </script>
</body>
</html>

일반적으로 폼 데이터를 서버로 전송하려면 두 가지 방법이 있다.

  1. HTML 폼 태그를 사용하여 데이터를 제출
  • action 속성에 서버의 URL을 , method 속성에 HTTP 메서드를 지정하면, 폼을 제출할 때 해당 URL로 데이터가 전송됨
  1. JavaScriptfetch 또는 XMLHttpRequest를 사용하여 비동기적으로 데이터를 전송하는 방법
  • 사용자 경험이 향상되며 동적인 웹 구현이 가능

위 코드에서는 2가지 방식을 모두 보여주고 있다.
2번 방식의 script는 로그인 방식과 유사하다.

게시글 작성 구현

  • blog 앱 생성
  • settings.py에 blog 추가
  • 모델 생성
# blog > models.py

from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=100)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title
  • url 추가(config, blog)
  • view 구현
# blog > views.py
# DRF로 FBV 작성
# post_list: GET(비회원), POST(회원)

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Post

@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def post_list(request):
    if request.method == "GET":
        posts = Post.objects.all()
        content = {"posts": [{"title": post.title, "content": post.content} for post in posts]}
        return Response(content)
    elif request.method == "POST":
        print(request.data)
        title = request.data["title"]
        content = request.data["content"]
        post = Post.objects.create(title=title, content=content)
        post.save()
        return Response({"message": "글 작성 완료!"})

GET 요청이면 모든 게시글 객체를 가져와 제목과 내용을 딕셔너리로 변환한 후, Response 객체로 응답한다.

Post 요청이면 요청 데이터에서 제목과 내용을 추출하여 새로운 Post객체를 생성한 후 저장한다. 성공 메세지를 Response 객체로 응답하고 있다.

  • admin 페이지에 추가
  • 관리자 계정 생성
  • 마이그레이트
  • 게시글 생성 테스트
# writer.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>write</title>
</head>
<body>
    <!-- 해당 코드는 셈플 코드 입니다. -->
    <form action="" method="">
        email : <input type="text" name="email"><br>
        패스워드 : <input type="password" name="password"><br>
        <input id="login" type="button" value="로그인">
    </form>
    <form action="" method="">
        title: <input type="text" name="title"><br>
        content: <input type="text" name="content"><br>
        <input id="write" type="button" value="게시물작성">
    </form>
    <script>
        const login = document.querySelector('#login');
        const write = document.querySelector('#write');

        login.addEventListener('click', (e) => {
            e.preventDefault(); // submit의 기본동작을 막는다.
            const email = document.querySelector('input[name="email"]').value;
            const password = document.querySelector('input[name="password"]').value;
            const data = {
                email: email,
                password: password
            }
            console.log(data)

            // fetch를 이용해서 서버에 POST 요청을 보낸다.
            fetch('http://127.0.0.1:8000/accounts/login/', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data)
            })
            .then(response => response.json())
            .then(data => {
                console.log(data)
                localStorage.setItem('access_token', data.access_token)
                localStorage.setItem('refresh_token', data.refresh_token)
            })

        })


        write.addEventListener('click', (e) => {
            e.preventDefault(); // submit의 기본동작을 막는다.
            const title = document.querySelector('input[name="title"]').value;
            const content = document.querySelector('input[name="content"]').value;
            const data = {
                title: title,
                content: content
            }
            console.log(data)
            const token = localStorage.getItem('access_token')

            if (token){
                // fetch를 이용해서 서버에 POST 요청을 보낸다.
                fetch('http://127.0.0.1:8000/blog/list/', {
                    method: 'POST',
                    headers: {
                        Authorization: `Bearer ${token}`,
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(data)
                })
                .then(response => response.json())
                .then(data => {
                    console.log(data)
                })
            } else {
                alert('로그인이 필요합니다.')
            }
        })
    </script>
</body>
</html>
  1. 로그인 버튼을 누르면 이메일과 비밀번호 값을 가져와 서버에 POST 요청을 보낸다. 서버로부터 받은 데이터에서 두 토큰을 로컬 스토리지에 저장한다.
  • 여기서 로컬 스토리지는 웹 브라우저가 제공하는 저장소 중 하나로, 웹 앱의 데이터를 사용자의 컴퓨터에 저장하게 해 준다. 이러한 데이터는 브라우저 세션이 종료되어도 삭제되지 않는다.
  • 삭제하는 코드를 작성해야 삭제된다. 용량은 5MB 정도이고 데이터는 키-값 쌍으로 저장된다.
// 데이터 저장
localStorage.setItem('user', 'John Doe');

// 데이터 조회
const user = localStorage.getItem('user');
console.log(user); // 'John Doe'

// 데이터 삭제
localStorage.removeItem('user');

// 전체 삭제
localStorage.clear();
  1. Write를 누르면 제목과 내용을 가져와 서버에 POST 요청을 보낸다. 이때, 요청 헤더에 access 토큰을 조회한 후 토큰을 포함하여 인증 정보를 전송한다.

참고

참고

profile
익숙해지기 위해 기록합니다

0개의 댓글