이번에 전대위키에 refresh token 처리 로직을 추가했다.
초기 기본 로직은 로그인 시 헤더로 받은 엑세스 토큰을 로컬에 넣고 요청마다 로컬에 있는 엑세스 토큰을 헤더에 보내주었다.
헤더
에서 access token
을 받고 바디에서 각 토큰의 만료시간을 받는다refresh token
은 쿠키에 넣어 관리했다.axios interceptor
로 엑세스 토큰 재발급 요청을 보내 엑세스 토큰과 만료시간을 교체해주어 자동으로 로그인이 유지되게 구현했다.로그인은 보안이 중요하기 때문에 어떻게 토큰을 저장해야 보안상 안전할지 생각해 보았다. 하지만 어떤 레퍼런스들을 찾아봐도 완전히 안전한 방법은 없다고 한다. 리프레시 토큰을 쿠키에 넣어 주고받는게 성공해서 엑세스 토큰을 어디에 저장할지 고민이 됐다.
생각해 본 방법은
엑세스 토큰 로컬에 넣기 vs 엑세스 토큰 redux-persist 에 넣기
였는데 두 방법 모두 구현해봤지만 로컬에 저장하는게 구현하는데 훨씬 간단했다.(리덕스에 엑세스 토큰 저장한 방법은 조금 더 공부하고 정리할 예정이다-interceptor 안에서 selector를 불러오는게 생각보다 까다로웠다-)
사실 redux persist 또한 로컬 혹은 세션에 저장 되는 것 이니까 로컬과 보안 상 다를 게 없을 것 같다는 생각에 프로젝트에는 로컬에 토큰을 저장하는 것으로 적용 했다.
이거는 서버에서 set-cookie 로 잘 전달이 되는데 프론트에서 setCookie 를 해줘도 가져오지 못했다. 백에서도 포스트맨에는 쿠키가 잘 온다고 해서 프론트에서 잘못한 줄 알았으나 쿠키는 프론트에서 건들지 않는게 안전하다고 한다... 그래서 백엔드 분들에게 로그인 성공 시 쿠키에 저절로 리프레시 토큰이 저장되게 수정해달라고 부탁했다.
(도메인 주소를 맞추지 않았고, 대신 배포 주소가 둘 다 https 였기 때문에 samesite 설정을 해 주었다. )
이 부분은 프론트에서 해결할 부분이 아니었다... 추후에 cors 정책 부분을 공부해봐야겠다. 프론트에서는 withCredentials 을 true 로 바꾸는 것 말고는 할 수 있는게 없었다.
구현하면서 cors -> 403 -> cors -> 403 ..... 에러가 계속 발생했다. jar 파일을 20개 넘게 받아서 해결했다.
403 Forbidden
403::ERR_FAILED
Failed to load resource: net::ERR_FAILED
localhost/:1 Access to XMLHttpRequest at '백앤드주소' from origin '프론트주소' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
docs:1
이 에러를 몇십번은 본 것 같다.
jar 파일에서 성공을 했는데도 배포했더니 해당 에러가 계속 떴다.
발생한 에러를 백엔드에서 해결해야 할 지 프론트에서 해결해야 할 지 모르겠다는 점이 가장 힘들었었다. 그래도 이번에 공부하면서 왜 백엔드에서 해결해야 하는지, 프론트에서 해결할 수 있는 에러가 아니라는 것을 설명하려고 내 코드와 에러사항을 자세히 이해하며 개발할 수 있었던 것 같다.
서버에 요청 보냈을 때 엑세스 토큰이 만료되어 403 에러가 뜨면 재발급 요청을 보내려고 했다. 그런데 리프레시 토큰이 만료될때 엑세스 토큰도 만료되게 된다면 엑세스 토큰 만료 에러(403)가 떠서 구분을 못했다. 그래서 엑세스 토큰 만료시간과 리프레시 토큰 만료시간을 로컬에 저장하여 응답 interceptor 에서 에러가 날 때(서버에서 응답받을때 에러나면) 그 안에서 저장된 만료시간을 계산해서 재발급 요청과 로그아웃을 하도록 했다.
(그리고 재발급 요청 전에 요청이 가면 401 에러가 떠서 401로는 처리하지 못했다.)
엑세스 토큰 재발급 요청을 보냈을때, 재발급에서 에러가 나면 멈추지 않고 무한으로 요청이 됐다. 그래서 서버에서 403, cors 에러가 나면 10000번 이상 요청이 돼서 하나 확인할때마다 창을 꺼야했었다.
그래서
originalConfig._retry = true;
를 설정하여 에러가 나면 재요청을 보내지 않게 했다.
만료시간이 지난 엑세스 토큰을 헤더에서 인터셉트 하는 instance 를 재발급 요청 보내는 부분에도 사용을 했더니 에러가 났다. 재발급 요청은 헤더에 엑세스 토큰 없이 보내서 해결했다.
로그인의 기능상에서는 문제가 되지 않지만, 재발급 성공 후 로컬에 저장하는 동안에 요청을 보내게 되면 에러가 뜬다. 로컬에 저장 후에 요청을 보내도록 await 을 해줄 수 있을 것 같은데 아직 해결하지는 못했다. 비동기 처리 공부를 더 해봐야겠다.
import axios from "axios";
import routes from "@/routes";
axios.defaults.withCredentials = true;
export const instance = axios.create({
baseURL: import.meta.env.BASE_URL,
timeout: 1000 * 5,
headers: {
"Content-Type": "application/json",
},
});
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.authorization = `${token}`;
}
return config;
});
instance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const status = error?.response.status;
const originalConfig = error?.config;
const accessExpiredTime = localStorage.getItem("accessExpiredTime");
const refreshExpiredTime = localStorage.getItem("refreshExpiredTime");
if (refreshExpiredTime && refreshExpiredTime < new Date()) {
alert("로그인 시간이 만료되었습니다. 다시 로그인해주세요");
localStorage.clear();
location.href = routes.login;
return Promise.resolve(error.response.data.error.message);
}
if (
accessExpiredTime &&
refreshExpiredTime &&
accessExpiredTime < new Date() - 10000 &&
refreshExpiredTime > new Date()
) {
originalConfig._retry = true;
axios
.post(
"access-token 요청 주소"
)
.then((response) => {
localStorage.setItem("token", response.headers.authorization);
localStorage.setItem(
"accessExpiredTime",
parseInt(response.data.response.accessTokenExpiration)
);
})
.catch((error) => {
console.log(error);
return;
});
return instance(error.config);
}
if (status === 500) {
console.log(error?.response?.data.error.message);
}
return Promise.reject(error.response);
}
);
레퍼런스
https://stackoverflow.com/questions/52946376/reactjs-axios-interceptors-how-dispatch-a-logout-action
https://stackoverflow.com/questions/75111159/how-to-use-useselector-inside-axios-interceptors