가장 기본적인 기능이면서 구현하기 까다로운 로그인 기능...
vue2 기반의 vue-cli와 nuxt도 jwt 기능을 활용한 로그인 기능에 대한 글을 작성했었는데
솔직히 너무 초반에 공부하면서 만들어본 거라 내용 자체가 마음에 들지 않았다.
(글을 다시 쓰고 싶지만 귀찮아서 못하고있다.)
최근 vite+vue3로 vue3에 대해 조금씩 접해보다 jwt를 이용한 로그인 기능을 구현해보자 해서 만들어봤다.
토큰을 발급, 검증하는 백엔드 부분은 다루지 않고 웹단만 작성했습니다.
우선 프로젝트에 axios, vue-router, vuex가 모두 세팅되었다는 가정하에 시작합니다.
그리고 디자인 프레임워크로 퀘이사를 사용했습니다.
추가적으로 설치가 필요한 건 vue3-cookies 이걸 설치해야 합니다.
npm i vue3-cookies
목표
1. 토큰은 accessToken, refreshToken 이렇게 2가지를 사용한다.
- accessToken의 유효기간 1시간, refreshToken의 유효기간은 24시간
2. 라우터를 이동할 때마다 accessToken이 유효한지 검증요청을 보낸다.
- accessToken이 없고 refreshToken만 있을 경우에는 토큰을 재발급 후 계속 진행
- 재발급 과정에서 사용자가 끊김을 느끼지 않아야 한다.
3. axios 요청마다 토큰을 담아서 보낸다.
백엔드에선 요청마다 토큰 검증을 통해 accessToken이 없을 경우 status: 419을, 변조된 경우엔 status: 401을 웹으로 response를 보낸다.
백엔드에서 토큰 검증 후 에러 코드에 대한 처리
419 : accessToken이 만료되어 null 값으로 요청이 간 거라 재발급 후 이전 작업 계속 진행
(재발급 요청 중 refreshToken이 null이거나 변조되었을 경우엔 로그인 창을 띄움)
401 : accessToken이 변조되거나 잘못된 토큰이므로 작업을 진행하지 않고 로그인 창을 띄움
구현
토큰이 없을 시 로그인 페이지로 이동을 시키는 게 아닌 컴포넌트로 띄울 생각이다.
로그인 컴포넌트 소스
<template lang="">
<div class="q-pa-md q-gutter-sm">
<q-dialog v-model="needLogin" persistent transition-show="scale" transition-hide="scale">
<q-card class="bg-teal text-white">
<q-card-section>
<div class="text-h6">Please login...</div>
</q-card-section>
<q-card-section class="q-pt-none">
<div class="column">
<div class="row justify-center items-center">
<q-card square bordered class="q-pa-lg shadow-1">
<q-card-section>
<q-form class="q-gutter-md">
<q-input square filled clearable v-model="user_id" type="text" label="ID" />
<q-input square filled clearable v-model="user_pw" type="password" label="password" />
</q-form>
</q-card-section>
<q-card-actions class="q-px-md">
<q-btn unelevated color="light-green-7" size="lg" class="full-width" label="Login" @click="login" />
</q-card-actions>
</q-card>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
export default {
setup() {
const user_id = ref('test');
const user_pw = ref('aaaa');
const store = useStore();
//@@ 로그인 필요 여부
const needLogin = computed(() => {
return store.getters['auth/needLogin'];
})
//@@ 로그인 처리
const login = async () => {
try {
const rs = await store.dispatch('auth/login', {
user_id: user_id.value,
user_pw: user_pw.value
})
alert(rs);
} catch (err) {
alert(err);
}
}
return {
needLogin,
user_id,
user_pw,
login
}
}
}
</script>
<style lang="">
</style>
스토어에 있는 auth.js의 needLogin의 상태값에 따라 컴포넌트의 활성화 비활성화 여부가 결정된다.
토큰 관련 로직마다 상태값을 변경하는 부분들이 작성되어 있다.
로그인 컴포넌트가 있는 App.vue 소스
<template>
<q-layout view="hHh lpR lFf">
<q-header bordered class="bg-dark text-white" height-hint="98">
<q-toolbar>
<q-btn dense flat round icon="menu" @click="drawer = !drawer" />
<q-toolbar-title>
<q-avatar>
<img src="https://cdn.quasar.dev/logo-v2/svg/logo-mono-white.svg">
</q-avatar>
Title
</q-toolbar-title>
</q-toolbar>
</q-header>
<login/>
<side-menu :drawer="drawer"/>
<q-page-container>
<div class="q-pa-md">
<router-view />
</div>
</q-page-container>
</q-layout>
</template>
<script>
import { ref } from 'vue'
import sideMenu from './components/menu.vue'
import login from './components/login/login.vue'
export default {
setup () {
let drawer = ref(false);
return {
drawer,
}
},
components: {
sideMenu,
login
}
}
</script>
여기까지 우선 로그인 창 띄울 준비는 끝
1. 먼저 토큰 및 로그인 상태 값을 다루는 store 파일 작성
auth.js에서 토큰이 관련된 기능 및 상태값을 다룬다.
import { useCookies } from 'vue3-cookies'
const { cookies } = useCookies();
import axios from '../../plugins/axios';
export default {
namespaced: true,
state: {
needLogin: false,
},
mutations: {
needLogin(state, data) {
state.needLogin = data;
},
},
getters: {
needLogin(state) {
return state.needLogin;
},
},
actions: {
login({commit}, params) { //로그인 및 토큰 처리
return new Promise( async(resolve, reject) => {
try {
const rs = await axios.post('/api/auth/login', params);
if (rs.data.ok) {
const access = rs.data.result.accessToken;
const refresh = rs.data.result.refreshToken;
cookies.set('accessToken', access, import.meta.env.VITE_ACCESS_TIME);
cookies.set('refreshToken', refresh, import.meta.env.VITE_REFRESH_TIME);
commit('needLogin', false);
}
resolve(rs.data.msg);
} catch (err) {
console.error(err);
reject(err);
}
})
},
verifyToken({commit}) { //라우터 이동 시 토큰 검증
return new Promise( async(resolve, reject) => {
try {
const rs = await axios.post('/api/auth/accessTokenCheck');
if(rs.data.ok) {
resolve(true);
} else {
console.error(rs.data.msg);
alert(rs.data.result);
commit('needLogin', true);
resolve(false);
}
} catch (err) {
console.error(err);
reject(err);
}
})
},
refreshToken({commit}) { //토큰 재발급
return new Promise( async(resolve, reject) => {
try {
const rs = await axios.post('/api/auth/refreshToken');
if(rs.data.ok) {
const access = rs.data.result.accessToken;
const refresh = rs.data.result.refreshToken;
cookies.set('accessToken', access, import.meta.env.VITE_ACCESS_TIME);
cookies.set('refreshToken', refresh, import.meta.env.VITE_REFRESH_TIME);
commit('needLogin', false);
resolve(true);
} else {
console.error(rs.data.msg);
commit('needLogin', true);
resolve(false);
}
} catch (err) {
console.error(err);
reject(err);
}
})
},
}
}
needLogin이 true일 땐 로그인 컴포넌트가 활성화되고, false일 땐 비활성화된다.
여기까지만 하면 우선 기본 값을 false로 해놔서 컴포넌트가 뜨진 않을 것이다.
login은 로그인 기능을 처리하는 곳으로 로그인 성공 시 쿠키에 토큰을 생성하고 needLogin상태를 false로 바꾼다.
verifyToken은 라우터 이동 시 accessToken이 있을 경우에 토큰 검증을 요청하는 부분이다.
refreshToken은 말 그대로 재발급 요청
2. axios 세팅 및 interceptor 구현
axios.js는 axios에 대한 기본 세팅만 되어 있고, interceptor.js는 이름 그대로 axios에 대한 인터셉터 기능을 처리하는 파일이다.
먼저 axios.js 소스
import axios from 'axios';
const instance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
headers: {
"Content-Type":"application/json",
},
timeout: 3000
})
export default instance;
그리고 interceptor.js
import instance from "./axios"
import { useCookies } from 'vue3-cookies'
const { cookies } = useCookies();
const setup = (store) => {
instance.interceptors.request.use(
async (config) => {
// console.log('axios.js request : ' , config);
if(import.meta.env.VITE_IS_LOGIN === 'Y') {
config.headers['x-access-token'] = cookies.get('accessToken');
config.headers['x-refresh-token'] = cookies.get('refreshToken');
return config;
} else {
return config
}
},
(error) => {
console.error('axios.js request error : ', error);
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(res) => {
// console.log('axios.js response : ' , res);
return res
},
async (error) => {
console.error('axios.js reqponse error : ', error);
if(import.meta.env.VITE_IS_LOGIN === 'Y') {
const errorRes = error.response;
const errorAPI = error.response.config;
if (cookies.get('refreshToken') === null) {
store.commit('auth/needLogin', true);
return Promise.reject(error);
} else {
if (errorRes.status === 419) { // accessToken이 null일 경우 419코드를 받고 토큰 재생성 요청
try {
await store.dispatch('auth/refreshToken');
return instance(errorAPI);
} catch (err) {
// console.error('err);
return Promise.reject(err);
}
}
if (errorRes.status === 401) { //accessToken이 변조 등 유효하지 않은 토큰일 경우
console.warn('유효하지 않은 토큰', error);
store.commit('auth/needLogin', true);
alert('다시 로그인해주시기 바랍니다.');
return Promise.reject(error);
}
}
}
return Promise.reject(error);
}
)
}
export default setup;
request 부분에선 headers에 토큰들을 담아준다.
response 에러 부분이 핵심이다.
백엔드에서 토큰 검증 중 발생한 에러에 대해 401 또는 419 에러를 주기 때문에 그거에 대한 로직을 추가했다.
refreshToken가 없으면 바로 로그인 컴포넌트를 활성화시키고, 있으면 에러 상태에 따라 처리된다.
419 일 땐 재발급 후 정상처리 되면 이전에 요청했던 걸 다시 요청한다.
(예를 들어 데이터 요청 중 419 에러가 오면 재발급 후 다시 데이터 요청을 날림)
401은 그냥 바로 로그인이 필요한 상태값으로 변경시킨다.
(위에 VITE_IS_LOGIN에 대한 조건문은 작성하지 않아도 된다... 상황에 따라 기능을 활성화/비활성화시키고 싶어서 넣은 env옵션이다.)
소스를 보면 store를 받아서 처리하게 되어 있는데 이건 main.js 소스에서 넘겨줬다.
import { createApp } from 'vue'
import { Quasar } from 'quasar'
import '@quasar/extras/material-icons/material-icons.css'
import 'quasar/src/css/index.sass'
import router from './router';
import store from './store'
import axios from './plugins/axios'
import axiosInterceptor from './plugins/interceptor'
axiosInterceptor(store);
import App from './App.vue'
const app = createApp(App);
app.use(Quasar, {
plugins: {}
})
app.use(router(store));
app.use(store);
app.provide('$axios', axios);
app.mount('#app');
그리고 라우터 쪽에서도 라우터 이동마다 토큰 검증이 있어야 해서 store를 사용할 수 있게 함수화 시켰다.
app.use(router(store)); 이렇게 store를 넘겨준다.
3. 라우터 가드 구현
router.js 소스
import { createWebHistory, createRouter } from 'vue-router'
import { useCookies } from 'vue3-cookies'
const { cookies } = useCookies();
const routes = [
{
path: '/',
name: 'root',
component: () => import('./views/index.vue')
},
{
path: '/composition',
name: 'composition',
component: () => import('./views/composition.vue')
},
{
path: '/test-api',
name: 'test-api',
component: () => import('./views/api.vue')
},
{
path: '/use-store',
name: 'useStore',
component: () => import('./views/use-store.vue')
},
]
export default function(store) {
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from, next) => {
if (import.meta.env.VITE_IS_LOGIN === 'Y') {
const access = cookies.get('accessToken');
const refresh = cookies.get('refreshToken');
//@@ refreshToken이 없을 경우 로그인 창 띄우기
if (refresh === null) {
console.warn('need login...');
store.commit('auth/needLogin', true);
} else if (access === null && refresh !== null) {
//refreshToken은 있고 accessToken만 있을 경우 재발급요청
await store.dispatch('auth/refreshToken');
}else {
//토큰이 다 있다면 페이지 이동 전 토큰 검증
await store.dispatch('auth/verifyToken');
}
return next();
} else {
store.commit('auth/needLogin', false);
return next()
}
})
return router;
}
라우터 가드 beforeEach를 사용했다.
페이지가 이동되기 전 refreshToken이 없을 경우엔 로그인 컴포넌트가 활성화되도록 하고,
refreshToken는 있고 accessToken이 없을 경우엔 재발급 요청(이때 백엔드에선 refreshToken 유효성 체크 후 정상이면 재발급해준다.) 후 정상처리 시 페이지 이동 마무리를 한다.
accessToken이 있을 경우엔 accessToken 검증 요청 후 정상이면 페이지 이동이 된다.
이렇게 하면 끝이다.
정상 로그인 후 쿠키에서 accessToken을 삭제 후 페이지를 이동하거나 axios 요청을 보내면 바로 재발급하고 계속 진행한다.
accessToken의 값을 변경 후 위에 했던 행위를 하면 로그인 컴포넌트가 뜬다.
쿠키가 둘 다 없을 경우도 로그인 컴포넌트가 뜨고, accessToken을 삭제하고 refreshToken 값을 변경하면 로그인 창이 뜬다.
소스는 제 깃허브를 통해 볼 수 있습니다.
https://github.com/smw0807/vue3/tree/main/vite_quasar
'VueJS > Vite' 카테고리의 다른 글
[VueJS] vite로 만든 vue프로젝트 GitHub Pages에 올리기 (2) | 2023.12.31 |
---|---|
[VueJS] Vite에 NuxtJS의 layouts, pages 규칙 적용해보기 (0) | 2023.02.06 |
[VueJS] Firebase 구글 로그인(Vite, Vue3, TS) (0) | 2023.01.24 |
[VueJS] vite에 axios, interceptors 적용하기 (0) | 2022.03.03 |
[VueJS] Vite에서 env 사용하기 (0) | 2022.02.19 |