Vue-Login-Frontend2

dyeon-dev·2023년 11월 27일
post-thumbnail

JWT 핸들링하기 (Regen Token으로 갱신하기)

Refresh Token을 안쓰고 Regen Token을 사용해서 token 한 개의 값만 갱신하는 방식으로 개발을 진행했다.

Why?

왜 Regen Token으로 개발하는가?
백엔드에서 Regen Token 컨트롤러를 실행하기 위해서는 Access Token 인증을 다 거치고 응답을 줘야한다. 따라서 인증된 사용자만 접근이 가능하기 때문에 Access Token 자체가 만료되면 접근 자체가 불가능하다. 보안상 이슈를 위해서 토큰 자체를 하나만 안전하게 보안 처리를 해주었다. 굳이 토큰을 두 개 가질 필요가 없는 상황이고 만료창을 띄우고 연장시키는 방식이 더 적합하다고 판단했다.

How?

백엔드에서 토큰 및 보안을 어떤식으로 관리하는가?
최초 로그인 시 프론트에서 백엔드로 전달되는 데이터들이 있다. 사용자한테 입력받은 ID, PW 뿐만 아니라 Request 시에 IP 주소, 브라우저 접근 경로 등 여러가지 보안을 위한 정보를 전달한다. 최초 로그인 성공 시 ID, 로그인을 시도한 IP 주소, user agent browser 이 3가지를 DB나 Memory에 저장해둔다. 백엔드에서는 사용자에게 Token을 주고 만약 다른 사용자가 Token을 탈취해서 백엔드에 그대로 요청을 하게 되면 저장돼있던 IP주소와 user agent 정보가 다르기 때문에 공격으로 처리한다.



  • 백엔드 → 로그인시 인증 서버로부터 access token 을 받아온다.
  • 프론트 → axios 요청해서 받은 data의 access token을 메모리에 저장한다.
<script setup lang="ts">
import { useQuasar } from 'quasar'
import { ref } from 'vue'
import axios from 'axios';
import { useRouter } from 'vue-router';
import { useUserStore } from "../store/userStore";

const $q = useQuasar()
const router = useRouter();
const userStore = useUserStore();

const id = ref<string>('');
const pw = ref<string>('');

const onSubmit = async () => {
    try {
        const userData = {
            id: id.value,
            pw: pw.value,
        };

        const { data } = await axios.post("/login", userData);
        console.log(data);
        userStore.setUsername(userData.id);
        userStore.setToken(data.token);

				// 파라미터로 받은 토큰이 있다면, 토큰을 로컬 스토리지로 저장한다.
        if (data.token) {
            localStorage.setItem("access_token", data.token);
        }

        $q.notify({
            color: 'green-4',
            textColor: 'white',
            icon: 'cloud_done',
            message: '로그인 성공'
        })
        router.push({ path: '/Home' });
    }
    catch (error) {
        console.log(error);
        $q.notify({
            color: 'red-5',
            textColor: 'white',
            icon: 'warning',
            message: '아이디와 비밀번호가 일치하지 않습니다'
        })
    } finally {
        id.value = '';
        pw.value = '';
    }
};
</script>
  • 백엔드 → regen token을 만들고, 보안옵션 등을 지정한다.
  • 프론트 → 권한이 필요한 요청시 Authorization 헤더에 access token을 보내준다.
