Skip to content

Commit

Permalink
[feat] axios Interceptor로 토큰 검증 및 갱신 자동화
Browse files Browse the repository at this point in the history
  • Loading branch information
allone9425 committed Jun 28, 2024
1 parent 34b7b79 commit 1495b38
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 60 deletions.
20 changes: 9 additions & 11 deletions front/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import "./App.css";
import Layout from "./components/Layout/Layout";
import { ServerMap } from "./components/Map/ServerMap";
Expand All @@ -8,16 +8,14 @@ import Login from "./pages/Login";
function App() {
return (
<div className="App">
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/join" element={<Join />} />
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="server/:serverId" element={<ServerMap />} />
</Route>
</Routes>
</BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/join" element={<Join />} />
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="server/:serverId" element={<ServerMap />} />
</Route>
</Routes>
</div>
);
}
Expand Down
22 changes: 22 additions & 0 deletions front/src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
이 인스턴스가 필요한 이유 :
--------------------------------------------------------
<로직>
토큰이 유효한가?
토큰이 유효하지 않다면 리프레시 토큰으로 발급
발급실패 시 로그인페이지로 이동
--------------------------------------------------------
axios 인터셉터로 위의 로직대로 코드 작성 하다가 요청 인터셉터가 계속해서 같은 요청을 반복하는 상황이 발생,
token.ts의 두 함수(validateAccessToken, refreshAccessToken)에서 요청을 보내는 부분이 인터셉터에 의해 다시 가로채지고 있는 것으로 추정
이를 해결하기 위한 방법으로 아래의 인스턴스를 별도 만들어서 해결함
*/
import axios from "axios";

const axiosInstance = axios.create({
baseURL: "http://kpring.duckdns.org",
headers: {
"Content-Type": "application/json",
},
});

export default axiosInstance;
29 changes: 16 additions & 13 deletions front/src/api/token.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import axios from "axios";
import axiosInstance from "./axiosInstance";

