Home JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민(2) - 프론트 서버
Post
Cancel

JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민(2) - 프론트 서버

이제 백엔드 서버에서 구현했던 토큰을 이용한 로그인 시나리오를 프론트 서버에서 사용하는 방법에 대해 정리한다.

우선 프론트 서버에서 구현해야 할 부분을 보자.

  • 백엔드 서버에 권한이 필요한 요청을 하기 위해서는 Access 토큰을 전달해야 한다.
    • Access 토큰을 권한이 필요한 요청 헤더에 담아 전달해 권한을 증명한다.
  • 권한이 필요한 페이지는 권한이 없을 때 Login 페이지로 이동하도록 해야한다.
  • 권한이 있는 경우, 즉 로그인이 됐을 경우 권한에 따라 로그인, 회원가입 버튼 대신 로그아웃 버튼이 있어야 한다.
    • 종합적으로 권한 관리를 할 수 있어야 한다.
  • 권한이 필요한 요청을 할 때 401 Unauthorized 에러같이 권한에 대한 오류를 만난다면 쿠키에 있는 Refresh 토큰을 이용해 캐싱된 Access 토큰을 가져오거나, 만료되었다면 갱신해야 한다.
    • 이 동작은 사용자는 모르게 적용되어야 한다.

구현

Access 토큰의 메모리 변수 저장

/src/stores/authStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import {reactive, readonly} from "vue";  
import axios from "axios";  
  
const state = reactive({  
  accessToken: null,  
    username: null,  
    isAuthenticated: false,  
});  
  
const setAuth = (token, username) => {  
  state.accessToken = token;  
    state.username = username;  
    state.isAuthenticated = !!token;  
};  
  
const clearAuth = () => {  
  state.accessToken = null;  
    state.username = null;  
    state.isAuthenticated = false;  
};  
  
const refreshToken = async () => {  
  try {  
  const response = await axios.post('/api/auth/refresh');  
        const {accessToken} = response.data;  
  
        setAuth(accessToken, undefined);  
        return accessToken;  
    } catch (error) {  
  console.error("토큰 갱신 실패", error);  
        clearAuth();  
        throw error;  
    }  
};  
  
export default function useAuthStore() {  
  return {  
  state: readonly(state),  
        setAuth,  
        clearAuth,  
        refreshToken,  
    };  
}
  • reactive
    • reactive는 객체를 반응형으로 만든다.
    • 객체의 속성이 변경될 때 Vue에서 변경사항을 감지하고 업데이트 한다.
    • 따라서 reactive를 통해 토큰과 권한을 관리한다.
  • 그 외에 권한을 부여하고, 지우는 등의 관리 메서드가 존재하고 이 구현부를 통해 각 vue 페이지에서 권한을 이용한 동작을 구현할 수 있다.

Access 토큰이 요청헤더에 있어야 한다.

Access 토큰은 자동으로 갱신되고, 요청 헤더에 포함되어 전달돼야 한다.

/src/axios.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import axios from "axios";  
import useAuthStore from "@/stores/authStore";  
  
const axiosInstance = axios.create();  
  
axiosInstance.interceptors.request.use(  
  (config) => {  
  const{state} = useAuthStore();  
        const token = state.accessToken;  
  
        if (token) {  
  config.headers.Authorization = `Bearer ${token}`;  
        }  
  
  return config;  
    },  
    (error) => {  
  return Promise.reject(error);  
    }  
);  
  
axiosInstance.interceptors.response.use(  
  response => response,  
    async (error) => {  
  const originalRequest = error.config;  
  
        if (error.response.status === 401 && !originalRequest._retries) {  
  originalRequest._retries = true;  
  
            try {  
  const accessToken = await useAuthStore().refreshToken();  
                originalRequest.headers.Authorization = `Bearer ${accessToken}`;  
                console.log("Access 토큰 갱신");  
                return axios(originalRequest);  
            } catch (refreshError) {  
  return Promise.reject(refreshError);  
            }  
  }  
  
  return Promise.reject(error);  
    }  
);  
  
