export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import acceptLanguage from 'accept-language'; import { getToken } from 'next-auth/jwt'; import { fallbackLng, languages, cookieName } from '@/i18n/settings'; acceptLanguage.languages(languages); // 로그인이 필요 없는 공개 경로 const publicPaths = [ '/evcp', '/procurement', '/sales', '/engineering', '/partners', '/privacy', '/projects', '/partners/repository', '/partners/signup', '/partners/tech-signup', '/api/auth', '/spreadTest', '/auth/reset-password', ]; const landingPages = [ '/', ]; // 경로가 공개 경로인지 확인하는 함수 function isPublicPath(path: string, lng: string) { // 1. 언어별 루트 경로 체크 (예: /ko, /en) if (path === `/${lng}` || path === `/${lng}/`) { return true; } // 2. 랜딩 페이지들 체크 if (landingPages.some(landingPage => { if (landingPage === '/') { return path === `/${lng}` || path === `/${lng}/`; } return path === `/${lng}${landingPage}` || path.startsWith(`/${lng}${landingPage}/`); })) { return true; } // 3. publicPaths 배열의 경로들과 매칭 if (publicPaths.some(publicPath => { return path === `/${lng}${publicPath}`; })) { return true; } // 4. auth API는 별도 처리 if (path.includes('/api/auth')) { return true; } return false; } // 도메인별 기본 대시보드 경로 정의 function getDashboardPath(domain: string, lng: string): string { switch (domain) { case 'pending': return `/${lng}/pending`; case 'evcp': return `/${lng}/evcp/report`; case 'procurement': return `/${lng}/procurement/dashboard`; case 'sales': return `/${lng}/sales/dashboard`; case 'engineering': return `/${lng}/engineering/dashboard`; case 'partners': return `/${lng}/partners/dashboard`; default: return `/${lng}/pending`; // 기본값 } } // 도메인-URL 일치 여부 확인 및 올바른 리다이렉트 경로 반환 function getDomainRedirectPath(path: string, domain: string, lng: string) { // 도메인이 없는 경우 리다이렉트 없음 if (!domain) return null; // 각 도메인 경로 패턴 확인 (trailing slash 문제 해결) const domainPatterns = { pending: `/pending`, evcp: `/evcp`, procurement: `/evcp`, sales: `/evcp`, engineering: `/evcp`, partners: `/partners` }; // 현재 경로가 어떤 도메인 패턴에 속하는지 확인 let currentPathDomain: string | null = null; for (const [domainName, pattern] of Object.entries(domainPatterns)) { // 정확한 매칭을 위해 언어 코드를 포함한 전체 패턴으로 확인 const fullPattern = `/${lng}${pattern}`; if (path === fullPattern || path.startsWith(`${fullPattern}/`)) { currentPathDomain = domainName; break; } } // 도메인과 경로가 일치하지 않는 경우 if (currentPathDomain && currentPathDomain !== domain) { // pending 사용자는 오직 pending 경로만 접근 가능 if (domain === 'pending') { return getDashboardPath('pending', lng); } // 다른 도메인 사용자가 pending에 접근하려는 경우 if (currentPathDomain === 'pending') { return getDashboardPath(domain, lng); } // 일반적인 도메인 불일치 처리 const targetPattern = domainPatterns[domain as keyof typeof domainPatterns]; if (targetPattern && currentPathDomain) { const sourcePattern = domainPatterns[currentPathDomain as keyof typeof domainPatterns]; return path.replace(`/${lng}${sourcePattern}`, `/${lng}${targetPattern}`); } } // 일치하거나 처리할 수 없는 경우 null 반환 return null; } // 세션 타임아웃 체크 함수 function checkSessionTimeout(token: any): { isExpired: boolean; isExpiringSoon: boolean } { if (!token?.sessionExpiredAt) { return { isExpired: false, isExpiringSoon: false }; } const now = Date.now(); const expiresAt = token.sessionExpiredAt; const timeUntilExpiry = expiresAt - now; const warningThreshold = 10 * 60 * 1000; // 10분 return { isExpired: timeUntilExpiry <= 0, isExpiringSoon: timeUntilExpiry <= warningThreshold && timeUntilExpiry > 0 }; } // 실제 로그인 페이지인지 확인하는 함수 (pending 페이지 제외) function isActualLoginPage(pathname: string, detectedLng: string): boolean { const actualLoginPages = [ `/${detectedLng}/evcp`, `/${detectedLng}/procurement`, `/${detectedLng}/sales`, `/${detectedLng}/engineering`, `/${detectedLng}/partners`, // pending은 로그인 페이지가 아니라 실제 대시보드이므로 제외 ]; return actualLoginPages.includes(pathname); } // 로그인 페이지 URL 생성 함수 (세션 만료 정보 포함) function createLoginUrl(pathname: string, detectedLng: string, origin: string, request: NextRequest, reason?: string) { let loginPath; // 경로에 따라 적절한 로그인 페이지 선택 if (pathname.includes('/partners') || pathname.startsWith(`/${detectedLng}/vendor`)) { loginPath = `/${detectedLng}/partners`; } else { // evcp, procurement, sales, engineering, pending 모두 evcp 로그인 사용 // pending 페이지는 로그인 페이지가 아니라 실제 대시보드이므로 evcp 로그인으로 보냄 loginPath = `/${detectedLng}/evcp`; } const redirectUrl = new URL(loginPath, origin); // 로그인 후 원래 페이지로 리다이렉트하기 위해 callbackUrl 추가 redirectUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search); // 세션 만료 관련 정보 추가 if (reason) { redirectUrl.searchParams.set('reason', reason); if (reason === 'expired') { redirectUrl.searchParams.set('message', '세션이 만료되었습니다. 다시 로그인해주세요.'); } } return redirectUrl; } // 세션 쿠키 삭제 함수 function clearSessionCookies(response: NextResponse) { response.cookies.delete('next-auth.session-token'); response.cookies.delete('__Secure-next-auth.session-token'); } export async function middleware(request: NextRequest) { /** * 1. 쿠키에서 언어 가져오기 */ let lng = request.cookies.get(cookieName)?.value; /** * 2. 쿠키가 없다면 브라우저의 Accept-Language 헤더에서 언어를 추론 */ if (!lng) { const headerLang = request.headers.get('accept-language'); lng = acceptLanguage.get(headerLang) || fallbackLng; } const { pathname, searchParams, origin } = request.nextUrl; /** * 3. "/" 경로로 들어온 경우 -> "/{lng}"로 리다이렉트 */ if (pathname === '/') { const redirectUrl = new URL(`/${lng}`, origin); redirectUrl.search = searchParams.toString(); return NextResponse.redirect(redirectUrl); } /** * 4. 현재 pathname이 언어 경로를 포함하고 있는지 확인 */ const hasValidLngInPath = languages.some( (language) => pathname === `/${language}` || pathname.startsWith(`/${language}/`), ); /** * 5. 언어 경로가 누락된 경우 -> "/{lng}" + 기존 pathname 으로 리다이렉트 */ if (!hasValidLngInPath) { const redirectUrl = new URL(`/${lng}${pathname}`, origin); redirectUrl.search = searchParams.toString(); return NextResponse.redirect(redirectUrl); } // 언어 코드 추출 const pathnameParts = pathname.split('/'); const detectedLng = pathnameParts[1]; // 예: /ko/partners -> ko // 토큰 가져오기 (인증 상태 확인 및 도메인 검증에 사용) const token = await getToken({ req: request }); /** * 6. 세션 타임아웃 체크 (인증된 사용자에 대해서만) */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpired, isExpiringSoon } = checkSessionTimeout(token); if (isExpired) { console.log(`[Middleware.ts] Session expired in middleware for user ${token.email}`); const loginUrl = createLoginUrl(pathname, detectedLng, origin, request, 'expired'); const response = NextResponse.redirect(loginUrl); clearSessionCookies(response); return response; } } /** * 7. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션 */ if (token && token.domain && !isPublicPath(pathname, detectedLng)) { // 사용자의 domain과 URL 경로가 일치하는지 확인 const redirectPath = getDomainRedirectPath(pathname, token.domain as string, detectedLng); // 도메인과 URL이 일치하지 않으면 리다이렉트 if (redirectPath) { console.log("[Middleware.ts] redirectPath: ", redirectPath) const redirectUrl = new URL(redirectPath, origin); redirectUrl.search = searchParams.toString(); return NextResponse.redirect(redirectUrl); } } /** * 8. 이미 로그인한 사용자가 실제 로그인 페이지에 접근할 경우 대시보드로 리다이렉트 * (pending 페이지는 실제 대시보드이므로 제외) */ if (token) { // 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트 const { isExpired } = checkSessionTimeout(token); if (!isExpired && isActualLoginPage(pathname, detectedLng)) { // 사용자의 도메인에 맞는 대시보드로 리다이렉트 const dashboardPath = getDashboardPath(token.domain as string, detectedLng); const redirectUrl = new URL(dashboardPath, origin); redirectUrl.search = searchParams.toString(); return NextResponse.redirect(redirectUrl); } } /** * 9. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트 */ if (!isPublicPath(pathname, detectedLng)) { if (!token) { const loginUrl = createLoginUrl(pathname, detectedLng, origin, request); return NextResponse.redirect(loginUrl); } // 토큰은 있지만 세션이 만료된 경우 (이미 위에서 처리되었지만 추가 안전장치) const { isExpired } = checkSessionTimeout(token); if (isExpired) { const loginUrl = createLoginUrl(pathname, detectedLng, origin, request, 'expired'); const response = NextResponse.redirect(loginUrl); clearSessionCookies(response); return response; } } /** * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ /** * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ const requestHeaders = new Headers(request.headers); requestHeaders.set('x-pathname', pathname); const response = NextResponse.next({ request: { headers: requestHeaders, }, }); // 만료된 세션 쿠키 정리 (공개 경로 포함) if (token) { const { isExpired } = checkSessionTimeout(token); if (isExpired) { clearSessionCookies(response); } } /** * 11. 세션 만료 경고를 위한 헤더 추가 */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpiringSoon } = checkSessionTimeout(token); if (isExpiringSoon && token.sessionExpiredAt) { response.headers.set('X-Session-Warning', 'true'); response.headers.set('X-Session-Expires-At', token.sessionExpiredAt.toString()); response.headers.set('X-Session-Time-Left', (token.sessionExpiredAt - Date.now()).toString()); } } /** * 12. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트 */ const currentCookie = request.cookies.get(cookieName)?.value; if (detectedLng && detectedLng !== currentCookie) { response.cookies.set(cookieName, detectedLng, { path: '/' }); } return response; } /** * 13. 매칭할 경로 설정 */ export const config = { matcher: [ '/((?!_next|.*\\..*|api|viewer).*)', // API 경로 전체 제외 ], };