diff --git a/front/src/App.tsx b/front/src/App.tsx
index ada21efc..34cd20a2 100644
--- a/front/src/App.tsx
+++ b/front/src/App.tsx
@@ -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";
@@ -8,16 +8,14 @@ import Login from "./pages/Login";
function App() {
return (
-
-
- } />
- } />
- }>
- } />
- } />
-
-
-
+
+ } />
+ } />
+ }>
+ } />
+ } />
+
+
);
}
diff --git a/front/src/api/axiosInstance.ts b/front/src/api/axiosInstance.ts
new file mode 100644
index 00000000..6c4dc1f2
--- /dev/null
+++ b/front/src/api/axiosInstance.ts
@@ -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;
diff --git a/front/src/api/token.ts b/front/src/api/token.ts
new file mode 100644
index 00000000..361dc991
--- /dev/null
+++ b/front/src/api/token.ts
@@ -0,0 +1,61 @@
+import axiosInstance from "./axiosInstance";
+
+// 토큰 검증 API
+export async function validateAccessToken(token: string): Promise {
+ console.log("액세트 토큰이 유효한가?:", token);
+ try {
+ const response = await axiosInstance.post(
+ "/auth/api/v2/validation",
+ {},
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+ console.log("액세스 토큰 응답 상태(status):", response.status);
+ console.log("액세스토큰 응답 데이터:", response.data);
+
+ if (response.status === 200) {
+ console.log("토큰 검증 성공:", response.data);
+ return true;
+ } else {
+ console.error("토큰 검증 실패:", response.data);
+ return false;
+ }
+ } catch (error) {
+ console.error("토큰 검증 중 오류 발생:", error);
+ return false;
+ }
+}
+
+// 새로운 accessToken 요청
+export async function refreshAccessToken(
+ refreshToken: string
+): Promise<{ accessToken: string; refreshToken: string } | null> {
+ console.log("리프레시 토큰으로 새로운 액세스 토큰 요청:", refreshToken);
+ try {
+ 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);
+ return response.data.data;
+ } else {
+ console.error("accessToken 갱신 실패:", response.data);
+ return null;
+ }
+ } catch (error) {
+ console.error("API 호출 중 오류 발생:", error);
+ return null;
+ }
+}
diff --git a/front/src/components/Auth/LoginBox.tsx b/front/src/components/Auth/LoginBox.tsx
index f437549b..7d6b45d6 100644
--- a/front/src/components/Auth/LoginBox.tsx
+++ b/front/src/components/Auth/LoginBox.tsx
@@ -1,35 +1,47 @@
import LoginIcon from "@mui/icons-material/Login";
import PersonAddAlt1Icon from "@mui/icons-material/PersonAddAlt1";
+import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
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, useState } from "react";
import { useNavigate } from "react-router";
import { LoginValidation } from "../../hooks/LoginValidation";
import { useLoginStore } from "../../store/useLoginStore";
-
+import type { AlertInfo } from "../../types/join";
async function login(email: string, password: string) {
try {
- const response = await fetch(
+ const response = await axios.post(
"http://kpring.duckdns.org/user/api/v1/login",
+ { email, password },
{
- method: "POST",
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify({ email, password }),
}
);
- const data = await response.json();
- if (response.ok) {
- console.log("로그인 성공:", data);
- return data.data;
+ const data = response.data;
+ console.log("로그인 성공:", data);
+ return data.data;
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ if (error.response) {
+ // 서버 응답이 있지만, 응답 코드가 2xx가 아님
+ console.error("로그인 실패:", error.response.data);
+ } else if (error.request) {
+ // 요청이 이루어졌으나 응답을 받지 못함
+ console.error("응답 없음:", error.request);
+ } else {
+ // 요청 설정 중에 문제 발생
+ console.error("API 호출 중 오류 발생:", error.message);
+ }
} else {
- console.error("로그인 실패:", data);
- return null;
+ // axios 에러가 아닌 경우
+ console.error("예상치 못한 오류 발생:", error);
}
- } catch (error) {
- console.error("API 호출 중 오류 발생:", error);
return null;
}
}
@@ -44,7 +56,15 @@ function LoginBox() {
validatePassword,
validators,
} = LoginValidation();
+ const [open, setOpen] = useState(false);
+ const [alertInfo, setAlertInfo] = useState({
+ severity: "info",
+ message: "",
+ });
const { setTokens } = useLoginStore();
+
+ const navigate = useNavigate();
+
const onChangeHandler = (
field: string,
event: React.ChangeEvent
@@ -62,13 +82,36 @@ function LoginBox() {
if (result) {
//console.log("토큰 설정:", result);
setTokens(result.accessToken, result.refreshToken);
- //navigate("/");
+
+ setAlertInfo({
+ severity: "success",
+ message: "로그인 성공! 3초 후 메인 페이지로 이동합니다.",
+ });
+ setOpen(true);
+
+ setTimeout(() => {
+ navigate("/");
+ }, 3000);
} else {
console.error("로그인 실패.");
- alert("로그인 실패. 이메일 혹은 비밀번호를 확인해 주세요.");
+ setAlertInfo({
+ severity: "error",
+ message: "로그인 실패. 이메일 혹은 비밀번호를 확인해 주세요.",
+ });
+ setOpen(true);
}
};
- const navigate = useNavigate();
+
+ const clickCloseHandler = (
+ event: Event | SyntheticEvent,
+ reason?: SnackbarCloseReason
+ ) => {
+ if (reason === "clickaway") {
+ return;
+ }
+ setOpen(false);
+ };
+
return (
@@ -136,6 +179,19 @@ function LoginBox() {
+
+
+ {alertInfo.message}
+
+
);
diff --git a/front/src/index.tsx b/front/src/index.tsx
index d0a4552b..61bb63dc 100644
--- a/front/src/index.tsx
+++ b/front/src/index.tsx
@@ -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 = ({ 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
@@ -16,7 +35,11 @@ const queryClient = new QueryClient();
root.render(
-
+
+
+
+
+
);
diff --git a/front/src/store/useLoginStore.ts b/front/src/store/useLoginStore.ts
index d33b78fc..aa76de93 100644
--- a/front/src/store/useLoginStore.ts
+++ b/front/src/store/useLoginStore.ts
@@ -1,17 +1,23 @@
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((set) => ({
- accessToken: "",
- refreshToken: "",
+ accessToken: localStorage.getItem("dicoTown_AccessToken") || "",
+ refreshToken: localStorage.getItem("dicoTown_RefreshToken") || "",
setTokens: (accessToken, refreshToken) => {
set({ accessToken, refreshToken });
localStorage.setItem("dicoTown_AccessToken", accessToken);
localStorage.setItem("dicoTown_RefreshToken", refreshToken);
},
+ clearTokens: () => {
+ set({ accessToken: "", refreshToken: "" });
+ localStorage.removeItem("dicoTown_AccessToken");
+ localStorage.removeItem("dicoTown_RefreshToken");
+ },
}));
diff --git a/front/src/utils/axiosInterceptor.ts b/front/src/utils/axiosInterceptor.ts
new file mode 100644
index 00000000..1445c54c
--- /dev/null
+++ b/front/src/utils/axiosInterceptor.ts
@@ -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;