const headers = {
                Authorization: `Bearer ${data.token}`,
                "Content-Type": "application/json",
            };
  • access token 만료되기 10초 전에 프론트에서 알람창이 뜨고 사용자에게 연장 요청을 한다.

    • jwt-decode 라이브러리를 사용해서 타임 스탬프로 만료시간을 계산한다. → 프론트 혹은 백엔드에서 만료시간을 설정해줄 수 있다. 필자는 프론트에서 작업을 해주었다.

    • jwt-decode의 라이브러리가 최근에 typescript에서 decalre function가 업데이트가 됐기 때문에 import 해줄 때 { } 안에 변수를 사용해서 라이브러리의 함수를 사용해줘야 한다. → import { jwtdecode } from 'jwt-decode’

      			import { jwtDecode } from 'jwt-decode'
      
      interface IPayload {
                  iat: number
                  exp: number
              }
      
              // Decode the token to get the payload
              const decoded = jwtDecode<IPayload>(data.token);
      
              if (decoded) {
                  const expiredMs = decoded.exp * 1000
      
                  if (expiredMs) {
                      const expiredDt = new Date(expiredMs);
                      const currentDt = new Date();
                      const currentMs = currentDt.valueOf()
      
                      console.log(`expiredDt : ${expiredDt}`)
                      console.log(`currentDt : ${currentDt}`)
      						} else {
                      console.error('Token does not contain an expiration time (exp).');
                  }
              } else {
                  console.error('Failed to decode the token.');
       }
  • 프론트 → access token 이 만료되었을 시, 서버 렌더링 과정 혹은 API 통신을 통해 regen token 재발급을 요청한다.

    • 프론트에서 regen token 요청 시 id와 기존 token 값을 보내준다.

    • dialog 만료 알림창으로 연장 요청을 한다.

      setTimeout(async () => {
                          if (expiredMs - currentMs <= 10 * 1000) {
                              console.log('로그인 만료 10초전')
                             
                              $q.dialog({
                                  title: `로그인이 10초 후 만료됩니다. 연장하시겠습니까?`,
                                  message: 'CANCEL을 누르면 로그아웃 됩니다.',
                                  cancel: true,
                                  persistent: true
                              }).onOk(() => {
                                  isUserLogin();
      							const axiosResponse = await axios.post("/api/regenToken", { id: userStore.id }, { headers });
      		                        const regenToken = axiosResponse.data.token;
                              }).onCancel(() => {
                                  logoutUser();
                              }).onDismiss(() => {
                              })
                          }
                      });
  • 백엔드 → 프론트에서 요청받은 값으로 regen token을 위한 보안 과정을 거친 후 regen token을 보내준다.

  • 프론트 → 백엔드에서 받은 regen token을 다시 store의 setToken으로 token을 설정 해준다.

setTimeout(async () => {
                    if (expiredMs - currentMs <= 10 * 1000) {
                        console.log('로그인 만료 10초전')
                       
                        $q.dialog({
                            title: `로그인이 10초 후 만료됩니다. 연장하시겠습니까?`,
                            message: 'CANCEL을 누르면 로그아웃 됩니다.',
                            cancel: true,
                            persistent: true
                        }).onOk(() => {
                            isUserLogin();
							const axiosResponse = await axios.post("/api/regenToken", { id: userStore.id }, { headers });
		                        const regenToken = axiosResponse.data.token;
														userStore.setToken(regenToken);
		                        localStorage.setItem("access_token", regenToken);
                        }).onCancel(() => {
                            logoutUser();
                        }).onDismiss(() => {
                        })
                    }
                });

Store의 토큰을 axios통신에 실어주기 위해서 header token 설정을 전처리/후처리 해주고, router guard로 통신 공통 설정을 해줄 것이다.

axios와 intercepter, header token 적용

Store의 토큰을 axios통신에 실어주기 위해서 header token 설정을 axios가 호출되기 전에 넣어준다.

  • axios가 호출되기 전 / 후 함수를 만들어준다. (setPreRequest(instance) / setPostRequest(instance))
  • axios의 instance의 interceptors로 request 하기 전에 store의 token 값이 있다면 header에 token 값을 넣어준다. 이렇게 하면 axios 통신을 할 때마다 자동으로 함수를 호출할 수 있다.

import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import axios from 'axios'
import { useUserStore } from '@/store/userStore'

// ====================================================================
interface IAxiosErrorLog {
  code: string
  message: string
  name: string
  request?: unknown
  response?: unknown
}

// 전처리
const setPreRequest = (instance: AxiosInstance) => {
  instance.interceptors.request.use(
    (config: InternalAxiosRequestConfig) => {
      const $authState = useUserStore()

      config.headers = config.headers ?? {}

      if ($authState.token !== undefined) config.headers.Authorization = 'Bearer ' + $authState.token

      console.log(`==================== axios request, url : ${config.url}`)
      console.log(config)

      return config
    },
    (e) => {
      if (axios.isAxiosError(e)) {
        const err = e as AxiosError

        console.error(`==================== axios request error, url : ${err.config?.url}`)
        console.error(e as IAxiosErrorLog)
      } else {
        console.error('==================== request error')
        console.error(e)
      }

      return Promise.reject(e)
    }
  )
}

