Refresh Token을 안쓰고 Regen Token을 사용해서 token 한 개의 값만 갱신하는 방식으로 개발을 진행했다.
왜 Regen Token으로 개발하는가?
백엔드에서 Regen Token 컨트롤러를 실행하기 위해서는 Access Token 인증을 다 거치고 응답을 줘야한다. 따라서 인증된 사용자만 접근이 가능하기 때문에 Access Token 자체가 만료되면 접근 자체가 불가능하다. 보안상 이슈를 위해서 토큰 자체를 하나만 안전하게 보안 처리를 해주었다. 굳이 토큰을 두 개 가질 필요가 없는 상황이고 만료창을 띄우고 연장시키는 방식이 더 적합하다고 판단했다.
백엔드에서 토큰 및 보안을 어떤식으로 관리하는가?
최초 로그인 시 프론트에서 백엔드로 전달되는 데이터들이 있다. 사용자한테 입력받은 ID, PW 뿐만 아니라 Request 시에 IP 주소, 브라우저 접근 경로 등 여러가지 보안을 위한 정보를 전달한다. 최초 로그인 성공 시 ID, 로그인을 시도한 IP 주소, user agent browser 이 3가지를 DB나 Memory에 저장해둔다. 백엔드에서는 사용자에게 Token을 주고 만약 다른 사용자가 Token을 탈취해서 백엔드에 그대로 요청을 하게 되면 저장돼있던 IP주소와 user agent 정보가 다르기 때문에 공격으로 처리한다.
<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>
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로 통신 공통 설정을 해줄 것이다.
Store의 토큰을 axios통신에 실어주기 위해서 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)
}
)
}
export const $axios = () => {
const instance = axios.create()
setPreRequest(instance)
setPostRequest(instance)
return instance
}
/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
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
}
}
참고