diff --git a/src/app/api/_lib/proxyRequest.ts b/src/app/api/_lib/proxyRequest.ts new file mode 100644 index 0000000..59052fa --- /dev/null +++ b/src/app/api/_lib/proxyRequest.ts @@ -0,0 +1,37 @@ +import { cookies } from 'next/headers'; +import { NextRequest } from 'next/server'; + +interface CustomRequestInit extends RequestInit { + duplex?: 'half' | 'full' | string; +} + +export const fetchWithAccessToken = async ( + req: NextRequest, + params: { path: string[] } +) => { + const cookieStore = await cookies(); + const headers = new Headers(req.headers); + const accessToken = cookieStore.get('accessToken')?.value; + + const pathname = `/${params.path.join('/')}`; + const search = req.nextUrl.search; + const targetPath = pathname + search; + + // 액세스 토큰 주입 + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + + const fetchOptions: CustomRequestInit = { + method: req.method, + headers, + body: req.body ? req.clone().body : null, + duplex: 'half', + }; + + // request 요청 + return await fetch( + `${process.env.NEXT_PUBLIC_API_URL}${targetPath}`, + fetchOptions + ); +}; diff --git a/src/app/api/_lib/proxyResponse.ts b/src/app/api/_lib/proxyResponse.ts new file mode 100644 index 0000000..d45032e --- /dev/null +++ b/src/app/api/_lib/proxyResponse.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; + +export 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 }); + } + + if (contentType.startsWith('text/')) { + // text 처리 + const resText = await res.text(); + return new NextResponse(resText, { + status: res.status, + headers: { 'Content-Type': contentType }, + }); + } + + // 바이너리/파일 처리 + const buffer = await res.arrayBuffer(); + return new NextResponse(buffer, { + status: res.status, + headers: { 'Content-Type': contentType }, + }); +}; diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index 39e9f68..54a15e9 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -1,3 +1,5 @@ +import { fetchWithAccessToken } from '@/src/app/api/_lib/proxyRequest'; +import { handleApiResponse } from '@/src/app/api/_lib/proxyResponse'; import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils'; import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; @@ -7,69 +9,14 @@ interface TokenReissueResponse { 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 handleProxyRequest = async ( + req: NextRequest, + params: { path: string[] } +): Promise => { const cookieStore = await cookies(); const refreshToken = cookieStore.get('refreshToken')?.value; - const res = await fetchWithAccessToken(req); + const res = await fetchWithAccessToken(req, params); // 액세스 토큰 만료 + 리프레시 토큰이 있다면, if (res.status === 401 && refreshToken) { @@ -83,38 +30,32 @@ const handleProxyRequest = async (req: NextRequest): Promise => { headers: refreshHeaders, } ); - const data: TokenReissueResponse = await refreshTokenRes.json(); // 갱신 실패시 if (!refreshTokenRes.ok) { - // 로그인 페이지로 리다이렉트 - const response = new NextResponse(null); + const response = NextResponse.json( + { error: 'TOKEN_REFRESH_FAILED' }, + { status: 401 } + ); + + // 커스텀 헤더 추가 + response.headers.set('X-Auth-Error', 'REFRESH_TOKEN_EXPIRED'); // 쿠키 삭제 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 data: TokenReissueResponse = await refreshTokenRes.json(); const { accessToken: newAccessToken, refreshToken: newRefreshToken } = data; // 새롭게 발급 받은 토큰 재설정 await setTokenCookies(newAccessToken, newRefreshToken); // 다시 api 요청 - const retryRes = await fetchWithAccessToken(req); + const retryRes = await fetchWithAccessToken(req, params); return handleApiResponse(retryRes); } @@ -122,15 +63,31 @@ const handleProxyRequest = async (req: NextRequest): Promise => { return handleApiResponse(res); }; -export async function GET(req: NextRequest) { - return handleProxyRequest(req); +export async function GET( + req: NextRequest, + ctx: { params: { path: string[] } } +) { + const params = await ctx.params; + return handleProxyRequest(req, params); } -export async function POST(req: NextRequest) { - return handleProxyRequest(req); +export async function POST( + req: NextRequest, + ctx: { params: { path: string[] } } +) { + const params = await ctx.params; + return handleProxyRequest(req, params); } -export async function PATCH(req: NextRequest) { - return handleProxyRequest(req); +export async function PATCH( + req: NextRequest, + ctx: { params: { path: string[] } } +) { + const params = await ctx.params; + return handleProxyRequest(req, params); } -export async function DELETE(req: NextRequest) { - return handleProxyRequest(req); +export async function DELETE( + req: NextRequest, + ctx: { params: { path: string[] } } +) { + const params = await ctx.params; + return handleProxyRequest(req, params); } diff --git a/src/app/api/refreshToken/route.ts b/src/app/api/refreshToken/route.ts deleted file mode 100644 index 02cf7f5..0000000 --- a/src/app/api/refreshToken/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { setTokenAction } from '@/src/services/primitives/tokenAction'; -import axios, { AxiosError } from 'axios'; -import { cookies } from 'next/headers'; -import { NextResponse } from 'next/server'; - -interface TokenReissueResponse { - accessToken: string; - refreshToken: string; -} - -export async function POST() { - try { - // 1. refreshToken 토큰 가져오기 - const cookieStore = await cookies(); - const refreshToken = cookieStore.get('refreshToken')?.value; - - // 2. 리프레시 토큰 없으면, 에러 반환 - if (!refreshToken) { - return NextResponse.json( - { error: '리프레시 토큰이 없습니다.' }, - { status: 401 } - ); - } - - // 3. 리프레시 토큰으로 토큰 재발급 요청 - const { data } = await axios.post( - `${process.env.NEXT_PUBLIC_API_URL}/auth/tokens`, - { refreshToken }, - { - headers: { - Authorization: `Bearer ${refreshToken}`, - }, - } - ); - - // 4. 서버액션으로 refreshToken 쿠키 다시 저장 - await setTokenAction(data.refreshToken); - - // 5. 토큰 반환 - return NextResponse.json({ ...data }); - } catch (error) { - const err = error as AxiosError; - const status = err.response?.status || 500; - - return NextResponse.json({ error: '토큰 갱신 실패' }, { status: status }); - } -} diff --git a/src/middleware.ts b/src/middleware.ts index b0567f2..30527ca 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,27 +10,31 @@ const PUBLIC_PATH_PATTERNS = [/^\/$/, /^\/detail\/\d+$/]; export function middleware(req: NextRequest) { const { pathname, origin } = req.nextUrl; - const accessToken = req.cookies.get('accessToken')?.value; + + // 라우트 핸들러 요청은 리다이렉트 처리에서 제외 + if (pathname.startsWith('/api')) { + return NextResponse.next(); + } const isAuthPath = AUTH_PATHS.includes(pathname); const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) => regex.test(pathname) ); - // 라우트 핸들러 요청은 리다이렉트 처리에서 제외 - if (pathname.startsWith('/api')) { - return NextResponse.next(); - } + const accessToken = req.cookies.get('accessToken')?.value; + const refreshToken = req.cookies.get('refreshToken')?.value; + const isUnauthenticated = + accessToken === undefined && refreshToken === undefined; // 로그인시 - if (accessToken) { + if (!isUnauthenticated) { // auth 페이지(로그인/회원가입) 라우트시 if (isAuthPath) { return NextResponse.redirect(`${origin}/`); } } // 로그아웃시 - if (!accessToken) { + if (isUnauthenticated) { // 프로텍트 페이지 (/, /detail, /login, /signup 제외) 접속시 if (!isPublicPath && !isAuthPath) { // 로그인페이지로 리다이렉트, 이때 params에 ?&redirect_uri=현재페이지경로를 추가해줘서 로그인 완료시 접속시도했던 페이지로 리다이렉트되도록 처리 diff --git a/src/services/primitives/apiClient.ts b/src/services/primitives/apiClient.ts index 077f778..c193368 100644 --- a/src/services/primitives/apiClient.ts +++ b/src/services/primitives/apiClient.ts @@ -8,3 +8,31 @@ const baseURL = isProduction export const apiClient = axios.create({ baseURL: `${baseURL}/api/proxy`, }); + +const REDIRECT_STATE = { + isRedirecting: false, +}; + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + const res = error.response; + + // refresh token 만료로 인한 인증 실패 시 + if ( + res?.status === 401 && + res?.headers?.['x-auth-error'] === 'REFRESH_TOKEN_EXPIRED' + ) { + // SSR 에러 방지용 플래그 + if (typeof window !== 'undefined') { + // 중복 리다이렉트 방지용 플래그 + if (!REDIRECT_STATE.isRedirecting) { + REDIRECT_STATE.isRedirecting = true; + const currentPath = window.location.pathname + window.location.search; + window.location.href = `/login?redirect_path=${currentPath}`; + } + } + } + return Promise.reject(error); + } +);