DRF에 권한 설정을 추가하자 게시글 조회가 401 Unauthorized 오류와 함께 막혔다.
인증 과정에서 프론트엔드(Vue)의 역할을 이해하고, 이 오류를 해결해 완전한 인증 시스템을 구축한다.
인증의 큰 흐름은 다음과 같다.
db.sqlite3 삭제Failed to load resource: the server responded with a status of 401 (Unauthorized)
오늘의 목표
// router/index.js
import SignUpView from '@/views/SignUpView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...
{
path: '/signup',
name: 'SignUpView',
component: SignUpView
}
]
})
export default router
<!-- App.vue -->
<template>
<header>
<nav>
<RouterLink :to="{ name:'ArticleView' }">Articles</RouterLink>
<RouterLink :to="{ name: 'SignUpView'}">SignUpPage</RouterLink>
</nav>
</header>
<RouterView />
</template>
<!-- views/SignUpView.vue -->
<template>
<div>
<h1>Sign Up Page</h1>
<form @submit.prevent="signUp">
<label for="username">username: </label>
<input type="text" id="username" v-model.trim="username" /> <br>
<label for="password1">password: </label>
<input type="password" id="password1" v-model.trim="password1" /> <br>
<label for="password2">password confirmation: </label>
<input type="password" id="password2" v-model.trim="password2" /> <br>
<input type="submit" value="signup">
</form>
</div>
</template>
// views/SignUpView.vue
import { ref } from 'vue'
const username = ref(null)
const password1 = ref(null)
const password2 = ref(null)
http://localhost:5173/signup 접속하여 폼 렌더링 확인signUp 함수가 해야 할 일
// stores/accounts.js
export const useAccountStore = defineStore('account', () => {
const signUp = function () {
// ...
}
return { signUp }
}, { persist: true })
payload는 전달해야 하는 데이터로 자주 쓰는 변수명
// views/SignUpView.vue
import { useAccountStore } from '@/stores/accounts'
const accountStore = useAccountStore()
const signUp = function () {
const payload = {
username: username.value,
password1: password1.value,
password2: password2.value,
}
accountStore.signUp(payload)
}
<!-- views/SignUpView.vue -->
<template>
<div>
<h1>Sign Up Page</h1>
<form @submit.prevent="signUp">
<label for="username">username: </label>
<!-- ... -->
</form>
</div>
</template>
// stores/accounts.js
const signUp = function (payload) {
const username = payload.username
const password1 = payload.password1
const password2 = payload.password2
// const { username, password1, password2 } = payload
axios({
method: 'post',
url: `${API_URL}/accounts/signup/`,
data: {
username, password1, password2
}
})
.then(res => {
console.log('회원가입이 완료되었습니다.')
})
.catch(err => console.log(err))
}
회원가입이 완료되었습니다. 출력 확인SELECT * FROM accounts_user LIMIT 100 으로 가입된 user 데이터 확인// router/index.js
import LogInView from '@/views/LogInView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ...,
{
path: '/login',
name: 'LogInView',
component: LogInView
}
]
})
<!-- App.vue -->
<template>
<header>
<nav>
<RouterLink :to="{ name:'ArticleView' }">Articles</RouterLink>
<RouterLink :to="{ name:'SignUpView' }">SignUpPage</RouterLink>
<RouterLink :to="{ name:'LogInView' }">LogInPage</RouterLink>
</nav>
</header>
<RouterView />
</template>
<!-- views/LoginView.vue -->
<template>
<div>
<h1>LogIn Page</h1>
<form>
<label for="username">username: </label> <br>
<input type="text" id="username" v-model.trim="username" /> <br>
<label for="password">password: </label> <br>
<input type="password" id="password" v-model.trim="password" /> <br>
<input type="submit" value="LogIn"/>
</form>
</div>
</template>
// views/LoginView.vue
import { ref } from 'vue';
const username = ref(null)
const password = ref(null)
http://localhost:5173/login 접속하여 폼 렌더링 확인logIn 함수가 해야 할 일
// stores/accounts.js
export const useAccountStore = defineStore('account', () => {
const logIn = function () {
// ...
}
return { signUp, logIn }
}, { persist: true })
// views/LoginView.vue
import { useAccountStore } from '@/stores/accounts.js'
const accountStore = useAccountStore()
const logIn = function () {
const payload = {
username: username.value,
password: password.value
}
accountStore.logIn(payload)
}
<!-- views/LoginView.vue -->
<template>
<div>
<h1>LogIn Page</h1>
<form @submit.prevent="logIn">
<!-- ... -->
</form>
</div>
</template>
// stores/accounts.js
const logIn = function (payload) {
const username = payload.username
const password = payload.password
axios({
method: 'post',
url: `${API_URL}/accounts/login/`,
data: {
username, password
}
})
.then(res => {
console.log('로그인이 완료되었습니다.')
console.log(res.data)
})
.catch(err => console.log(err))
}
{ key: 'c99fc78062f7843fba363e94956439c5765fc708' }// stores/accounts.js
export const useAccountStore = defineStore('account', () => {
const token = ref(null)
const logIn = function (payload) {
// ...
.then(res => {
token.value = res.data.key
})
.catch(err => console.log(err))
}
return { signUp, logIn, token }
}, { persist: true })
token이 저장된 것 확인// stores/accounts.js
import { useRouter } from 'vue-router'
export const useAccountStore = defineStore('account', () => {
const router = useRouter()
const logIn = function (payload) {
// ...
.then(res => {
token.value = res.data.key
router.push({ name: 'ArticleView' })
})
.catch(err => console.log(err))
}
})
getArticles)에 token 추가 후 조회// stores/articles.js
import { useAccountStore } from './accounts'
export const useArticleStore = defineStore('article', () => {
const accountStore = useAccountStore()
const getArticles = function () {
axios({
method: 'get',
url: `${API_URL}/api/v1/articles/`,
headers: {
'Authorization': `Token ${accountStore.token}`
}
})
// ...
}
})
createArticle)에 token 추가 후 생성// views/CreateView.vue
import { useAccountStore } from '@/stores/accounts'
const accountStore = useAccountStore()
const createArticle = function () {
axios({
method: 'post',
url: `${store.API_URL}/api/v1/articles/`,
data: {
// ...
},
headers: {
'Authorization': `Token ${accountStore.token}`
}
})
}
→ 401 오류가 해결되고 게시글이 정상적으로 출력되는 것을 확인할 수 있다.
isLogin 변수 작성computed를 활용해 token 값이 변경될 때만 이 상태를 다시 계산하도록 함computed: 원본 데이터가 바뀔 때만, 값을 알아서 다시 계산하는 ref
// stores/accounts.js
export const useAccountStore = defineStore('account', () => {
// ...
const isLogin = computed(() => {
return token.value ? true : false
})
// ...
return { signUp, logIn, token, isLogin }
}, { persist: true })
beforeEach를 활용하여, 인증되지 않은 사용자가 보호된 페이지(예: 메인 페이지)로 이동하려 할 때 로그인 페이지로 리다이렉트beforeEach: 모든 페이지 이동 직전에 수행
// router/index.js
import { useAccountStore } from '@/stores/accounts'
const router = createRouter({ /* ... */ })
router.beforeEach((to, from) => {
const accountStore = useAccountStore()
if (to.name === 'ArticleView' && !accountStore.isLogin) {
window.alert('로그인이 필요합니다.')
return { name: 'LogInView' }
}
})
local storage: 브라우저를 껐다 켜도, 데이터가 사라지지 않는 저장 공간
// router/index.js
router.beforeEach((to, from) => {
const accountStore = useAccountStore()
if (to.name === 'ArticleView' && !accountStore.isLogin) {
window.alert('로그인이 필요합니다.')
return { name: 'LogInView' }
}
if ((to.name === 'SignUpView' || to.name === 'LogInView') && (accountStore.isLogin)) {
window.alert('이미 로그인 되어 있습니다.')
return { name: 'ArticleView' }
}
})
이미 로그인 되어 있습니다. alert 후 메인으로 이동
dj-rest-auth를 활용한 회원가입 시 User Model을 Customize 해보기
db.sqlite3 삭제PositiveIntegerField 추가# accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class User(AbstractUser):
age = models.PositiveIntegerField(null=True, blank=True)
makemigrations, migrate 작업 진행$ python manage.py makemigrations
Migrations for 'accounts':
accounts\migrations\0002_user_age.py
- Add field age to user
accounts_user 테이블에 age integer unsigned 필드 추가 확인payload data: 헤더, 메타데이터를 제외한 전송의 목적이 되는 데이터
<!-- views/SignUpView.vue -->
<template>
<div>
<h1>Sign Up Page</h1>
<form @submit.prevent="signUp">
<!-- ... -->
<label for="age">age: </label>
<input type="number" id="age" v-model.trim="age" /> <br>
<input type="submit" value="signup">
</form>
</div>
</template>
// views/SignUpView.vue
<script setup>
const age = ref(0)
const signUp = function () {
const payload = {
// ...
age: age.value
}
accountStore.signUp(payload)
}
</script>
signUp 함수에 age 정보 추가// store/accounts.js
const signUp = function (payload) {
// ...
const age = payload.age
// const { username, password1, password2, age } = payload
axios({
method: 'post',
url: `${API_URL}/accounts/signup/`,
data: {
username: username,
password1: password1,
password2: password2,
age: age,
}
})
// ...
}
회원가입 완료되었습니다. 출력age 정보는 누락되었음 (age 컬럼이 NULL)dj-rest-auth의 RegisterSerializer의 field 정보 확인username, email, password1, password2 필드만 정의 되어 있음 (그래서 age가 누락됨)class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(
max_length=get_username_max_length(),
min_length=allauth_account_settings.USERNAME_MIN_LENGTH,
required=allauth_account_settings.USERNAME_REQUIRED,
)
email = serializers.EmailField(required=allauth_account_settings.EMAIL_REQUIRED)
password1 = serializers.CharField(write_only=True)
password2 = serializers.CharField(write_only=True)
CustomRegisterSerializer에 정의 (RegisterSerializer 상속)age 필드 추가 (필요하다면, first_name 등 기본 제공 필드 추가 가능)# accounts/serializers.py
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
class CustomRegisterSerializer(RegisterSerializer):
age = serializers.IntegerField()
get_cleaned_data 함수 구조 확인def get_cleaned_data(self):
return {
'username': self.validated_data.get('username', ''),
'password1': self.validated_data.get('password1', ''),
'email': self.validated_data.get('email', ''),
}
super()를 사용하여, 기존 필드에 대한 유효성 검사 실행 후,# accounts/serializers.py
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
class CustomRegisterSerializer(RegisterSerializer):
age = serializers.IntegerField()
def get_cleaned_data(self):
data = super().get_cleaned_data()
data['age'] = self.validated_data.get('age')
return data
save 함수도 유효성 검사 함수와 같은 작업을 진행# 원본 save 함수 구조 참고
def save(self, request):
adapter = get_adapter()
user = adapter.new_user(request)
self.cleaned_data = self.get_cleaned_data()
user = adapter.save_user(request, user, self, commit=False)
if "password1" in self.cleaned_data:
try:
adapter.clean_password(self.cleaned_data['password1'], user=user)
except DjangoValidationError as exc:
raise serializers.ValidationError(
detail=serializers.as_serializer_error(exc)
)
user.save()
self.custom_signup(request, user)
setup_user_email(request, user, [])
return user
# accounts/serializers.py
from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers
class CustomRegisterSerializer(RegisterSerializer):
# ...
def save(self, request):
user = super().save(request)
user.age = self.validated_data.get('age')
user.save()
return user
dj-rest-auth가 CustomRegisterSerializer를 사용하도록 settings.py에 설정REST_AUTH_REGISTER_SERIALIZERS 주석 해제# settings.py
...
REST_AUTH = {
'REGISTER_SERIALIZER': 'accounts.serializers.CustomRegisterSerializer',
}
age 정보가 기입된 것을 확인 (예: age = 17)// stores/accounts.js
import { useRouter } from 'vue-router'
export const useAccountStore = defineStore('account', () => {
const router = useRouter()
const logOut = function () {
axios({
method: 'post',
url: `${API_URL}/accounts/logout/`
})
.then((res) => {
token.value = null
router.push({ name: 'LoginView' })
})
.catch((err) => console.log(err))
}
return { signUp, logIn, token, isLogin, logOut }
})
<!-- App.vue -->
<form @submit.prevent="logOut">
<input type="submit" value="Logout">
</form>
// App.vue
import { useAccountStore } from '@/stores/accounts'
const accountStore = useAccountStore()
const logOut = function () {
accountStore.logOut()
}
// stores/accounts.js
const signUp = function (payload) {
// ...
.then(res => {
const password = password1
logIn({ username, password })
})
.catch(err => console.log(err))
}
# articles/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article
# Article 모델의 post_save 신호를 이 함수가 받도록 설정
@receiver(post_save, sender=Article)
def article_post_save(sender, instance, created, **kwargs):
# 'created'는 객체가 새로 생성되었을 때 True
if created:
print(f"알림: 새로운 게시글이 작성되었습니다! (제목: {instance.title})")
Vite에서 환경 변수를 사용하는 법
.env.local 파일 생성 및 API 변수 작성VITE_ 접두어를 작성해야 함# .env.local
VITE_TMDB_API_KEY=ey1hf1wnfdk202f...
const API_KEY = import.meta.env.VITE_TMDB_API_KEY
Awesome Vue.js
Vuetify
프론트엔드(Vue)는 로그인 시 발급받은 토큰을 store에 저장하고, 인증이 필요한 요청마다 헤더에 토큰을 담아 보낸다. User 모델을 커스터마이징할 때는 모델 필드 추가 → migration →
RegisterSerializer상속 및get_cleaned_data/save오버라이딩 →settings.py의REST_AUTH설정까지 함께 맞춰줘야 추가 필드가 누락 없이 저장된다.