백엔드를 DRF로 구현하는 경우에도, simplejwt를 이용해 JWT인증방식을 서비스에 적용할 수 있습니다.
먼저 터미널에 아래와 같이 입력하여 simplejwt를 다운받을 수 있습니다.
pip install djangorestframework-simplejwt
이후, settings.py파일에서 몇 가지 설정을 해주어야 합니다.
먼저 INSTALLED_APPS에 simplejwt를 추가해줍니다.
rest_framework_simplejwt
라는 이름으로 추가해주세요.
로그아웃 기능을 구현하기 위해 필요한 rest_framework_simplejwt.token_blacklist
도 같이 추가해주겠습니다.
불꽃 이모티콘으로 표시해둔 두 부분을 추가해주시면 됩니다.
INSTALLED_APPS = [
"corsheaders",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"myblog",
"blog_api",
"rest_framework",
🔥'rest_framework_simplejwt',🔥
"user",
🔥"rest_framework_simplejwt.token_blacklist",🔥
]
이후, REST_FRAMEWORK설정 변수 안에, DEFAULT_AUTHENTICATION_CLASSES
관련 설정을 입력해줍니다. 아래와 같이 입력해주세요.
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
}
'DEFAULT_AUTHENTICATION_CLASSES'
에 simplejwt를 명시해줌으로써, 저희 서비스는 JWT를 사용하여 유저를 인증하게 됩니다.
이후, settings.py파일에 아래의 내용을 추가해줍니다.
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"UPDATE_LAST_LOGIN": False,
"ALGORITHM": "HS256",
# "SIGNING_KEY": settings.SECRET_KEY,
"VERIFYING_KEY": "",
"AUDIENCE": None,
"ISSUER": None,
"JSON_ENCODER": None,
"JWK_URL": None,
"LEEWAY": 0,
"AUTH_HEADER_TYPES": ("Bearer", "JWT"),
"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),
"TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
"TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
"TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
"TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
"SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
"SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
}
많은 설정 중, 아래의 내용들은 따로 간단히 살펴보겠습니다.
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": False,
사용자가 처음 로그인을 하면, 사용자의 인증 정보를 가진 두 개의 토큰이 생성됩니다. 각각은 액세스 토큰, 리프레쉬 토큰이라 불립니다.
사용자는 access토큰을 사용하여, 서비스 내에서 이동하며 매번 로그인을 할 필요 없이 서버에 자신의 신원을 인증할 수 있습니다.
예를 들어, 여러분이 처음 로그인을 하고, 채팅 기능을 눌렀더니 또 다시 로그인을 해야하는 대신, 로그인 절차를 생략하고 서버에게 토큰을 보여주며 "나 검증된 유저니까 채팅기능 허용해줘" 라고 신원인증을 받는 것입니다.
일반적으로 액세스 토큰의 유효시간은 짧게 설정하고, 리프레쉬 토큰은 유효시간을 하루 정도로 길게 설정하여, refresh토큰의 유효시간이 만료되기 전에 계속 access토큰을 갱신할 수 있도록 하는 구조입니다.
위의 처음 두 줄의 명령어를 통해 각 토큰의 유효시간을 정해줄 수 있습니다.
"ROTATE_REFRESH_TOKENS"의 경우, 액세스 토큰을 갱신할 때, 리프레시 토큰의 유효시간을 다시 갱신 이후의 시점부터 24시간으로(제 설정의 경우) 변경할 것인지를 설정합니다.
simplejwt의 초기 설정 내용은 모두 공식문서를 참고하였습니다.
https://django-rest-framework-simplejwt.readthedocs.io/en/latest/getting_started.html
이제, simplejwt에서 제공하는 토큰 기능을 사용하기 위해 root의 urls.py에 아래와 같이 토큰 생성 및 갱신 엔트포인트를 생성해줍니다.
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path("admin/", admin.site.urls),
path('', include('myblog.urls')),
path('api/', include('blog_api.urls')),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/user/', include("user.urls"))
]
토큰의 생성을 담당하는 TokenObtainPairView
와, 토큰의 갱신을 담당하는 TokenRefreshView
는 rest_framework_simplejwt에서 기본으로 제공해주기에, 별도의 구현 없이 불러와 엔드포인트만 지정해주면 됩니다.
앞으로 프론트엔드에선, 회원가입을 하면 axios의 요청은 api/token
으로 보내야 합니다. 그러면, TokenObtainPairView가 해당 유저에 맞는 고유 토큰을 부여해줍니다.
서비스에 맞는 커스텀 유저 모델을 생성해주었습니다.
데이터베이스의 경우 postgreSQL을 사용하였으며, DB 연동 및 기타 설정들은 별도의 포스팅에서 자세히 다뤄두었으니, 간략히 코드만 첨부하겠습니다.
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
#유저 모델 매니저
class TravelCommunityUserManager(BaseUserManager):
def create_user(self, email, user_name, first_name, password, **other_fields):
if not email:
raise ValueError(_("이메일 주소는 반드시 입력하셔야 합니다."))
email = self.normalize_email(email)
user = self.model(email=email, user_name=user_name, first_name=first_name, **other_fields)
user.set_password(password)
user.save()
return user
def create_superuser(self, email, user_name, first_name, password, **other_fields):
other_fields.setdefault('is_admin', True)
other_fields.setdefault('is_staff', True)
other_fields.setdefault('is_active', True)
if other_fields.get('is_admin') is not True or other_fields.get('is_superuser') is not True:
raise ValueError("관리자 권한이 없습니다.")
return self.create_user(email=email, user_name=user_name, first_name=first_name, password=password, **other_fields)
#유저 모델 스키마
class TravelCommunityUser(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(_('enter your email here'), unique=True)
user_name = models.CharField(max_length=50, unique=True)
first_name = models.CharField(max_length=20, blank=True)
last_name = models.CharField(max_length=100)
joined_date = models.DateField(default=timezone.now)
about_this_user = models.TextField(max_length=1000, blank=True)
is_admin = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
is_premium_member = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False)
password = models.CharField()
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['user_name', 'first_name']
objects = TravelCommunityUserManager()
def __str__(self):
return self.user_name
serializer도 다음과 같이 생성해줍니다.
마찬가지로, 시리얼라이저 역시 다른 포스팅에서 자세히 다뤄두었습니다.
from rest_framework import serializers
from user.models import TravelCommunityUser
class UserJoinInSerializer(serializers.ModelSerializer):
class Meta:
model = TravelCommunityUser
fields = ["email", "user_name", "password"]
#비밀번호는 클라이언트 요청 json에만 포함. 서버 respose json에 미포함
extra_kwargs = {"password": {"write_only" : True}}
def create(self, validated_data):
password = validated_data.pop("password", None)
user_instance = self.Meta.model(**validated_data)
if password:
user_instance.set_password(password)
user_instance.save()
return user_instance
serializer생성은 커스텀 모델 생성 포스팅에서 다루었으니,
지금은 아래의 코드만 따로 살펴보겠습니다.
extra_kwargs = {"password": {"write_only" : True}}
다음과 같이, password속성에 대해, write_only의 값을 True로 주어
사용자는 비밀번호를 서버로 적어 보낼 수만 있고, 서버로부터 비밀번호를 받아올 수 없습니다.
즉, 브라우저와 서버가 통신하며 주고받는 response객체에, 비밀번호는 브라우저에서 서버로 보낼 시에만 일방향적으로 포함되도록 설정한 것입니다.
regiser함수의 두 번째 인자에 커스텀 클래스를 전달하여 어드민 페이지를 커스텀할 수 있습니다. 저의 경우, 장고에서 디폴트로 제공하는 유저 스키마를 사용하지 않았기에, 제 커스텀 유저 모델에 맞게 어드민 페이지를 수정한 것입니다.
from django.contrib import admin
from .models import TravelCommunityUser
from django.contrib.auth.admin import UserAdmin
class CustomTravelCommunityUserAdminInterface(UserAdmin):
ordering = ["-joined_date"]
list_display = ["email", "user_name", "first_name", "last_name", "joined_date", "about_this_user", "is_admin", "is_active", "is_premium_member", "is_staff"]
search_fields = ["email", "user_name", "first_name", "last_name", "joined_date", "about_this_user"]
admin.site.register(TravelCommunityUser, CustomTravelCommunityUserAdminInterface)
백엔드와의 통신을 위해 axios를 사용하겠습니다.
터미널에 다음과 같이 입력하여 다운받겠습니다.
npm install axios
회원 가입 기능을 구현해보겠습니다.
우선 리액트 프로젝트의 전체적인 폴더 구조는 다음과 같습니다.
axios.js 라는 이름의 파일에, 앞으로 사용할 axios객체를 만들어 공통으로 적용할 설정들을 작성해주겠습니다.
전체 코드는 아래와 같습니다.
import axios from "axios";
const baseURL = "http://127.0.0.1:8000/api/";
const axiosInstance = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
//django 설정 파일에서 simple jwt header type에 JWT주었음
Authorization: localStorage.getItem("access_token")
? "JWT " + localStorage.getItem("access_token")
: null,
"Content-Type": "application/json",
accept: "application/json",
},
});
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async function (error) {
const originalRequest = error.config;
if (typeof error.response === "undefined") {
alert("서버 장애가 발생했습니다. 빠른 시일 내에 해결하겠습니다");
return Promise.reject(error);
}
if (
error.response.status === 401 &&
originalRequest.url === baseURL + "token/refresh/"
) {
window.location.href = "/login/";
return Promise.reject(error);
}
if (
error.response.data.code === "token_not_valid" &&
error.response.status === 401 &&
error.response.statusText === "Unauthorized"
) {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const tokenParts = JSON.parse(atob(refreshToken.split(".")[1]));
// exp date in token is expressed in seconds, while now() returns milliseconds:
const now = Math.ceil(Date.now() / 1000);
console.log(tokenParts.exp);
if (tokenParts.exp > now) {
return axiosInstance
.post("/token/refresh/", { refresh: refreshToken })
.then((response) => {
localStorage.setItem("access_token", response.data.access);
localStorage.setItem("refresh_token", response.data.refresh);
axiosInstance.defaults.headers["Authorization"] =
"JWT " + response.data.access;
originalRequest.headers["Authorization"] =
"JWT " + response.data.access;
return axiosInstance(originalRequest);
})
.catch((err) => {
console.log(err);
});
} else {
console.log("Refresh token is expired", tokenParts.exp, now);
window.location.href = "/login/";
}
} else {
console.log("Refresh token not available.");
window.location.href = "/login/";
}
}
// specific error handling done elsewhere
return Promise.reject(error);
}
);
export default axiosInstance;
중요한 것은, baseURL을 지정해두었기에, 만일 axiosInstance
객체를 이용하여 `user/register/`
로 요청을 보내도록 코드를 구현했다면, 실제로는 `http://127.0.0.1:8000/api/user/register/`
로 요청을 보내는 것입니다.
핵심인 부분만 다시 살펴보면,
import axios from "axios";
const baseURL = "http://127.0.0.1:8000/api/";
const axiosInstance = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
//django 설정 파일에서 simple jwt header type에 JWT주었음
Authorization: localStorage.getItem("access_token")
? "JWT " + localStorage.getItem("access_token")
: null,
"Content-Type": "application/json",
accept: "application/json",
},
});
export default axiosInstance;
axios.create
명령어를 사용하여 공통으로 적용될 설정을 포함한 axios객체를 만들어 추출하고 있습니다. 이후, 백엔드로 요청을 보낼 때, axios.post요청 대신, axiosInstance.post
로 접근하겠습니다.
이렇게 함으로써, 반복되는 코드의 재작성을 줄일 수 있습니다.
headers: {
//django 설정 파일에서 simple jwt header type에 JWT주었음
Authorization: localStorage.getItem("access_token")
? "JWT " + localStorage.getItem("access_token")
: null,
"Content-Type": "application/json",
accept: "application/json",
},
이후, 요청 header에 사용자 Authorization과 관련된 내용을 작성해줍니다.
access_token이 있다면, "JWT "
라는 문자열을 토큰의 접두사로 사용하여 JWT방식으로 인증하고 있음을 명시하도록 코드를 작성해주어야 합니다.
중요한 점은, JWT접두사와 토큰 사이에 하나의 공백이 존재해야 한다는 것입니다.
이에 따라, "JWT"
가 아닌, "JWT "
형태의 문자열을 토큰에 병합해야 합니다.
"Content-Type" : "application/json"
의 경우, 클라이언트가 서버로 보내는 요청 데이터의 형식이 json객체임을 나타냅니다.
accept: "application/json",
의 경우, 서버가 보내는 응답 객체의 데이터 형식을 json 객체로 해달라는 의미입니다.
앞서 axios.js파일에서 생성해둔 axiosInstance 객체를 사용해
장고 백엔드에 여러 요청을 보내보겠습니다.
프론트엔드 단에서 장고 서버로 새로운 유저를 DB에 등록해달라는 요청을 보내는 전체 코드는 다음과 같습니다.
import axiosInstance from "../axios.js";
import { useNavigate } from "react-router-dom";
import React, { useState } from "react";
import styled from "styled-components";
//커스텀 스타일 컴포넌트 정의
const Wrapper = styled.form`
background-color: black;
color: white;
display: flex;
flex-direction: column;
gap: 15px;
//배경색 화면 전체에 차게
min-height: 100vh;
justify-content: center;
align-items: center;
padding-top: 30px;
`;
const Input = styled.input`
padding: 5px 20px;
border: 2px solid white;
border-radius: 20px;
background-color: black;
color: white;
font-size: 28px;
`;
//회원가입
export default function Register() {
const navigate = useNavigate();
const initialFormData = Object.freeze({
email: "",
user_name: "",
password: "",
});
const [formData, setFormData] = useState(initialFormData);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value.trim(),
});
};
const handleSubmit = (e) => {
e.preventDefault();
axiosInstance
.post(`user/register/`, {
email: formData.email,
user_name: formData.user_name,
password: formData.password,
})
.then((res) => {
navigate("/login");
console.log(res);
});
};
return (
<Wrapper onSubmit={handleSubmit}>
<h1 style={{ fontSize: "80px", marginBottom: "10px" }}>회원가입</h1>
<Input
placeholder="이메일을 입력해주세요"
type="email"
name="email"
required
onChange={handleChange}
/>
<Input
placeholder="사용자 이름을 입력해주세요"
type="text"
name="user_name"
required
onChange={handleChange}
/>
<Input
placeholder="비밀번호를 입력해주세요"
type="password"
name="password"
required
onChange={handleChange}
/>
<Input type="button" onClick={handleSubmit} value={"회원가입 하기"} />
</Wrapper>
);
}
프론트엔드에서 axios는 백엔드의 `user/register/`
경로로 새로운 회원 정보를 DB에 넣어달라는 요청을 보내고 있습니다.
const handleSubmit = (e) => {
e.preventDefault();
axiosInstance
.post(`user/register/`, {
email: formData.email,
user_name: formData.user_name,
password: formData.password,
})
.then((res) => {
navigate("/login");
console.log(res);
});
};
사용자가 로그인 하기 버튼을 누르거나 엔터를 입력하여 form이 제출되면,
handleSubmit함수는 axiosInstance.post()
메소드를 동작시킵니다.
post함수의 첫 인자로는 데이터를 전송할 백엔드의 엔드포인트(경로)를 적어줍니다. user/register/
로 요청을 보내고 있지만, 실제로는 baseURL과 결합하여`http://127.0.0.1:8000/api/user/register/`
로 요청을 보내는 것입니다.
`http://127.0.0.1:8000/api/user/register/`
로 보내진 요청은 누가, 어디서 받아주는지 확인하기 위해 장고의 root url파일을 살펴보겠습니다.
#root urls.py
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path("admin/", admin.site.urls),
path('', include('myblog.urls')),
path('api/', include('blog_api.urls')),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/user/', include("user.urls"))
]
로컬호스트 주소/api/user
이후의 엔드포인트는 user.urls에 명시되어 있는 것을 확인할 수 있습니다.
user app의 urls.py파일은 아래와 같이 작성하였습니다.
#user app urls.py
from django.urls import path
from .views import CustomUserCreate, BlacklistTokenView
urlpatterns = [
path('register/', CustomUserCreate.as_view(), name="create_user"),
path('logout/blacklist/', BlacklistTokenView.as_view(), name="blacklist")
]
그렇다면, 클라이언트에서 보낸 회원가입 요청은 CustomUserCreate
라는 view에서 처리해주도록 구현하면 됩니다.
아래와 같이, 회원가입 로직을 수행하는 코드를 작성해줍니다.
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import UserJoinInSerializer
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
class CustomUserCreate(APIView):
permission_classes = [AllowAny]
def post(self, request):
serializer = UserJoinInSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
if user:
json = serializer.data
return Response(json, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
유효성 검증을 거친 데이터는 save()
메소드를 통해 DB에 저장됩니다.
DRF의 모든 뷰는 Response, HttpResponse, StreamingHttpResponse중 하나의 값을 반환해야 합니다. 그렇지 않으면 에러가 발생하니, 유효성 검증을 통과한 경우와 통과하지 못한 두 경우 모두 Response객체를 반환하도록 코드를 작성해야 합니다.
회원가입 기능을 구현했다면, 이젠 로그인 기능을 구현할 차례입니다.
전체적인 소스코드는 다음과 같습니다.
import axiosInstance from "../axios.js";
import { useNavigate } from "react-router-dom";
import React, { useState } from "react";
//회원가입
export default function Login() {
const navigate = useNavigate();
const initialFormData = Object.freeze({
email: "",
password: "",
});
const [formData, setFormData] = useState(initialFormData);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value.trim(),
});
};
const handleSubmit = (e) => {
e.preventDefault();
axiosInstance
.post("token/", {
email: formData.email,
password: formData.password,
})
.then((res) => {
localStorage.setItem("access_token", res.data.access);
localStorage.setItem("refresh_token", res.data.refresh);
//새로운 토큰으로 헤더 업데이트
axiosInstance.defaults.headers["Authorization"] =
"JWT " + localStorage.getItem("access_token");
navigate("/");
});
};
return (
<>
<form onSubmit={handleSubmit}>
<input
placeholder="이메일을 입력해주세요"
type="email"
name="email"
required
onChange={handleChange}
/>
<input
placeholder="비밀번호를 입력해주세요"
type="password"
name="password"
required
onChange={handleChange}
/>
<input type="button" value={"로그인하기"} onClick={handleSubmit} />
</form>
</>
);
}
회원가입 로직과 전반적으로 매우 유사합니다.
차이가 있는 부분만 따로 살펴보겠습니다.
const handleSubmit = (e) => {
e.preventDefault();
axiosInstance
.post("token/", {
email: formData.email,
password: formData.password,
})
.then((res) => {
localStorage.setItem("access_token", res.data.access);
localStorage.setItem("refresh_token", res.data.refresh);
//새로운 토큰으로 헤더 업데이트
axiosInstance.defaults.headers["Authorization"] =
"JWT " + localStorage.getItem("access_token");
navigate("/");
});
};
우선, 백엔드로의 로그인 요청을 보내는 경로는 "token/"
으로 설정해주어야 합니다.
해당 엔드포인트로 요청을 보내면 simplejwt의 TokenObtainPairView
가 유저에 맞는 access 및 refresh 토큰을 생성해줍니다.
이후, 발급받은 토큰들을 브라우저의 로컬 스토리지에 각각 access_token, refresh_token 이라는 key값에 매칭되도록 저장해줍니다.
이후, axiosInstance의 헤더의 인증정보(Authorization)을 각 유저의 고유 토큰에 맞게 변경해주어야 합니다.
유저의 토큰은 로컬 스초리지에 보관되어 있으니, getItem("키값")
으로 접근하여 값을 받아올 수 있습니다.
로그인의 경우, 프론트에서 token경로로 요청을 보내고 있습니다.
장고 백엔드의 root urls를 보니, 해당 경로를 simplejwt에서 제공하는 뷰에서 처리해주고 있으니, 따로 구현할 필요는 없다는 것을 확인할 수 있습니다.
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path("admin/", admin.site.urls),
path('', include('myblog.urls')),
path('api/', include('blog_api.urls')),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/user/', include("user.urls"))
]
로그아웃이란 것을 컴퓨터의 관점에서 생각해보면,
인증된 현재의 유저를 인증되지 않은 유저로 변경하는 것입니다.
저희 서비스의 경우, 토큰을 이용하여 사용자를 인증하는 방식을 채택했습니다.
즉, 사용자의 모든 인증정보는 토큰에 들어있습니다.
그렇다면, 특정 유저를 로그아웃 시키기 위한 절차는 다음과 같이 생각해볼 수 있습니다.
- 해당 유저의 토큰을 해당 유저에게서 빼앗는다
- 빼앗은 그 토큰을 다른 사용자가 사용할 수 없도록 무효한 토큰으로 만든다 (블랙리스트 토큰으로 만들어 취득해도 사용할 수 없도록 한다)
우선, 로그아웃 요청을 보내는 클라이언트 측의 전체적인 소스코드는 다음과 같습니다.
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
import axiosInstance from "../axios";
export default function Logout() {
const navigate = useNavigate();
const handleClick = () => {
//url은 django user urls참고
const response = axiosInstance.post("user/logout/blacklist/", {
refresh_token: localStorage.getItem("refresh_token"),
});
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
//axios객체 헤더 토큰 제거
axiosInstance.defaults.headers["Authorization"] = null;
navigate("/login");
};
return (
<div>
<button onClick={handleClick}>로그아웃</button>
</div>
);
}
특징적인 코드만 살펴보면,
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
//axios객체 헤더 토큰 제거
axiosInstance.defaults.headers["Authorization"] = null;
다음과 같이 로컬스토리지에 저장된 access, refresh토큰을 삭제하고,
유저의 인증헤더에 저장된 JWT 인증 문자열도 지워주고 있습니다.
프론트엔드에서는 "user/logout/blacklist/"
경로로 로그아웃 요청을 보내고 있고, 백엔드에서는 BlacklistTokenView
가 해당 요청을 받아 처리합니다.
#로그아웃시 토큰 비활성화
class BlacklistTokenView(APIView):
permission_classes = [AllowAny]
def post(self, request):
try:
refresh_token = request.data["refresh_token"]
token = RefreshToken(refresh_token)
token.blacklist()
return Response({"message": "Token blacklisted successfully"}, status=status.HTTP_200_OK)
except Exception as e:
return Response(status=status.HTTP_400_BAD_REQUEST)
특징적인 코드만 살펴보면,
refresh_token = request.data["refresh_token"]
token = RefreshToken(refresh_token)
token.blacklist()
프론트엔드에서 보낸 리프레쉬 토큰을 삭제하고, 블랙리스트에 올려 더이상 사용할 수 업도록 하고 있음을 확인할 수 있습니다.
마치, 분실신고 된 카드는 취득해도 사용할 수 없게 하는 것과 비슷하다는 느낌이 듭니다.
예제 코드는 상당 부분 해당 튜토리얼을 참고하였습니다.
다만, 제 프로젝트에 맞게 변수, 클래스명, 스키마 등이 약간 다릅니다.
해당 튜토리얼을 그대로 따라하시면 제 코드와 맞지 않는 부분이 존재할 수 있습니다.
source: https://www.youtube.com/watch?v=AfYfvjP1hK8&list=PLOLrQ9Pn6caw0PjVwymNc64NkUNbZlhFw&index=3&t=3368s