// 후처리 (axios가 전달되고 백엔드로 응답받은 부분)
const setPostRequest = (instance: AxiosInstance) => {
  instance.interceptors.response.use(
    (response: AxiosResponse) => {
      console.log(`==================== axios response, url : ${response.config.url}`)
      console.log(response)

      return response
    },
    (e) => {
      if (axios.isAxiosError(e)) {
        const err = e as AxiosError

        console.error(`==================== axios response error, url : ${err.config?.url}`)
        console.error(e as IAxiosErrorLog)
      } else {
        console.error('==================== response error')
        console.error(e)
      }

      return Promise.reject(e)
    }
  )
}
  • axios 인스턴스를 만들고 전처리/후처리 함수를 반환해준다. 이 기능을 axios 호출 시에 사용해보자.
export const $axios = () => {
  const instance = axios.create()
  setPreRequest(instance)
  setPostRequest(instance)
  return instance
}
  • 공통 axios 설정하기

/router/index.ts

import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import { loginHandler, simulatorHandler, loginExistHandler } from './guards'
import routes from './routes'
import { $axios } from '@/axios/index'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: routes,
})

router.beforeEach(async (to, from, next) => {
  const guardArr: {
    (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext): Promise<boolean> | boolean
  }[] = []

  guardArr.push(simulatorHandler)

  if (to.path.toLowerCase().startsWith('/login')) {
    guardArr.push(loginHandler);
   } 
  else if (to.path === '/Home' || to.path === '/Modbus/MasterEthernet' || to.path === '/Modbus/SlaveEthernet' || to.path === '/OPCUA/Client' || to.path === '/OPCUA/Server' || to.path === '/Log') {
    guardArr.push(loginExistHandler);
  }
  $axios()
  let flag = true
  for (const guard of guardArr) {
    if (!(await guard(to, from, next))) {
      flag = false
      break
    }
  }

  if (flag) next()
})
export default router
  • router guard 설정하기
import axios from 'axios'
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useIdStore } from '@/store/idStore'
import { useStateStore } from '@/store/stateStore'
import { useUserStore } from '@/store/userStore'

export const simulatorHandler = async (_to: RouteLocationNormalized, _from: RouteLocationNormalized, _next: NavigationGuardNext) => {
  console.log('simulatorHandler')
  const stateStore = useStateStore()
  console.log(stateStore.state)

  if (stateStore.state) {
    alert('시뮬레이터 종료 후 이동해 주세요')

    return false
  }

  return true
}

export const loginHandler = async (_to: RouteLocationNormalized, _from: RouteLocationNormalized, next: NavigationGuardNext) => {
  console.log('loginHandler')
  // 블록킹 코드 실행
  try {
    const idStore = useIdStore()
    if (idStore.clientId !== '') next()
    const response = await axios.get('/api/getClientId')
    const clientId = response.data
    idStore.clientId = clientId
    console.log('Client ID set in idStore:', clientId)
    return true // 계속 다음 페이지로 이동
  } catch (error) {
    console.error('Error fetching client ID:', error)
    // 오류 발생 시 어떤 처리를 하거나 다른 경로로 리다이렉트할 수 있다.
    return false
  }
}

export const loginExistHandler = async (_to: RouteLocationNormalized, _from: RouteLocationNormalized, next: NavigationGuardNext) => {
  try {
    const userStore = useUserStore()
    if (userStore.token == '') {
      alert('로그인을 해주세요')
      next('/login')
      return false
    }
    return true // 계속 다음 페이지로 이동
  } catch (error) {
    console.error('Error fetching client ID:', error)
    // 오류 발생 시 어떤 처리를 하거나 다른 경로로 리다이렉트할 수 있다.
    return false
  }
}

참고

profile
https://github.com/dyeon-dev

0개의 댓글