// 토큰 검증 API
async function validateAccessToken(token: string) {
export async function validateAccessToken(token: string): Promise<boolean> {
console.log("액세트 토큰이 유효한가?:", token);
try {
const response = await axios.post(
"http://kpring.duckdns.org/auth/api/v2/validation",
const response = await axiosInstance.post(
"/auth/api/v2/validation",
{},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
console.log("액세스 토큰 응답 상태(status):", response.status);
console.log("액세스토큰 응답 데이터:", response.data);

if (response.status === 200) {
console.log("토큰 검증 성공:", response.data);
Expand All @@ -28,19 +30,22 @@ async function validateAccessToken(token: string) {
}

// 새로운 accessToken 요청
async function refreshAccessToken(refreshToken: string) {
export async function refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; refreshToken: string } | null> {
console.log("리프레시 토큰으로 새로운 액세스 토큰 요청:", refreshToken);
try {
const response = await axios.post(
"http://kpring.duckdns.org/auth/api/v1/acess_token",
{
refreshToken,
},
const response = await axiosInstance.post(
"/auth/api/v1/access_token",
{ refreshToken },
{
headers: {
"Content-Type": "application/json",
},
}
);
console.log("새로운 액세스 토큰 응답 상태(status):", response.status);
console.log("새로운 액세스 토큰 응답 데이터 :", response.data);

if (response.status === 200) {
console.log("accessToken 갱신 성공:", response.data);
Expand All @@ -54,5 +59,3 @@ async function refreshAccessToken(refreshToken: string) {
return null;
}
}

export { refreshAccessToken, validateAccessToken };
32 changes: 1 addition & 31 deletions front/src/components/Auth/LoginBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import Button from "@mui/material/Button";
import Snackbar, { SnackbarCloseReason } from "@mui/material/Snackbar";
import TextField from "@mui/material/TextField";
import axios from "axios";
import React, { SyntheticEvent, useEffect, useState } from "react";
import React, { SyntheticEvent, useState } from "react";
import { useNavigate } from "react-router";
import { refreshAccessToken, validateAccessToken } from "../../api/token";
import { LoginValidation } from "../../hooks/LoginValidation";
import { useLoginStore } from "../../store/useLoginStore";
import type { AlertInfo } from "../../types/join";
Expand Down Expand Up @@ -66,35 +65,6 @@ function LoginBox() {

const navigate = useNavigate();

useEffect(() => {
const checkAndSetTokens = async () => {
//localStorage에서 토큰 가져오기
const accessToken = localStorage.getItem("dicoTown_AccessToken");
const refreshToken = localStorage.getItem("dicoTown_RefreshToken");

//accessToken이 유효한지 검증하고 유효하지 않으면 refreshToken 사용해서 새로운 토큰 발급하기
if (accessToken && refreshToken) {
const isTokenValid = await validateAccessToken(accessToken);
if (!isTokenValid) {
const newTokens = await refreshAccessToken(refreshToken);
if (newTokens) {
setTokens(newTokens.accessToken, newTokens.refreshToken);
console.log("새로운 토큰 저장 완료.");
} else {
//accessToken 이 유효하지 않고, 새로운 토큰 발급도 실패하면 로그인 페이지로 리디렉션
console.error("새로운 토큰 발급 실패. 로그인 페이지로 이동합니다.");
navigate("/login");
}
} else {
setTokens(accessToken, refreshToken);
console.log("토큰을 불러왔음");
}
}
};

checkAndSetTokens();
}, [setTokens, navigate]);

const onChangeHandler = (
field: string,
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
Expand Down
31 changes: 27 additions & 4 deletions front/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { ThemeProvider } from "@emotion/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { useNavigate } from "react-router";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
import reportWebVitals from "./reportWebVitals";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@emotion/react";
import { useLoginStore } from "./store/useLoginStore";
import theme from "./theme/themeConfig";
import interceptorSetup from "./utils/axiosInterceptor";

interface InterceptorSetupProps {
children: React.ReactNode;
}

// axios 인터셉터 설정 컴포넌트
const InterceptorSetup: React.FC<InterceptorSetupProps> = ({ children }) => {
const store = useLoginStore();
const navigate = useNavigate();
React.useEffect(() => {
interceptorSetup(store, navigate);
}, [store, navigate]);

return <>{children}</>;
};

const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
Expand All @@ -16,7 +35,11 @@ const queryClient = new QueryClient();
root.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<App />
<BrowserRouter>
<InterceptorSetup>
<App />
</InterceptorSetup>
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
);
Expand Down
8 changes: 7 additions & 1 deletion front/src/store/useLoginStore.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import create from "zustand";

interface LoginState {
export interface LoginState {
accessToken: string;
refreshToken: string;
setTokens: (accessToken: string, refreshToken: string) => void;
clearTokens: () => void;
}

export const useLoginStore = create<LoginState>((set) => ({
Expand All @@ -14,4 +15,9 @@ export const useLoginStore = create<LoginState>((set) => ({
localStorage.setItem("dicoTown_AccessToken", accessToken);
localStorage.setItem("dicoTown_RefreshToken", refreshToken);
},
clearTokens: () => {
set({ accessToken: "", refreshToken: "" });
localStorage.removeItem("dicoTown_AccessToken");
localStorage.removeItem("dicoTown_RefreshToken");
},
}));
140 changes: 140 additions & 0 deletions front/src/utils/axiosInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
Axios Interceptor의 역할 :
자동으로 토큰을 검증하고, 유효하지 않은 경우 자동으로 갱신하는 시스템을 자동화 할 수 있음
1. 자동 토큰 검증(모든 요청 전)
- 요청을 보내기 전에 액세스 토큰이 있는지 확인
- 액세스 토큰이 있다면 validateAccessToken 함수를 통해 토큰의 유효성 확인
- 토큰이 유효하지 않다면, 자동으로 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급
2. 자동 토큰 갱신(401 응답 처리)
- 서버 응답이 401 Unauthorized인 경우 즉 액세스 토큰이 만료된 경우에 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급
- 새로운 토큰이 발급되면 저장하고 원래 요청 재시도(토큰이 만료되어 처음 실패했던 API 요청을 새로운 토큰으로 요청 시도)
- 만약 새로운 토큰 발급에 실패하면 사용자를 로그인 페이지로 이동
3. 상태 관리와 로컬 스토리지 동기화
1) 토큰 저장:
- 새로운 액세스 토큰과 리프레시 토큰을 상태와 로컬 스토리지에 저장
2) 토큰 초기화:
- 토큰 갱신이 실패하거나, 유효하지 않은 토큰이 감지되면 상태와 로컬 스토리지를 초기화
<전체 동작흐름>
요청 인터셉터 (Request Interceptor)
1. 모든 요청 전에 액세스 토큰을 확인
2. 액세스 토큰이 존재하면 validateAccessToken 함수를 호출하여 토큰의 유효성 확인
3. 토큰이 유효하지 않다면, refreshAccessToken 함수를 호출하여 새로운 토큰을 발급받음
4. 토큰이 유효하면 요청 헤더에 액세스 토큰 추가
응답 인터셉터 (Response Interceptor)
1. 서버 응답이 200번대 상태 코드인 경우, 응답을 그대로 반환
2. 서버 응답이 401 Unauthorized인 경우, refreshAccessToken 함수를 호출하여 새로운 토큰을 발급받고 원래 요청 재시도
3. 만약 새로운 토큰 발급에 실패하면, 상태와 로컬 스토리지를 초기화하고 로그인 페이지로 이동
*/

import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { NavigateFunction } from "react-router-dom";
import { refreshAccessToken, validateAccessToken } from "../api/token";
import { LoginState } from "../store/useLoginStore";

interface RetryConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}

// Axios 인터셉터 설정 함수
const interceptorSetup = (store: LoginState, navigate: NavigateFunction) => {
// 요청 인터셉터 설정
axios.interceptors.request.use(
async (config: RetryConfig) => {
const token = store.accessToken;
console.log("요청 인터셉터의 Token :", token);

if (token) {
// 액세스 토큰 유효성 검사
const isTokenValid = await validateAccessToken(token);
console.log("유효한 토큰 : ", isTokenValid);

if (!isTokenValid) {
console.log("토큰이 유효하지 않습니다. 새로운 토큰 발급 시도");
// 액세스 토큰이 유효하지 않으면 리프레시 토큰을 사용해 새로운 액세스 토큰 발급
const newTokens = await refreshAccessToken(store.refreshToken);
if (newTokens) {
// 새로운 토큰을 상태와 로컬 스토리지에 저장
store.setTokens(newTokens.accessToken, newTokens.refreshToken);
// 요청 헤더에 새로운 액세스 토큰 추가
config.headers.Authorization = `Bearer ${newTokens.accessToken}`;
console.log("새로운 토큰:", newTokens);
} else {
// 새로운 토큰 발급 실패 시 로그인 페이지로 이동
console.error(
"리프레시 토큰을 이용한 발급이 실패, 로그인 페이지로 이동"
);
store.clearTokens();
navigate("/login");
return Promise.reject(new Error("토큰이 만료되었습니다."));
}
} else {
// 액세스 토큰이 유효하면 요청 헤더에 추가
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error: AxiosError) => {
// 요청 에러 처리
console.error("Request Error:", error);
return Promise.reject(error);
}
);
// 응답 인터셉터 설정
axios.interceptors.response.use(
(response: AxiosResponse) => {
console.log("응답 인터셉터의 response:", response);
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig;
if (
error.response &&
// 인증 오류 (401) 처리
error.response.status === 401 &&
originalRequest &&
// 요청이 재시도되지 않았는지 확인
!originalRequest._retry
) {
originalRequest._retry = true;
console.log(
"응답 인터셉터 401 오류, 리프레시 토큰을 토큰을 사용해 새로운 액세스 토큰 발급 "
);
// 리프레시 토큰을 사용해 새로운 액세스 토큰 발급
const newTokens = await refreshAccessToken(store.refreshToken);
if (newTokens) {
// 새로운 토큰을 상태와 로컬 스토리지에 저장
store.setTokens(newTokens.accessToken, newTokens.refreshToken);
// 원래 요청 헤더에 새로운 액세스 토큰 추가
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
console.log("401 오류 이후의 새로운 토큰 :", newTokens);
// 원래 요청을 재시도
return axios(originalRequest);
} else {
// 새로운 토큰 발급 실패 시 로그인 페이지로 이동
console.error(
"401 오류 이후 토큰을 새로운 토큰 발급 실패, 로그인페이지로 이동"
);
store.clearTokens();
navigate("/login");
return Promise.reject(new Error("토큰이 만료되었습니다."));
}
}
// 그 외의 응답 에러 처리
console.error("응답 오류:", error);
return Promise.reject(error);
}
);
};

export default interceptorSetup;

0 comments on commit 1495b38

Please sign in to comment.