VueJS/Vite

[VueJS] Vite+Vue3 JWT기능 구현하기

SongMinu 2022. 4. 25. 15:12
728x90

가장 기본적인 기능이면서 구현하기 까다로운 로그인 기능...

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

 

 

 

반응형