diff --git a/src/app/api/_lib/tokenUtils.ts b/src/app/api/_lib/tokenUtils.ts new file mode 100644 index 0000000..e80b44a --- /dev/null +++ b/src/app/api/_lib/tokenUtils.ts @@ -0,0 +1,25 @@ +import { cookies } from 'next/headers'; + +export const setTokenCookies = async ( + accessToken: string, + refreshToken: string +) => { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production'; + + // 액세스 토큰 저장 + cookieStore.set('accessToken', accessToken, { + httpOnly: true, + sameSite: isProduction ? 'none' : 'lax', + secure: isProduction, + maxAge: 60 * 30, // 30분 + }); + + // 리프레시 토큰 저장 + cookieStore.set('refreshToken', refreshToken, { + httpOnly: true, + sameSite: isProduction ? 'none' : 'lax', + secure: isProduction, + maxAge: 60 * 60 * 24 * 14, // 14일 + }); +}; diff --git a/src/app/api/auth/kakao/route.ts b/src/app/api/auth/kakao/route.ts new file mode 100644 index 0000000..22cddc8 --- /dev/null +++ b/src/app/api/auth/kakao/route.ts @@ -0,0 +1,26 @@ +import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils'; +import { KakaoLoginRequestBody } from '@/src/services/pages/login/api'; +import { TokenUserResponseType } from '@/src/types/userType'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + const body: KakaoLoginRequestBody = await req.json(); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/oauth/sign-in/kakao`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ); + const data: TokenUserResponseType = await res.json(); + + if (!res.ok) return NextResponse.json(data, { status: res.status }); + + const { accessToken, refreshToken } = data; + + await setTokenCookies(accessToken, refreshToken); + + return NextResponse.json(data); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..e1df848 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,23 @@ +import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils'; +import { loginRequestBody } from '@/src/services/pages/login/api'; +import { TokenUserResponseType } from '@/src/types/userType'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + const body: loginRequestBody = await req.json(); + + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data: TokenUserResponseType = await res.json(); + + if (!res.ok) return NextResponse.json(data, { status: res.status }); + + const { accessToken, refreshToken } = data; + + await setTokenCookies(accessToken, refreshToken); + + return NextResponse.json(data); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f48c754 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; + +export async function DELETE() { + const res = NextResponse.json({ ok: true }); + + res.cookies.delete('accessToken'); + res.cookies.delete('refreshToken'); + + return res; +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..39e9f68 --- /dev/null +++ b/src/app/api/proxy/[...path]/route.ts @@ -0,0 +1,136 @@ +import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +interface TokenReissueResponse { + accessToken: string; + refreshToken: string; +} + +interface CustomRequestInit extends RequestInit { + duplex?: 'half' | 'full' | string; +} + +const handleApiResponse = async (res: Response) => { + // 204 코드시 처리 + if (res.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const contentType = res.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + // json 처리 + const resData = await res.json(); + return NextResponse.json(resData, { status: res.status }); + } else { + // 바이너리/파일 처리 + const buffer = await res.arrayBuffer(); + return new NextResponse(buffer, { status: res.status }); + } +}; + +const fetchWithAccessToken = async (req: NextRequest) => { + const cookieStore = await cookies(); + const headers = new Headers(req.headers); + const accessToken = cookieStore.get('accessToken')?.value; + const { pathname, search } = req.nextUrl; + const targetPath = pathname.replace('/api/proxy', '') + search; + + // 액세스 토큰 주입 + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + + const fetchOptions: CustomRequestInit = { + method: req.method, + headers, + body: req.body, + duplex: 'half', + }; + + // request 요청 + return await fetch( + `${process.env.NEXT_PUBLIC_API_URL}${targetPath}`, + fetchOptions + ); +}; + +const PUBLIC_PATH_PATTERNS = [ + /^\/$/, + /^\/detail\/\d+$/, + /^\/login$/, + /^\/login\/social\/kakao$/, + /^\/signup$/, + /^\/signup\/social\/kakao$/, +]; + +const handleProxyRequest = async (req: NextRequest): Promise => { + const cookieStore = await cookies(); + const refreshToken = cookieStore.get('refreshToken')?.value; + + const res = await fetchWithAccessToken(req); + + // 액세스 토큰 만료 + 리프레시 토큰이 있다면, + if (res.status === 401 && refreshToken) { + const refreshHeaders = new Headers(); + refreshHeaders.set('Authorization', `Bearer ${refreshToken}`); + + const refreshTokenRes = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auth/tokens`, + { + method: 'POST', + headers: refreshHeaders, + } + ); + const data: TokenReissueResponse = await refreshTokenRes.json(); + + // 갱신 실패시 + if (!refreshTokenRes.ok) { + // 로그인 페이지로 리다이렉트 + const response = new NextResponse(null); + + // 쿠키 삭제 + response.cookies.delete('accessToken'); + response.cookies.delete('refreshToken'); + + const currentPath = req.nextUrl.pathname; + const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) => + regex.test(currentPath) + ); + + if (!isPublicPath) { + // 인증 필요 -> 로그인 페이지로 redirect + return NextResponse.redirect(`/login?redirect_path=${currentPath}`); + } + + // 인증 필요 없는 Public Path -> 그냥 상태만 초기화 + return response; + } + + const { accessToken: newAccessToken, refreshToken: newRefreshToken } = data; + + // 새롭게 발급 받은 토큰 재설정 + await setTokenCookies(newAccessToken, newRefreshToken); + + // 다시 api 요청 + const retryRes = await fetchWithAccessToken(req); + + return handleApiResponse(retryRes); + } + + return handleApiResponse(res); +}; + +export async function GET(req: NextRequest) { + return handleProxyRequest(req); +} +export async function POST(req: NextRequest) { + return handleProxyRequest(req); +} +export async function PATCH(req: NextRequest) { + return handleProxyRequest(req); +} +export async function DELETE(req: NextRequest) { + return handleProxyRequest(req); +} diff --git a/src/components/pages/login/KakaoLoginClient.tsx b/src/components/pages/login/KakaoLoginClient.tsx index e6aca35..3f4827d 100644 --- a/src/components/pages/login/KakaoLoginClient.tsx +++ b/src/components/pages/login/KakaoLoginClient.tsx @@ -1,18 +1,48 @@ 'use client'; import LoadingSpinner from '@/src/components/primitives/LoadingSpinner'; import { KAKAO_REDIRECT_URI_LOGIN } from '@/src/constants/social'; -import useKakaoLoginUser from '@/src/hooks/pages/auth/useKakaoLoginUser'; +import { + KakaoLoginRequestBody, + kakaoLoginUser, +} from '@/src/services/pages/login/api'; +import { queries } from '@/src/services/primitives/queries'; import { useToastStore } from '@/src/store/useToastStore'; +import { TokenUserResponseType } from '@/src/types/userType'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useRef } from 'react'; export default function KakaoLoginClient() { const router = useRouter(); + const queryClient = useQueryClient(); const flagRef = useRef(false); const searchParams = useSearchParams(); const code = searchParams.get('code'); - const kakaoLoginMutation = useKakaoLoginUser(); + const redirectPath = searchParams.get('state'); const createToast = useToastStore((state) => state.createToast); + const kakaoLoginMutation = useMutation({ + mutationFn: async (data: KakaoLoginRequestBody) => + await kakaoLoginUser(data), + onSuccess: async (data: TokenUserResponseType) => { + queryClient.setQueryData(queries.user(), data.user); // 리액트 쿼리 데이터 캐싱 + + // redirectPath 값이 있으면, 해당 페이지로 다시 이동 + // 보안 취약점을 강화하기 위해 redirectPath.startsWith('/')로 현재 도메인내의 경로인지 확인 + if (redirectPath && redirectPath.startsWith('/')) { + router.replace(redirectPath); + } else { + router.replace('/'); + } + }, + onError: (error) => { + console.error(error); + createToast({ + message: '로그인에 실패하였습니다. 다시 시도 해주세요.', + type: 'failed', + }); + router.replace('/login'); + }, + }); useEffect(() => { if (!code) { @@ -32,7 +62,7 @@ export default function KakaoLoginClient() { redirectUri: KAKAO_REDIRECT_URI_LOGIN, token: code, }); - }, [code, kakaoLoginMutation, router]); + }, [code, kakaoLoginMutation, router, createToast]); return (
diff --git a/src/components/pages/login/LoginForm.tsx b/src/components/pages/login/LoginForm.tsx index 0d5d139..8fdd64a 100644 --- a/src/components/pages/login/LoginForm.tsx +++ b/src/components/pages/login/LoginForm.tsx @@ -3,8 +3,12 @@ import Button from '@/src/components/primitives/Button'; import FormInput from '@/src/components/primitives/input/FormInput'; import AlertModal from '@/src/components/primitives/modal/AlertModal'; -import useLoginUser from '@/src/hooks/pages/auth/useLoginUser'; +import { loginRequestBody, loginUser } from '@/src/services/pages/login/api'; +import { queries } from '@/src/services/primitives/queries'; +import { TokenUserResponseType } from '@/src/types/userType'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -16,24 +20,39 @@ interface FormDataType { const EMAIL_REGEXP = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; export default function LoginForm() { + const router = useRouter(); + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + const redirectPath = searchParams.get('redirect_path'); const [alertOpen, setAlertOpen] = useState(false); - const loginMutation = useLoginUser(); + const loginMutation = useMutation({ + mutationFn: (data: loginRequestBody) => loginUser(data), + onSuccess: (data: TokenUserResponseType) => { + queryClient.setQueryData(queries.user(), data.user); // 리액트 쿼리 데이터 캐싱 + + // redirectPath 값이 있으면, 해당 페이지로 다시 이동 + // 보안 취약점을 강화하기 위해 redirectPath.startsWith('/')로 현재 도메인내의 경로인지 확인 + if (redirectPath && redirectPath.startsWith('/')) { + router.replace(redirectPath); + } else { + router.replace('/'); + } + }, + onError: (error) => { + const err = error as AxiosError; + if (err?.response?.status === 400 || err?.response?.status === 404) { + setAlertOpen(true); + } + }, + }); const { register, handleSubmit, formState: { errors, isSubmitting, isValid }, } = useForm({ mode: 'onChange' }); - const handleSubmitForm = async (formData: FormDataType) => { - loginMutation.mutate(formData, { - onError: (error) => { - const err = error as AxiosError; - if (err?.response?.status === 400 || err?.response?.status === 404) { - setAlertOpen(true); - } - }, - }); - }; + const handleSubmitForm = (formData: FormDataType) => + loginMutation.mutateAsync(formData); return ( <> @@ -78,7 +97,7 @@ export default function LoginForm() { className='mt-2 md:mt-[10px]' disabled={!isValid || isSubmitting} > - 로그인하기 + {isSubmitting ? '로그인중...' : '로그인하기'} (null); diff --git a/src/components/primitives/AuthProvider.tsx b/src/components/primitives/AuthProvider.tsx index 28b9e6a..7e8dd70 100644 --- a/src/components/primitives/AuthProvider.tsx +++ b/src/components/primitives/AuthProvider.tsx @@ -2,72 +2,22 @@ import LoadingSpinner from '@/src/components/primitives/LoadingSpinner'; import ToastContainer from '@/src/components/primitives/toast/ToastContainer'; import { queries } from '@/src/services/primitives/queries'; -import { useTokenStore } from '@/src/store/useTokenStore'; import { useQuery } from '@tanstack/react-query'; -import { usePathname, useRouter } from 'next/navigation'; -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode } from 'react'; interface Props { children: ReactNode; } export default function AuthProvicder({ children }: Props) { - const [isAuthChecked, setIsAuthCheck] = useState(null); - const pathname = usePathname(); - const router = useRouter(); - const accessToken = useTokenStore((state) => state.accessToken); - // 리액트쿼리는 액세스 토큰이 있고, 유효한 액세스 토큰을 넘겨주었을 때에만 페칭을 시작함. const { isLoading } = useQuery({ - ...queries.userOptions(accessToken), + ...queries.userOptions(), retry: false, - staleTime: Infinity, + staleTime: 0, + gcTime: Infinity, }); - useEffect(() => { - if (!!accessToken) { - setIsAuthCheck(true); - } else { - setIsAuthCheck(false); - } - }, [accessToken]); - - // 리다이렉트 분기 - useEffect(() => { - // 리액트 쿼리 페칭중이라면 return - if (isAuthChecked === null || isLoading) return; - - const AUTH_PATHS = [ - '/login', - '/signup', - '/login/social/kakao', - '/signup/social/kakao', - ]; - const PUBLIC_PATH_PATTERNS = [/^\/$/, /^\/detail\/\d+$/]; - - const isAuthPath = AUTH_PATHS.includes(pathname); - const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) => - regex.test(pathname) - ); - - // 로그인 상태일때 - if (isAuthChecked) { - // auth 페이지(로그인/회원가입) 라우트시 - if (isAuthPath) { - router.replace('/'); - } - } - - // 로그인 상태가 아닐 때 - if (!isAuthChecked) { - // 프로텍트 페이지 (/, /detail, /login, /signup 제외) 접속시 - if (!isPublicPath && !isAuthPath) { - // 로그인페이지로 리다이렉트, 이때 params에 ?&redirect_uri=현재페이지경로 를 추가해줘서 로그인 완료시 접속시도했던 페이지로 리다이렉트되도록 처리 - router.replace(`/login?redirect_path=${pathname}`); - } - } - }, [pathname, router, isLoading, isAuthChecked]); - // 리액트 쿼리 페칭중이라면 로딩 보여주기. if (isLoading) return ( diff --git a/src/components/primitives/global/Header/UserMenuDropdown.tsx b/src/components/primitives/global/Header/UserMenuDropdown.tsx index 4f585ee..92d2b5a 100644 --- a/src/components/primitives/global/Header/UserMenuDropdown.tsx +++ b/src/components/primitives/global/Header/UserMenuDropdown.tsx @@ -1,6 +1,6 @@ 'use client'; -import logout from '@/src/services/primitives/logout'; +import useLogout from '@/src/hooks/useLogout'; import { useRouter } from 'next/navigation'; export default function UserMenuDropdown({ @@ -9,6 +9,7 @@ export default function UserMenuDropdown({ setVisible: (state: boolean) => void; }) { const router = useRouter(); + const logout = useLogout(); const handleMypageClick = () => { setVisible(false); diff --git a/src/hooks/pages/auth/useKakaoLoginUser.ts b/src/hooks/pages/auth/useKakaoLoginUser.ts deleted file mode 100644 index 61e389e..0000000 --- a/src/hooks/pages/auth/useKakaoLoginUser.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - KakaoLoginRequestBody, - kakaoLoginUser, -} from '@/src/services/pages/login/api'; -import { queries } from '@/src/services/primitives/queries'; -import { setTokenAction } from '@/src/services/primitives/tokenAction'; -import { useToastStore } from '@/src/store/useToastStore'; -import { useTokenStore } from '@/src/store/useTokenStore'; -import { TokenUserResponseType } from '@/src/types/userType'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useRouter, useSearchParams } from 'next/navigation'; - -export default function useKakaoLoginUser() { - const router = useRouter(); - const queryClient = useQueryClient(); - const searchParams = useSearchParams(); - const redirectPath = searchParams.get('state'); - const setAccessToken = useTokenStore((state) => state.setAccessToken); - const createToast = useToastStore((state) => state.createToast); - - return useMutation({ - mutationFn: async (data: KakaoLoginRequestBody) => - await kakaoLoginUser(data), - onSuccess: async (data: TokenUserResponseType) => { - queryClient.setQueryData(queries.user(), data.user); // 리액트 쿼리 데이터 캐싱 - setAccessToken(data.accessToken); // 액세스 토큰 로컬스토리지 저장 - await setTokenAction(data.refreshToken); // 리프레시 토큰 쿠키 설정 - - // redirectPath 값이 있으면, 해당 페이지로 다시 이동 - // 보안 취약점을 강화하기 위해 redirectPath.startsWith('/')로 현재 도메인내의 경로인지 확인 - if (redirectPath && redirectPath.startsWith('/')) { - router.replace(redirectPath); - } else { - router.replace('/'); - } - }, - onError: () => { - createToast({ - message: '로그인에 실패하였습니다. 다시 시도 해주세요.', - type: 'failed', - }); - router.replace('/login'); - }, - }); -} diff --git a/src/hooks/pages/auth/useLoginUser.ts b/src/hooks/pages/auth/useLoginUser.ts deleted file mode 100644 index 83a7831..0000000 --- a/src/hooks/pages/auth/useLoginUser.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { loginRequestBody, loginUser } from '@/src/services/pages/login/api'; -import { queries } from '@/src/services/primitives/queries'; -import { setTokenAction } from '@/src/services/primitives/tokenAction'; -import { useTokenStore } from '@/src/store/useTokenStore'; -import { TokenUserResponseType } from '@/src/types/userType'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; -import { useRouter, useSearchParams } from 'next/navigation'; - -export default function useLoginUser() { - const router = useRouter(); - const queryClient = useQueryClient(); - const searchParams = useSearchParams(); - const redirectPath = searchParams.get('redirect_path'); - const setAccessToken = useTokenStore((state) => state.setAccessToken); - - return useMutation({ - mutationFn: (data: loginRequestBody) => loginUser(data), - onSuccess: async (data: TokenUserResponseType) => { - queryClient.setQueryData(queries.user(), data.user); // 리액트 쿼리 데이터 캐싱 - setAccessToken(data.accessToken); // 액세스 토큰 로컬스토리지 저장 - await setTokenAction(data.refreshToken); // 리프레시 토큰 쿠키 설정 - - // redirectPath 값이 있으면, 해당 페이지로 다시 이동 - // 보안 취약점을 강화하기 위해 redirectPath.startsWith('/')로 현재 도메인내의 경로인지 확인 - if (redirectPath && redirectPath.startsWith('/')) { - router.replace(redirectPath); - } else { - router.replace('/'); - } - }, - onError: (error) => { - const err = error as AxiosError; - console.log(err); - }, - }); -} diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts new file mode 100644 index 0000000..0dd1e66 --- /dev/null +++ b/src/hooks/useLogout.ts @@ -0,0 +1,39 @@ +import { queries } from '@/src/services/primitives/queries'; +import { getQueryClient } from '@/src/utils/getQueryClient'; +import axios from 'axios'; +import { usePathname, useRouter } from 'next/navigation'; + +const PUBLIC_PATH_PATTERNS = [ + /^\/$/, + /^\/detail\/\d+$/, + /^\/login$/, + /^\/login\/social\/kakao$/, + /^\/signup$/, + /^\/signup\/social\/kakao$/, +]; + +export default function useLogout() { + const pathname = usePathname(); + const router = useRouter(); + + const logout = () => { + // 1. 토큰 삭제 + axios.delete('/api/auth/logout'); + + // 2. 리액트 쿼리 유저 정보 초기화 + const queryClient = getQueryClient(); + queryClient.setQueryData(queries.user(), null); + + const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) => + regex.test(pathname) + ); + + if (!isPublicPath) { + // 3. 인증 필요 O - 로그인 페이지 이동 + router.replace('/login'); + } + // 3. 인증 필요 X - 현재 페이지 유지 + }; + + return logout; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b0567f2 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const AUTH_PATHS = [ + '/login', + '/signup', + '/login/social/kakao', + '/signup/social/kakao', +]; +const PUBLIC_PATH_PATTERNS = [/^\/$/, /^\/detail\/\d+$/]; + +export function middleware(req: NextRequest) { + const { pathname, origin } = req.nextUrl; + const accessToken = req.cookies.get('accessToken')?.value; + + const isAuthPath = AUTH_PATHS.includes(pathname); + const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) => + regex.test(pathname) + ); + + // 라우트 핸들러 요청은 리다이렉트 처리에서 제외 + if (pathname.startsWith('/api')) { + return NextResponse.next(); + } + + // 로그인시 + if (accessToken) { + // auth 페이지(로그인/회원가입) 라우트시 + if (isAuthPath) { + return NextResponse.redirect(`${origin}/`); + } + } + // 로그아웃시 + if (!accessToken) { + // 프로텍트 페이지 (/, /detail, /login, /signup 제외) 접속시 + if (!isPublicPath && !isAuthPath) { + // 로그인페이지로 리다이렉트, 이때 params에 ?&redirect_uri=현재페이지경로를 추가해줘서 로그인 완료시 접속시도했던 페이지로 리다이렉트되도록 처리 + return NextResponse.redirect(`${origin}/login?redirect_path=${pathname}`); + } + } + return NextResponse.next(); +} + +export const config = { + matcher: [ + '/((?!_next/static|favicon.ico|.*\\.png|.*\\.jpg|.*\\.jpeg|.*\\.svg|_next/image).*)', + ], +}; diff --git a/src/services/pages/login/api.ts b/src/services/pages/login/api.ts index f07d2a4..9accf9c 100644 --- a/src/services/pages/login/api.ts +++ b/src/services/pages/login/api.ts @@ -1,5 +1,5 @@ -import { apiClient } from '@/src/services/primitives/apiClient'; import { TokenUserResponseType } from '@/src/types/userType'; +import axios from 'axios'; export interface loginRequestBody { email: string; @@ -15,7 +15,7 @@ export interface KakaoLoginRequestBody { export async function loginUser( loginBody: loginRequestBody ): Promise { - const res = await apiClient.post('/auth/login', loginBody); + const res = await axios.post('/api/auth/login', loginBody); return res.data; } @@ -23,6 +23,6 @@ export async function loginUser( export async function kakaoLoginUser( loginBody: KakaoLoginRequestBody ): Promise { - const res = await apiClient.post('/oauth/sign-in/kakao', loginBody); + const res = await axios.post('/api/auth/kakao', loginBody); return res.data; } diff --git a/src/services/primitives/apiClient.ts b/src/services/primitives/apiClient.ts index 568e363..077f778 100644 --- a/src/services/primitives/apiClient.ts +++ b/src/services/primitives/apiClient.ts @@ -1,55 +1,10 @@ -import logout from '@/src/services/primitives/logout'; -import { useTokenStore } from '@/src/store/useTokenStore'; import axios from 'axios'; -export const apiClient = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_URL, -}); - -// request - 토큰 주입 -apiClient.interceptors.request.use((config) => { - const accessToken = useTokenStore.getState().accessToken; +const isProduction = process.env.NODE_ENV === 'production'; +const baseURL = isProduction + ? 'https://global-nomad-frontend.vercel.app' + : 'http://localhost:3000'; - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; - } - - return config; +export const apiClient = axios.create({ + baseURL: `${baseURL}/api/proxy`, }); - -// response - 액세스 토큰 만료시 재발급 -apiClient.interceptors.response.use( - (response) => { - return response; - }, - async (error) => { - const originalRequest = error.config; - const status = error?.status || error?.response?.status; - - // 401 에러 - 액세스 토큰 만료시 - if (status === 401 && !originalRequest._retry) { - // 토큰 갱신 요청 중복 방지 플래그 - originalRequest._retry = true; - - try { - // 1. 토큰 갱신 라우터 핸들러 호출 - const { data } = await axios.post('/api/refreshToken'); - const { accessToken } = data; - - // 2. 새로운 액세스 토큰 zustand로 로컬스토리지에 저장 - useTokenStore.getState().setAccessToken(accessToken); - - // 3. 다시 api 요청 - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - return apiClient(originalRequest); - } catch (error) { - // 리프레시 토큰 만료시, 로그아웃 처리 - logout(); - - return Promise.reject(error); - } - } - - return Promise.reject(error); - } -); diff --git a/src/services/primitives/logout.ts b/src/services/primitives/logout.ts deleted file mode 100644 index db10aab..0000000 --- a/src/services/primitives/logout.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { queries } from '@/src/services/primitives/queries'; -import { deleteTokenAction } from '@/src/services/primitives/tokenAction'; -import { useTokenStore } from '@/src/store/useTokenStore'; -import { getQueryClient } from '@/src/utils/getQueryClient'; - -const PUBLIC_PATH_PATTERNS = [ - /^\/$/, - /^\/detail\/\d+$/, - /^\/login$/, - /^\/login\/social\/kakao$/, - /^\/signup$/, - /^\/signup\/social\/kakao$/, -]; - -export default function logout() { - const currentPath = window.location.pathname; - - // 1. accessToken을 zustand로 로컬스토리지에서 삭제 - useTokenStore.getState().deleteAccessToken(); - - // 2. refreshToken 쿠키에서 삭제 - deleteTokenAction(); - - // 3. 리액트 쿼리 유저 정보 초기화 - const queryClient = getQueryClient(); - queryClient.removeQueries({ queryKey: queries.user() }); - - const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) => - regex.test(currentPath) - ); - - if (!isPublicPath) { - // 4. 인증 필요 O - 로그인 페이지 이동 - window.location.href = '/login'; - } - // 4. 인증 필요 X - 현재 페이지 유지 -} diff --git a/src/services/primitives/queries.ts b/src/services/primitives/queries.ts index 50d9e3f..f183559 100644 --- a/src/services/primitives/queries.ts +++ b/src/services/primitives/queries.ts @@ -23,11 +23,10 @@ const queryClient = getQueryClient(); export const queries = { user: () => ['user'], - userOptions: (accessToken: string | null | undefined) => + userOptions: () => queryOptions({ queryKey: [...queries.user()], queryFn: () => getUserInfo(), - enabled: !!accessToken, }), myReservationList: () => ['myReservationList'], myReservationLists: (status: ReservationStatus | null) => [ diff --git a/src/store/useTokenStore.ts b/src/store/useTokenStore.ts deleted file mode 100644 index 019957d..0000000 --- a/src/store/useTokenStore.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -interface TokenState { - accessToken: null | string; - setAccessToken: (token: string | null) => void; - deleteAccessToken: () => void; -} - -export const useTokenStore = create()( - persist( - (set) => { - return { - accessToken: null, - setAccessToken: (token) => set({ accessToken: token }), - deleteAccessToken: () => set({ accessToken: null }), - }; - }, - { - name: 'accessToken', - storage: createJSONStorage(() => localStorage), - } - ) -);