/** middleware.ts */ 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'; // UAParser 임포트 수정 import { UAParser } from 'ua-parser-js'; import { fallbackLng, languages, cookieName } from '@/i18n/settings'; import { SessionRepository } from './lib/users/session/repository'; acceptLanguage.languages(languages); // 로그인이 필요 없는 공개 경로 const publicPaths = [ '/evcp', '/procurement', '/sales', '/engineering', '/partners', '/privacy', '/partners/repository', '/partners/signup', '/api/auth', '/spreadTest', '/auth/reset-password', ]; // 페이지 추적에서 제외할 경로들 const trackingExcludePaths = [ '/api', '/_next', '/favicon.ico', '/robots.txt', '/sitemap.xml', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.css', '.js', '.woff', '.woff2', '.ttf', '.eot' ]; // 경로가 공개 경로인지 확인하는 함수 function isPublicPath(path: string, lng: string) { // 1. 정확한 로그인 페이지 매칭 (/ko/evcp, /en/partners 등) if (publicPaths.some(publicPath => path === `/${lng}${publicPath}`)) { return true; } // 2. auth API는 별도 처리 if (path.includes('/api/auth')) { return true; } return false; } // 페이지 추적 제외 경로인지 확인 function shouldExcludeFromTracking(pathname: string): boolean { return trackingExcludePaths.some(excludePath => pathname.startsWith(excludePath) || pathname.includes(excludePath) ); } // 도메인별 기본 대시보드 경로 정의 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`; // 기본값 } } // 도메인별 로그인 페이지 경로 정의 function getLoginPath(domain: string, lng: string): string { switch (domain) { case 'partners': return `/${lng}/partners`; case 'pending': return `/${lng}/pending`; case 'evcp': case 'procurement': case 'sales': case 'engineering': default: return `/${lng}/evcp`; } } // 도메인-URL 일치 여부 확인 및 올바른 리다이렉트 경로 반환 function getDomainRedirectPath(path: string, domain: string, lng: string) { // 도메인이 없는 경우 리다이렉트 없음 if (!domain) return null; // 각 도메인 경로 패턴 확인 const domainPatterns = { pending: `/pending/`, evcp: `/evcp/`, procurement: `/procurement/`, sales: `/sales/`, engineering: `/engineering/`, partners: `/partners/` }; // 현재 경로가 어떤 도메인 패턴에 속하는지 확인 let currentPathDomain = null; for (const [domainName, pattern] of Object.entries(domainPatterns)) { if (path.includes(pattern)) { 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(sourcePattern, 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 }; } // 로그인 페이지 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 if (pathname.includes('/pending')) { loginPath = `/${detectedLng}/pending`; } else { // evcp, procurement, sales, engineering은 모두 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; } // 클라이언트 IP 추출 함수 (수정됨) function getClientIP(request: NextRequest): string { const forwarded = request.headers.get('x-forwarded-for'); const realIP = request.headers.get('x-real-ip'); const cfConnectingIP = request.headers.get('cf-connecting-ip'); // Cloudflare if (cfConnectingIP) { return cfConnectingIP; } if (forwarded) { return forwarded.split(',')[0].trim(); } if (realIP) { return realIP; } // NextRequest에는 ip 프로퍼티가 없으므로 기본값 반환 return '127.0.0.1'; } // 디바이스 타입 판단 함수 function getDeviceType(deviceType?: string): string { if (!deviceType) return 'desktop'; if (deviceType === 'mobile') return 'mobile'; if (deviceType === 'tablet') return 'tablet'; return 'desktop'; } // 페이지 제목 추출 함수 function extractPageTitle(pathname: string, lng: string): string { // 언어 코드 제거 const cleanPath = pathname.replace(`/${lng}`, '') || '/'; // 라우트 기반 페이지 제목 매핑 const titleMap: Record = { '/': 'Home', '/evcp': 'EVCP Login', '/evcp/report': 'EVCP Report', '/evcp/dashboard': 'EVCP Dashboard', '/procurement': 'Procurement Login', '/procurement/dashboard': 'Procurement Dashboard', '/sales': 'Sales Login', '/sales/dashboard': 'Sales Dashboard', '/engineering': 'Engineering Login', '/engineering/dashboard': 'Engineering Dashboard', '/partners': 'Partners Login', '/partners/dashboard': 'Partners Dashboard', '/pending': 'Pending', '/profile': 'Profile', '/settings': 'Settings', }; // 정확한 매칭 우선 if (titleMap[cleanPath]) { return titleMap[cleanPath]; } // 부분 매칭으로 fallback for (const [route, title] of Object.entries(titleMap)) { if (cleanPath.startsWith(route) && route !== '/') { return title; } } return cleanPath || 'Unknown Page'; } // 페이지 방문 추적 함수 (비동기, 논블로킹) - UAParser 수정됨 async function trackPageVisit(request: NextRequest, token: any, detectedLng: string) { // 백그라운드에서 실행하여 메인 요청을 블로킹하지 않음 setImmediate(async () => { try { const { pathname, searchParams } = request.nextUrl; // 추적 제외 경로 체크 if (shouldExcludeFromTracking(pathname)) { return; } const userAgent = request.headers.get('user-agent') || ''; // UAParser 사용 방법 수정 const parser = new UAParser(userAgent); const result = parser.getResult(); // 활성 세션 조회 및 업데이트 let sessionId = null; if (token?.id && token?.dbSessionId) { sessionId = token.dbSessionId; // 세션 활동 시간 업데이트 (await 없이 비동기 실행) SessionRepository.updateSessionActivity(sessionId).catch(error => { console.error('Failed to update session activity:', error); }); } // 페이지 방문 기록 await SessionRepository.recordPageVisit({ userId: token?.id || undefined, sessionId, route: pathname, pageTitle: extractPageTitle(pathname, detectedLng), referrer: request.headers.get('referer') || undefined, ipAddress: getClientIP(request), userAgent, queryParams: searchParams.toString() || undefined, deviceType: getDeviceType(result.device.type), browserName: result.browser.name || undefined, osName: result.os.name || undefined, }); } catch (error) { // 추적 실패는 로그만 남기고 메인 플로우에 영향 주지 않음 console.error('Failed to track page visit:', error); } }); } 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. 페이지 방문 추적 (비동기, 논블로킹) * - 리다이렉트가 발생하기 전에 실행 * - API나 정적 파일은 제외 */ if (!shouldExcludeFromTracking(pathname)) { trackPageVisit(request, token, detectedLng); // await 하지 않음 (논블로킹) } /** * 7. 세션 타임아웃 체크 (인증된 사용자에 대해서만) */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpired, isExpiringSoon } = checkSessionTimeout(token); if (isExpired) { console.log(`Session expired in middleware for user ${token.email}`); const loginUrl = createLoginUrl(pathname, detectedLng, origin, request, 'expired'); return NextResponse.redirect(loginUrl); } } /** * 8. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션 */ if (token && token.domain && !isPublicPath(pathname, detectedLng)) { // 사용자의 domain과 URL 경로가 일치하는지 확인 const redirectPath = getDomainRedirectPath(pathname, token.domain as string, detectedLng); console.log(redirectPath, "redirectPath") // 도메인과 URL이 일치하지 않으면 리다이렉트 if (redirectPath) { const redirectUrl = new URL(redirectPath, origin); redirectUrl.search = searchParams.toString(); return NextResponse.redirect(redirectUrl); } } /** * 9. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트 */ if (token) { // 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트 const { isExpired } = checkSessionTimeout(token); if (!isExpired) { // 모든 도메인의 로그인 페이지 확인 const loginPages = [ `/${detectedLng}/evcp`, `/${detectedLng}/procurement`, `/${detectedLng}/sales`, `/${detectedLng}/engineering`, `/${detectedLng}/partners`, `/${detectedLng}/pending` ]; if (loginPages.includes(pathname)) { // 사용자의 도메인에 맞는 대시보드로 리다이렉트 const dashboardPath = getDashboardPath(token.domain as string, detectedLng); const redirectUrl = new URL(dashboardPath, origin); redirectUrl.search = searchParams.toString(); return NextResponse.redirect(redirectUrl); } } } /** * 10. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트 */ 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'); return NextResponse.redirect(loginUrl); } } /** * 11. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ const response = NextResponse.next(); /** * 12. 세션 만료 경고를 위한 헤더 추가 */ 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()); } } /** * 13. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트 */ const currentCookie = request.cookies.get(cookieName)?.value; if (detectedLng && detectedLng !== currentCookie) { response.cookies.set(cookieName, detectedLng, { path: '/' }); } return response; } /** * 14. 매칭할 경로 설정 */ export const config = { matcher: [ '/((?!_next|.*\\..*|api|viewer).*)', // API 경로 전체 제외 ], };