지난 포스팅의 구글 로그인 적용과정을 통해서 클라이언트는 access토큰과 refresh 토큰을 성공적으로 받아올 수 있었다.
이번 포스팅에서는 두 토큰을 이용해 토큰의 만료 여부에 따라 자동 재발급 혹은 자동 로그아웃을 어떻게 구현하였는지 정리해보고자 한다!
인증에 필요한 정보들을 객체 형태로 Token에 담아 암호화시켜 사용하는 것
JWT는 주로 웹 애플리케이션에서 사용자 인증 및 권한 부여에 사용되며, 서버 간 데이터 전달 시에도 사용될 수 있다.
또한 모든 정보가 토큰 자체에 포함되어 있어 서버는 별도의 세션 저장소를 유지할 필요가 없다(Stateless).
access 토큰은 사용자에 대한 정보를 담고 있어서 서비스에 접근(Access)할 수 있는 토큰을 의미한다.
refresh 토큰은 그 자체로 어떤 정보를 담고있지는 않지만, access 토큰이 만료되었을 때 서버에서 이를 확인하고 새로운 액세스 토큰을 발급해주기 위해 사용한다.
JWT는 Stateless이기 때문에 서버에서 상태를 관리하지 않는다.
즉 access 토큰으로 JWT를 사용하여 사용자 검증을 진행하면 서버에서 토큰의 상태를 제어할 수 없다는 뜻이다. 서버에서는 만료가 되어도 자동으로 access 토큰의 유효 기간을 늘려주지는 않는다.
위키의 v.1.0의 경우에는 refresh 토큰을 통한 access토큰 재발급 api가 구현되어 있지 않았고, access토큰만 사용하는 서비스가 되어 토큰 만료에 대한 문제가 빈번하게 발생하게 되었다.
사용자가 서비스를 사용하고 있는 상태임에도 자동적으로 현재 토큰이 만료 되었는지 체크하지 않고 토큰을 재발급하지도 않았기에, 사용자가 토큰을 담은 요청을 보냈을 시에만 서버에서 만료에 의한 에러를 반환하면 강제 로그아웃을 시키는... 초가집같은 서비스가 된 것이다😰
또한, 외부자에게 access 토큰을 탈취당하면 만료가 될 때까지 속수무책이라는 문제도 존재한다.
이를 개선하기 위해 access 토큰의 유효시간을 짧게하는 대신 유효시간이 긴 refresh 토큰을 함께 발급받고, 프론트에서는 refresh 토큰을 이용해 access 토큰 자체를 계속 갱신해주어야 한다!
토큰 자체에 만료기간에 대한 정보가 들어있으므로, 이를 파싱해주어야 한다.
const parseJwt = (token: string | null) => {
if (token) return JSON.parse(atob(token.split('.')[1]));
};
exp
(expiration)을 추출하고 변환시켜 만료 일자와 현재 일자를 비교하여 토큰의 만료 여부를 판단한다.export const checkAndRefreshToken = async () => {
const accessToken = LocalStorage.getItem('access');
const refreshToken = LocalStorage.getItem('refresh');
if (!accessToken || !refreshToken) {
// console.log('필요 토큰 미존재');
return null;
}
const decodedAccess = parseJwt(accessToken);
const decodedRefresh = parseJwt(refreshToken);
if (decodedAccess && decodedAccess.exp * 1000 < Date.now()) {
// console.log('access 토큰 만료');
if (decodedRefresh && decodedRefresh.exp * 1000 >= Date.now()) {
try {
// console.log('자동 재연장')
const { accessToken: newAccessToken } = await getNewToken();
return newAccessToken;
} catch (error) {
LocalStorage.removeItem('access');
LocalStorage.removeItem('refresh');
return null;
}
} else {
// console.log('Refresh 토큰 만료');
alert('토큰 만료로 자동 로그아웃되었습니다. 다시 로그인해주세요.');
LocalStorage.removeItem('access');
LocalStorage.removeItem('refresh');
return null;
}
}
return accessToken;
};
// 토큰 유효성 검사 함수
export const AuthVerify = async () => {
const accessToken = await checkAndRefreshToken();
if (!accessToken) {
return false;
}
return true;
};
토큰을 헤더에 담아 API 요청을 보낼 때, access 토큰의 만료로 인해 서버에서 401 Unauthorized 에러를 반환한다면 어떻게 해야할까?
바로 Axios의 인터셉터(interceptors)
를 활용해 요청 또는 응답을 가로채서 해당 문제를 처리할 수 있다.
여기서는 응답 인터셉터를 사용하여 access 토큰이 만료되었을 때 자동으로 갱신하고, 갱신된 토큰으로 원래의 요청을 재시도하도록 한다.
const authAxios = axios.create({
baseURL,
timeout: 8000,
headers: {
Authorization: `Bearer ${token}`,
},
});
authAxios.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
try {
const { accessToken } = await getNewToken();
if (!accessToken) {
throw new Error('토큰 갱신 실패');
}
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
LocalStorage.setItem('access', accessToken);
return authAxios(originalRequest);
} catch (refreshError) {
alert('토큰이 만료되었습니다. 재로그인 후 시도해주세요!');
LocalStorage.removeItem('access');
LocalStorage.removeItem('refresh');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
);
export const newDocs = async (body: CreateDocs) => {
try {
const access = LocalStorage.getItem('access');
const authAxios = getAuthAxios(access);
const response = await authAxios.post(`${baseURL}docs/`, body);
return response.data;
} catch (error) {
throw error;
}
};
단순히 API 요청을 하는 경우뿐만 아니라 사용자 인터페이스(UI)에서도 토큰 만료를 확인하고 갱신할 필요가 있다. 특히, 네비게이션 바와 같이 모든 페이지에서 공통으로 렌더링되는 컴포넌트에서 이러한 기능을 구현하는 것이 중요하다.
네비게이션 바에서 사용자가 유효한 로그인 상태인지를 표시해주기 위해, NavBar 컴포넌트에 토큰 검사 및 갱신 기능을 추가하였고, 사용자가 서비스를 자유롭게 이용하는 동안 토큰이 자동으로 갱신될 수 있도록 개선하였다.
const NavBar = () => {
const router = useRouter();
const pathname = usePathname();
const [isLogin, setIsLogin] = useState(false);
// 유효한 토큰을 가진 경우에만 상태 변경
useEffect(() => {
const checkLoginStatus = async () => {
const loginStatus = await AuthVerify();
setIsLogin(loginStatus === true);
};
checkLoginStatus();
}, [pathname]);
return (
<>
...
<Image onClick={() => {
if (!isLogin) {router.push('/login');}}}
src={isLogin ? '/img/welcome.png' : '/img/login.png'}
alt={isLogin ? '로그인버튼' : '로그인'}
/>
...
</>
위키 프로젝트에서 로그인 기능을 처음으로 구현하면서, 이론적으로만 알고 있던 토큰을 이용한 사용자 검증을 실제로 적용해 볼 수 있었다.
현재 화면에는 로그아웃 버튼이 기획되어 있지 않아 자동 로그아웃 기능만 구현되어 있는 상황이다.
하지만 기획자와 논의한 후에는 로그아웃 버튼을 추가하여, 서버에서도 불필요한 토큰을 저장하지 않도록 지속적으로 개선해 나갈 예정이다!🔥