프로젝트에서 Access Token이 만료되었을 때 Refresh Token을 통해 재발급 했던 코드를 정리하고자 한다.
그전에 기반이 되는 HTTP프로토콜 특징과, 로그인 방식들, Interceptor에 대해 알아보자.
비연결성(ConnectionLess)이란,클라이언트와 서버가 한 번 연결을 맺은 후, 클 라이언트 요청에 대해 서버가 응답을 마치면 맺었던 연결을 끊어 버리는 성질이다.
HTTP의 버전에 따라 다르지만, HTTP 1.0은 하나에 요청에 대한 응답마다 TCP의 신뢰성을 수립하고 끊는 과정(3-way handshake, 4-way handshake)이 발생한다.
1.1 버전 부터는 Keep-Alive 기능이 추가되어서 이러한 비연결성이 어느정도 해소되었다. 이는 3-way handshake를 통해 연결된 세션을 계속 없애지않고 연결을 특정시간동안 유지한다.
이러한 비연결성때문에 무상태성이라는 특징이 나타난다. 무상태성은 HTTP요청시 서버는 클라이언트의 이전 상태를 기억하지 못하는 성질이다.
그래서 매번 요청을 할때마다 유저의 인증관련 정보를 보내주는 역할로 쿠키, 세션, 토큰등을 사용한다.
쿠키란? 클라이언트에서 서버에게 요청시 헤더에 담겨 전송되는 작은 데이터 조각이다.
서버에 로그인 요청을 보내게 되면, 응답값으로 세션id를 쿠키에 넣어 보내준다.
이후 요청시, 클라이언트에서 세션id가 들어있는 쿠키를 함께 보내주고, 받은 세션id값을 바탕으로 서버에서 세션을 조회해 유저정보를 찾는다.
보통 세션 저장소로는 Redis를 많이 사용한다고 한다.
결국 인증의 책임을 서버가 지게하기 위해 세션방식을 사용한다.
세션 쿠키방식은 몇가지 단점이 존재하는데, 먼저 서버측에서 세션을 위한 저장소를 추가로 사용해 추가적인 저장공간과 부하가 높아지게 되고, 해커가 http요청에서 탈취한 쿠키를 통해 재요청을 보낸다면, 사용자 정보를 악의적으로 이용 할 수 있는 가능성이 존재한다.
이를 보완한 것이 토큰기반 인증방식이다.
사용자가 로그인을 하면, 서버측에서는 Secret Key를 이용해 암호화된 Access Token을 발급해준다.
사용자는 이후 인증이 필요한 요청마다 헤더에 토큰을 함께 실어 보내는 방식이다.
서버에서는 이 토큰을 가지고 Secret Key를 이용해 토큰을 복호화 한 후 유효기간을 확인하고 사용자에 맞는 데이터를 가져온다.
즉, 토큰안에 유저정보가 들어있기 때문에 세션과 같이 별도의 저장소를 사용 할 필요가 없는 장점이 있다. 또한 서버입장에서 확정성이 뛰어나다.(소셜로그인은 토큰기반으로 구현됨)
하지만, 이미 탈취당한 토큰은 해커가 악의적으로 사용가능하기 때문에, 보안을 높히려고 access token의 유효기간을 짧게 설정하고 만약 만료시 refresh Token을 통해 재발급하는 방법을 많이 사용한다.
Interceptor를 사용하면 request와 response를 가로채 공통적인 로직을 처리 할 수 있게 해준다.
프로젝트에서는 instance를 만들어 api 요청을 처리했는데, instance마다 401에러나, 토큰재발급을 위한 공통 로직을 처리하기 위해 interceptor를 활용하였다.
request를 위한 interceptor와 response를 위한 interceptor가 존재하는데, 공식문서의 코드를 활용 코드를 보면 다음과 같다.
// 요청 인터셉터 추가하기
axios.interceptors.request.use(function (config) {
// 요청이 전달되기 전에 작업 수행
return config;
}, function (error) {
// 요청 오류가 있는 작업 수행
return Promise.reject(error);
});
// 응답 인터셉터 추가하기
axios.interceptors.response.use(function (response) {
// 2xx 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
// 응답 데이터가 있는 작업 수행
return response;
}, function (error) {
// 2xx 외의 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
// 응답 오류가 있는 작업 수행
return Promise.reject(error);
});
만약 accessToken이 존재하지 않는다면, 로그인 페이지로 redirect할 수 있도록 request Interceptor에서 처리해주었다.
토큰이 존재한다면, 토큰을 넣어 요청을 보낸다.
// API.ts
const instance = axios.create();
instance.defaults.withCredentials = true;
instance.defaults.baseURL = 'http://52.78.181.46';
instance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken();
if (!accessToken) {
window.location.href = '/login';
return config;
}
config.headers['Content-Type'] = 'application/json';
config.headers['Authorization'] = `Bearer ${accessToken}`;
return config;
},
(error: any) => {
console.log(error);
return Promise.reject(error);
},
);
response Interceptor에서는 응답을 받은 뒤 에러코드에 따라 에러를 처리했다.
에러의 상태가 404이면 notFound Page로 redirect 시켰고,
401이면 권한없음 페이지로,
만약 상태가 500이고, code가 7001이라면 토큰이 만료된 상태이다.
토큰만료를 클라이언트에서 판단할 수도 있고, 서버에서 판단할 수 있지만, 서버에서 판단해 요청시 클라이언트에게 만료되었다는 errorCode를 보내주는 식으로 구현하였다.
//API.ts
instance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
if (error.response?.status === 401) {
window.location.href = '/unauthorized';
}
else if (error.response?.status === 404) {
window.location.href = '/notFound';
}
else if (error.response && error.response?.status === 500) {
const errorCode = error.response.data.errorCode;
if (errorCode === 7001) {
await tokenRefresh(instance);
const accessToken = getAccessToken();
error.config.headers.Authorization = `Bearer ${accessToken}`;
// 중단된 요청을(에러난 요청)을 토큰 갱신 후 재요청
return instance(error.config);
}
}
토큰이 만료되었다면, Refresh Token을 통해 Access Token을 재발급 한 후, tokenRefresh함수를 통해 재발급된 토큰을 통해 재요청을 보낸다.
토큰 만료를 확인하는 함수도 구현했지만, 서버에서 이미 만료를 판단해 에러코드를 보내주기때문에 사용하지 않았다.
//ApiUtil.ts
export const tokenRefresh = async (instance: AxiosInstance) => {
const refreshToken = getRefreshToken(); // 리프레시 토큰을 가져오기
const { data } = await instance.get('/reissue', {
headers: { 'Content-Type': 'application/json', RefreshToken: `Bearer ${refreshToken}` },
});
const newAccessToken = data.accessToken;
sessionStorage.setItem('Authorization', newAccessToken); // 세션 스토리지에 액세스 토큰 저장
}; // tokenRefresh() - 토큰을 갱신해주는 함수
export const isTokenExpired = () => {
const accessToken = getAccessToken();
if (!accessToken) {
return true;
}
const decodedToken = jwtDecode<JwtPayload>(accessToken);
const currentTime = Date.now() / 1000;
if (decodedToken.exp !== undefined && decodedToken.exp < currentTime) {
// 토큰이 만료된 경우
return true;
}
return false;
}; // isTokenExpired() - 토큰 만료 여부를 확인하는 함수
ref)
http : https://junhyunny.github.io/information/http-keep-alive/
세션,쿠키방식 : https://tansfil.tistory.com/58?category=475681
Access + RefreshToken : https://tansfil.tistory.com/59?category=475681
axios Intercetpor 공식문서 : https://axios-http.com/kr/docs/interceptors