Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/app/api/_lib/proxyRequest.ts
Original file line number Diff line number Diff line change
@@ -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
);
};
32 changes: 32 additions & 0 deletions src/app/api/_lib/proxyResponse.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
};
123 changes: 40 additions & 83 deletions src/app/api/proxy/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<NextResponse> => {
const handleProxyRequest = async (
req: NextRequest,
params: { path: string[] }
): Promise<NextResponse> => {
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) {
Expand All @@ -83,54 +30,64 @@ const handleProxyRequest = async (req: NextRequest): Promise<NextResponse> => {
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);
}

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);
}
47 changes: 0 additions & 47 deletions src/app/api/refreshToken/route.ts

This file was deleted.

18 changes: 11 additions & 7 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=현재페이지경로를 추가해줘서 로그인 완료시 접속시도했던 페이지로 리다이렉트되도록 처리
Expand Down
28 changes: 28 additions & 0 deletions src/services/primitives/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
Loading