이제 백엔드 서버에서 구현했던 토큰을 이용한 로그인 시나리오를 프론트 서버에서 사용하는 방법에 대해 정리한다.
우선 프론트 서버에서 구현해야 할 부분을 보자.
- 백엔드 서버에 권한이 필요한 요청을 하기 위해서는 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의 이동을 감지하는 부분은 가능했고, 원하는 방향대로 새로고침을 감지하는 부분은 불가능했다. 내 지식이 부족한 탓이다.
이후 계속해서 고민해봤지만, 보안 문제와 사용자 경험을 적당히 해결할 수 있는 방법이 떠오르지 않는다.
우선은 이 서비스에 새로고침을 하는 경우가 드물다고 보고 이 문제에 대한 부분은 우선순위를 미루기로 했다. 정말 오래 고민해봤지만 해결 방법이 떠오르지 않았다.
어느정도 개발이 완료된 후 다시 한 번 도전해볼 고민인 것 같다..