JWT를 사용한 로그인 프로세스를 알아보자.
JSON 형식의 토큰으로, 사용자의 인증 정보를 안전하게 전송하는 데에 사용된다. 세 부분으로 구성되어 있다.
# 폴더 구성 및 설치
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)
: 일반 사용자를 생성하는데에 사용되며 email
과 password
를 필수 입력 필드로 지정하였다.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
를 사용자 모델의 객체 관리자로 설정한다는 의미이다.
유저 이름 대신 이메일을 사용하도록 하고 있으며, 성별 선택 옵션, 생년월일을 추가해 주었다.
accounts 앱을 추가하고, AUTH_USER_MODEL = "accounts.CustomUser"
라는 코드를 추가하여 사용자 정의 User를 사용할 것임을 명시한다.
이후, 마이그레이트를 진행한다.
# 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
토큰의 경우 재인증시 필요한 토큰인데 시간을 줄이면 더 높은 보안을 유지할 수 있다.
항상 이렇게 전부 정의할 필요는 없고 바꾸고자 하는 부분만 정의하면 된다.
이후 마이그레이트를 진행한다.
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 setuptools
와 pip install --upgrade distribute
를 해주자.
이메일로 회원가입, 로그인 시 정상 작동함을 볼 수 있다.
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 형식으로 반환한다.
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>
일반적으로 폼 데이터를 서버로 전송하려면 두 가지 방법이 있다.
action
속성에 서버의 URL을 , method
속성에 HTTP 메서드를 지정하면, 폼을 제출할 때 해당 URL로 데이터가 전송됨JavaScript
의 fetch
또는 XMLHttpRequest
를 사용하여 비동기적으로 데이터를 전송하는 방법위 코드에서는 2가지 방식을 모두 보여주고 있다.
2번 방식의 script는 로그인 방식과 유사하다.
# 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
# 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
객체로 응답하고 있다.
# 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>
로그인
버튼을 누르면 이메일과 비밀번호 값을 가져와 서버에 POST
요청을 보낸다. 서버로부터 받은 데이터에서 두 토큰을 로컬 스토리지
에 저장한다.로컬 스토리지
는 웹 브라우저가 제공하는 저장소 중 하나로, 웹 앱의 데이터를 사용자의 컴퓨터에 저장하게 해 준다. 이러한 데이터는 브라우저 세션이 종료되어도 삭제되지 않는다. // 데이터 저장
localStorage.setItem('user', 'John Doe');
// 데이터 조회
const user = localStorage.getItem('user');
console.log(user); // 'John Doe'
// 데이터 삭제
localStorage.removeItem('user');
// 전체 삭제
localStorage.clear();
Write
를 누르면 제목과 내용을 가져와 서버에 POST
요청을 보낸다. 이때, 요청 헤더에 access
토큰을 조회한 후 토큰을 포함하여 인증 정보를 전송한다.