export default axiosInstance;
  • Axios를 이용해 요청 및 응답 인터셉터를 구현했다.
  • Request Interceptor
    • 요청 인터셉터를 통해 AuthStore에서 액세스 토큰을 가져와 요청 헤더에 자동으로 추가한다.
  • Response Interceptor
    • 모든 HTTP 응답이 도착한 후 실행된다.
    • 따라서 HTTP 응답이 Unauthorized 응답이고, 요청이 재시도되지 않은 경우 토큰을 갱신하기 위해 refreshToken 메서드를 호출한다.
    • 토큰이 성공적으로 갱신되면, 기존에 실패했던 요청 헤더에 새로운 Access 토큰을 추가하고 기존 요청을 다시 보낸다.

이렇게 인터셉터를 구현하여 시나리오를 충족했다.

axios를 다른 vue 페이지에서 사용할 때 구현한 axios를 import 해서 사용하면 된다.

권한을 이용한 페이지 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { createRouter, createWebHistory } from 'vue-router';  
import LoginPage from '@/pages/LoginPage.vue';
import RegisterPage from "@/pages/RegisterPage.vue";  
import EvaluatePage from "@/pages/EvaluatePage.vue"  
import RecommendationPage from "@/pages/RecommendationPage.vue";  
import useAuthStore from "@/stores/authStore";  
  
const router = createRouter({  
  history: createWebHistory(),  
    routes: [  
  {  
  path: '/',  
            name: 'Home',  
            component: Home,  
        },  
        {  
  path: '/login',  
            name: 'LoginPage',  
            component: LoginPage,  
        },  
        {  
  path: '/register',  
            name: 'RegisterPage',  
            component: RegisterPage,  
        },  
        {  
  path: '/evaluate',  
            name: 'EvaluatePage',  
            component: EvaluatePage,  
            meta: {requireAuth: true}  
  },  
        {  
  path: '/recommendation',  
            name: 'RecommendationPage',  
            component: RecommendationPage,  
            meta: {requireAuth: true}  
  },  
    ],  
});  
  
router.beforeEach((to, from, next) => {  
  const authStore = useAuthStore();  
    const isAuthenticated = authStore.state.isAuthenticated;  
  
    if (to.matched.some(record => record.meta.requireAuth) && !isAuthenticated) {  
	    next({name: 'LoginPage'});  
    } else {  
	    next();  
    }  
})  
  
export default router;

기존의 라우터 설정에서 경로만 설정했던 부분에 권한을 추가했다.

  • requireAuth 라는 meta 속성을 정의해 권한이 필요한 페이지를 설정한다.
  • 권한이 없다면 LoginPage로 이동한다.

이 외 로그인 페이지 및 메뉴를 구성하는 Header는 이러한 구현부를 바탕으로 구현이 이루어지면 된다.

문제점

프론트 서버에서 위와 같이 구현함으로써 보안을 고민한 기존의 방식을 충분히 해결했다. 다만 한 가지 문제가 있다.

새로고침 시 권한이 사라진다.

왜냐하면 Access 토큰을 메모리 변수에 저장했기 때문에 새로고침을 했을 때 메모리 변수는 초기화되어 권한을 잃게 되는 것이다.

Access 토큰을 메모리 변수에 저장했던 이유는 localStorage에 저장했을 때 XSS 공격의 위험성, HttpOnly 쿠키에 저장했을 때 CSRF 공격에 취약하다는 점 때문이었다.

그런데 이 문제를 해결하려고 localStorage에 저장했다가 메모리 변수가 초기화 된 경우 다시 가져오도록 하는 로직을 작성한다거나, HttpOnly 쿠키를 사용하면 결국 같은 문제에 직면한다.

새로고침을 감지하고 캐싱해놨던 Access 토큰을 가져오는 방법도 생각해봤지만, router의 이동을 감지하는 부분은 가능했고, 원하는 방향대로 새로고침을 감지하는 부분은 불가능했다. 내 지식이 부족한 탓이다.

이후 계속해서 고민해봤지만, 보안 문제와 사용자 경험을 적당히 해결할 수 있는 방법이 떠오르지 않는다.

우선은 이 서비스에 새로고침을 하는 경우가 드물다고 보고 이 문제에 대한 부분은 우선순위를 미루기로 했다. 정말 오래 고민해봤지만 해결 방법이 떠오르지 않았다.

어느정도 개발이 완료된 후 다시 한 번 도전해볼 고민인 것 같다..

This post is licensed under CC BY 4.0 by the author.

JWT 토큰 (Access + Refresh) 방식의 보안 문제 고민.

Refresh 토큰을 이용한 Access 토큰 갱신에서의 문제