[Next.js] Axios Interceptors로 토큰 자동 재발급하기

minzip·2024년 7월 8일
5

Next.js

목록 보기
3/3
post-thumbnail

지난 포스팅의 구글 로그인 적용과정을 통해서 클라이언트는 access토큰과 refresh 토큰을 성공적으로 받아올 수 있었다.
이번 포스팅에서는 두 토큰을 이용해 토큰의 만료 여부에 따라 자동 재발급 혹은 자동 로그아웃을 어떻게 구현하였는지 정리해보고자 한다!

간단 개념 정리 ✍️

JWT (Json Web Token)

인증에 필요한 정보들을 객체 형태로 Token에 담아 암호화시켜 사용하는 것

  • JWT는 다음과 같이 세 부분으로 구성된다.
    - 헤더(Header): 토큰 유형과 해싱 알고리즘 정보를 포함.
    - 페이로드(Payload): 인증에 필요한 정보(예: 사용자 ID, 권한 등)를 포함.
    - 서명(Signature): 토큰의 무결성을 검증하기 위해 헤더와 페이로드를 비밀 키로 해싱한 값.
    - 암호화 방식: Base64 URL 인코딩을 사용하여 토큰을 문자열로 변환하며, 전송 중 변조를 방지하기 위해 서명 부분을 사용.

JWT는 주로 웹 애플리케이션에서 사용자 인증 및 권한 부여에 사용되며, 서버 간 데이터 전달 시에도 사용될 수 있다.
또한 모든 정보가 토큰 자체에 포함되어 있어 서버는 별도의 세션 저장소를 유지할 필요가 없다(Stateless).

access 토큰과 refresh 토큰

access 토큰은 사용자에 대한 정보를 담고 있어서 서비스에 접근(Access)할 수 있는 토큰을 의미한다.

refresh 토큰은 그 자체로 어떤 정보를 담고있지는 않지만, access 토큰이 만료되었을 때 서버에서 이를 확인하고 새로운 액세스 토큰을 발급해주기 위해 사용한다.

만약 access 토큰만 사용하면 어떻게 될까? 🤔

JWT는 Stateless이기 때문에 서버에서 상태를 관리하지 않는다.
즉 access 토큰으로 JWT를 사용하여 사용자 검증을 진행하면 서버에서 토큰의 상태를 제어할 수 없다는 뜻이다. 서버에서는 만료가 되어도 자동으로 access 토큰의 유효 기간을 늘려주지는 않는다.

위키의 v.1.0의 경우에는 refresh 토큰을 통한 access토큰 재발급 api가 구현되어 있지 않았고, access토큰만 사용하는 서비스가 되어 토큰 만료에 대한 문제가 빈번하게 발생하게 되었다.
사용자가 서비스를 사용하고 있는 상태임에도 자동적으로 현재 토큰이 만료 되었는지 체크하지 않고 토큰을 재발급하지도 않았기에, 사용자가 토큰을 담은 요청을 보냈을 시에만 서버에서 만료에 의한 에러를 반환하면 강제 로그아웃을 시키는... 초가집같은 서비스가 된 것이다😰

또한, 외부자에게 access 토큰을 탈취당하면 만료가 될 때까지 속수무책이라는 문제도 존재한다.

이를 개선하기 위해 access 토큰의 유효시간을 짧게하는 대신 유효시간이 긴 refresh 토큰을 함께 발급받고, 프론트에서는 refresh 토큰을 이용해 access 토큰 자체를 계속 갱신해주어야 한다!

구현해보자! 🚀

1. 토큰 만료 확인

토큰 자체에 만료기간에 대한 정보가 들어있으므로, 이를 파싱해주어야 한다.

  • 다음 메서드는 JWT의 페이로드 부분을 디코딩하여 JSON 객체로 변환해준다
const parseJwt = (token: string | null) => {
	if (token) return JSON.parse(atob(token.split('.')[1]));
};
  • 이후 JWT의 페이로드에서 만료시간 관련 클레임인 exp (expiration)을 추출하고 변환시켜 만료 일자와 현재 일자를 비교하여 토큰의 만료 여부를 판단한다.
  • 만약 access 토큰만 만료가 되었다면 refresh 토큰을 이용해 재발급을 받아주고
  • refresh 토큰도 만료 되었다면 자동 로그아웃을 진행한다.
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;
};

2. Axios Interceptors ⭐️

토큰을 헤더에 담아 API 요청을 보낼 때, access 토큰의 만료로 인해 서버에서 401 Unauthorized 에러를 반환한다면 어떻게 해야할까?

바로 Axios의 인터셉터(interceptors)를 활용해 요청 또는 응답을 가로채서 해당 문제를 처리할 수 있다.
여기서는 응답 인터셉터를 사용하여 access 토큰이 만료되었을 때 자동으로 갱신하고, 갱신된 토큰으로 원래의 요청을 재시도하도록 한다.

2.1. Axios 인스턴스 생성

  • 기본 URL과 요청 타임아웃, 그리고 Authorization 헤더에 JWT Access 토큰을 설정한 Axios 인스턴스를 생성한다.
const authAxios = axios.create({
    baseURL,
    timeout: 8000,
    headers: {
        Authorization: `Bearer ${token}`,
    },
});

2.2. 응답 인터셉터 설정

  • 응답 성공 시: 응답을 그대로 반환하고
  • 응답 에러 시:
    - 에러가 401(Unauthorized)인 경우, 새로운 Access 토큰을 발급받아 원래의 요청 헤더를 업데이트하고, 해당 요청을 재시도한다.
    • 만약 토큰 갱신에 실패하면 사용자에게 알리고, 로컬 저장소에서 토큰을 삭제한 후 로그인 페이지로 리다이렉트시킨다.
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);
        }
    }
);

2.3. 실제 API에 적용

  • 토큰을 사용해 문서 생성 요청을 보내는 메서드에서, 다음과 같이 위의 인터셉터를 활용한 메서드를 사용해 자동 갱신 및 요청 재시도를 적용해 볼 수 있다.
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;
    }
};

3. 로그인 UI 관리

단순히 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 ? '로그인버튼' : '로그인'}
	/>
     ...
    </>

마치며 💭

위키 프로젝트에서 로그인 기능을 처음으로 구현하면서, 이론적으로만 알고 있던 토큰을 이용한 사용자 검증을 실제로 적용해 볼 수 있었다.
현재 화면에는 로그아웃 버튼이 기획되어 있지 않아 자동 로그아웃 기능만 구현되어 있는 상황이다.
하지만 기획자와 논의한 후에는 로그아웃 버튼을 추가하여, 서버에서도 불필요한 토큰을 저장하지 않도록 지속적으로 개선해 나갈 예정이다!🔥

profile
내일은 더 성장하기

0개의 댓글