Access Token이 탈취되면 만료될 때까지 탈취자가 사용자 권한으로 접근이 가능하다. (보안 취약)
Access Token은 발급 후 서버에 저장되지 않고, 토큰 자체로 인증
Refresh Token과 Access Token 모두 JWT 형태이다.
하지만 Access Token은 인증에, Refresh Token은 토큰 재발급에 사용된다.
Access Token 만료 시(만료된 Access Token을 서버에 보내면), 서버는 같이 보내진 Refresh Token을 DB에 있는 것과 비교해서 일치하면 다시 Access Token을 재발급한다.
즉, Refresh Token 접근에 대한 권한을 주는 것이 아니라 Access Token 재발급에만 관여하는 것이다.
‣ 예시 설정
Access Token 유효기간 1시간, Refresh Token 유효기간 2주.
‣ 토큰 재발급
Access Token 만료 후, Refresh Token으로 새로운 Access Token 발급 받는다.
‣ 로그아웃 시
사용자가 로그아웃하면 Refresh Token 삭제. 새 로그인 시, 새로운 토큰 발급.
따라서 짧은 Access Token과 긴 Refresh Token 조합으로 보안 강화 및 사용자 편의 제공할 수 있다. 하지만 Refresh Token도 보안 위험 있으므로 적절한 유효기간 설정이 필요하다.
모든 응답과 오류를 가로채서 특정 로직을 수행
axios.interceptors.response.use
→ 응답 인터셉터를 설정
받은 응답을 그대로 반환
// 응답 인터셉터 설정
const interceptor = axios.interceptors.response.use(
response => {
// 성공적인 응답 처리
return response;
},
오류가 발생하면 오류로부터 원래의 요청 설정 (originalConfig) 및 오류 메시지와 상태 코드를 추출한다.
async error => {
// 오류 처리
const originalConfig = error.config; // 기존에 수행하려고 했던 작업
const msg = error.response.data.msg; // error msg from backend
const status = error.response.status; // 현재 발생한 에러 코드
if (status === 401 ) {
// 액세스 토큰 만료 시 처리
if(msg == "Expired Access Token. 토큰이 만료되었습니다.") {
// 새 토큰 요청
await axios.post(
`${import.meta.env.VITE_APP_GENERATED_SERVER_URL}/api/token/reissue`,{},
{headers: {
Authorization: `${localStorage.getItem('Authorization')}`,
Refresh: `${localStorage.getItem('Refresh')}`,
}},
)
.then((res) => {
// 새 토큰 저장 및 기존 토큰 삭제
localStorage.setItem("authorization", res.headers.authorization);
localStorage.setItem("refresh", res.headers.refresh);
localStorage.removeItem('Authorization');
localStorage.removeItem('Refresh');
// 원래 요청 재시도
originalConfig.headers["authorization"]="Bearer "+res.headers.authorization;
originalConfig.headers["refresh"]= res.headers.refresh;
return refreshAPI(originalConfig);
})
(코드 리팩토링)
.then((res) => {
// 로컬스토리지에 새 토큰 저장
localStorage.setItem("Authorization", res.headers.authorization);
localStorage.setItem("Refresh", res.headers.refresh);
// 새로 응답받은 데이터로 토큰 만료로 실패한 요청에 대한 인증 시도 (header에 토큰 담아 보낼 때 사용)
originalConfig.headers["authorization"]="Bearer "+res.headers.authorization;
originalConfig.headers["refresh"]= res.headers.refresh;
// 새로운 토큰으로 재요청
return refreshAPI(originalConfig);
})
localStorage.setItem("Authorization", res.headers.authorization);
를 실행하면 Authorization
이라는 키에 대한 기존의 값을 새로운 res.headers.authorization
값으로 대체된다.removeItem
으로 제거할 필요가 없다! setItem
을 호출할 때마다 해당 키에 대한 최신 값으로 업데이트되기 때문이다.재발급하여 로컬스토리지에 저장한 토큰을 사용하지 않고 응답 데이터 토큰을 사용하는 이유
.then(() =>{
window.location.reload();
})
else{
// 리프레시 토큰 만료 시 처리
localStorage.clear();
navigate("/");
window.alert("토큰이 만료되어 자동으로 로그아웃 되었습니다.")
}
컴포넌트가 사라질 때(언마운트될 때) 설정한 인터셉터를 제거하여 다른 컴포넌트나 페이지에서의 영향 방지
// 컴포넌트 언마운트 시 인터셉터 제거
return () => {
axios.interceptors.response.eject(interceptor);
};
import axios from 'axios';
import { useEffect } from 'react';
import { useNavigate } from 'react-router'
export default function TokenRefresher() {
const navigate = useNavigate();
useEffect(() => {
const refreshAPI = axios.create({
baseURL: import.meta.env.VITE_APP_GENERATED_SERVER_URL,
headers: {"Content-Type": "application/json"} // header의 Content-Type을 JSON 형식의 데이터를 전송한다
});
const interceptor = axios.interceptors.response.use(
// 성공적인 응답 처리
response => {
// console.log('Starting Request', response)
return response;
},
async error => {
const originalConfig = error.config; // 기존에 수행하려고 했던 작업
const msg = error.response.data.msg; // error msg from backend
const status = error.response.status; // 현재 발생한 에러 코드
// access_token 재발급
if (status === 401 ) {
if(msg == "Expired Access Token. 토큰이 만료되었습니다") {
// console.log("토큰 재발급 요청");
await axios.post(
`${import.meta.env.VITE_APP_GENERATED_SERVER_URL}/api/token/reissue`,{},
{headers: {
Authorization: `${localStorage.getItem('Authorization')}`,
Refresh: `${localStorage.getItem('Refresh')}`,
}},
)
.then((res) => {
// console.log("res : ", res);
// 새 토큰 저장
localStorage.setItem("Authorization", res.headers.authorization);
localStorage.setItem("Refresh", res.headers.refresh);
// 새로 응답받은 데이터로 토큰 만료로 실패한 요청에 대한 인증 시도 (header에 토큰 담아 보낼 때 사용)
originalConfig.headers["authorization"]="Bearer "+res.headers.authorization;
originalConfig.headers["refresh"]= res.headers.refresh;
// console.log("New access token obtained.");
// 새로운 토큰으로 재요청
return refreshAPI(originalConfig);
})
.catch(() => {
console.error('An error occurred while refreshing the token:', error);
});
}
// refresh_token 재발급과 예외 처리
// else if(msg == "만료된 리프레시 토큰입니다") {
else{
localStorage.clear();
navigate("/");
// window.alert("토큰이 만료되어 자동으로 로그아웃 되었습니다.")
}
}
else if(status == 400 || status == 404 || status == 409) {
// window.alert(msg);
// console.log(msg)
}
// console.error('Error response:', error);
// 다른 모든 오류를 거부하고 처리
return Promise.reject(error);
},
);
return () => {
axios.interceptors.response.eject(interceptor);
};
}, []);
return (
<div></div>
)
}
정리
이 컴포넌트는 API 요청 중 액세스 토큰이 만료된 경우 자동으로 (리프레시 토큰으로) 새 토큰을 요청하고, 해당 토큰으로 원래의 요청을 다시 시도한다.
또한, 리프레시 토큰이 만료된 경우 사용자를 로그아웃시키고 로그인 페이지로 이동시킨다.
useEffect의 반환 함수에서 설정한 인터셉터를 제거한다. 이는 컴포넌트가 언마운트될 때 인터셉터가 더 이상 적용되지 않도록 한다.
const Router = () => {
return (
<BrowserRouter>
<TokenRefresher />
<ConditionalLayout>
<Routes>
<Route path="/" element={<Home />} />\
</ConditionalLayout>
</BrowserRouter>
);
};
export default Router;
인터셉터를 사용함으로써 전체 애플리케이션의 HTTP 통신 관련 로직을 효율적으로 관리할 수 있다. 특히 토큰 기반 인증 시스템에서는 토큰의 자동 갱신과 관련된 로직을 중앙에서 처리할 수 있어 코드의 복잡성을 크게 줄이고 보안성과 사용자 경험을 향상시키는 데 큰 도움이 될 것이다.
참고자료
Access Token & Refresh Token 원리
[React] axios interceptor를 이용한 token refresh