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;