import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import createMiddleware from 'next-intl/middleware'; import { locales, defaultLocale } from '@/i18n/config'; /** * Combined Middleware for Multi-tenant ERP System * * Features: * 1. Internationalization (i18n) with locale detection * 2. Bot Detection and blocking for security * 3. Authentication and authorization (Sanctum/Bearer/API-Key) * * Strategy: Moderate bot blocking + Session-based auth * - Allows legitimate browsers and necessary crawlers * - Blocks bots from accessing sensitive ERP areas * - Protects routes with session/token authentication * - Prevents Chrome security warnings by not being too aggressive */ // Auth configuration import { AUTH_CONFIG } from '@/lib/api/auth/auth-config'; // Create i18n middleware const intlMiddleware = createMiddleware({ locales, defaultLocale, localePrefix: 'as-needed', // Don't show default locale in URL }); // Common bot user-agent patterns (case-insensitive) const BOT_PATTERNS = [ /bot/i, /crawler/i, /spider/i, /scraper/i, /curl/i, /wget/i, /python-requests/i, /scrapy/i, /axios/i, // Programmatic access /headless/i, /phantom/i, /selenium/i, /puppeteer/i, /playwright/i, // Browser automation tools /go-http-client/i, /java/i, /okhttp/i, /apache-httpclient/i, ]; // Paths that should be protected from bots const PROTECTED_PATHS = [ '/dashboard', '/admin', '/api', '/tenant', '/settings', '/users', '/reports', '/analytics', '/inventory', '/finance', '/hr', '/crm', '/employee', '/customer', '/supplier', '/orders', '/invoices', '/payroll', ]; // Paths that are allowed for everyone (including bots) const PUBLIC_PATHS = [ '/', '/login', '/about', '/contact', '/robots.txt', '/sitemap.xml', '/favicon.ico', ]; /** * Check if user-agent matches known bot patterns */ function isBot(userAgent: string): boolean { if (!userAgent) return false; return BOT_PATTERNS.some(pattern => pattern.test(userAgent)); } /** * Check if user-agent is Internet Explorer * IE 11: Contains "Trident" in user-agent * IE 10 and below: Contains "MSIE" in user-agent */ function isInternetExplorer(userAgent: string): boolean { if (!userAgent) return false; return /MSIE|Trident/.test(userAgent); } /** * Check if the path should be protected from bots */ function isProtectedPath(pathname: string): boolean { return PROTECTED_PATHS.some(path => pathname.startsWith(path)); } /** * Check if the path is public and accessible to all * Note: Currently unused but kept for future use */ function _isPublicPath(pathname: string): boolean { return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path)); } /** * Remove locale prefix from pathname for bot checking */ function getPathnameWithoutLocale(pathname: string): string { for (const locale of locales) { if (pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`) { return pathname.slice(`/${locale}`.length) || '/'; } } return pathname; } /** * 인증 체크 함수 * 3가지 인증 방식 지원: Bearer Token/Sanctum/API-Key */ function checkAuthentication(request: NextRequest): { isAuthenticated: boolean; authMode: 'sanctum' | 'bearer' | 'api-key' | null; } { // 1. Bearer Token 확인 (쿠키에서) // access_token 또는 refresh_token이 있으면 인증된 것으로 간주 const accessToken = request.cookies.get('access_token'); const refreshToken = request.cookies.get('refresh_token'); if ((accessToken && accessToken.value) || (refreshToken && refreshToken.value)) { return { isAuthenticated: true, authMode: 'bearer' }; } // 2. Bearer Token 확인 (Authorization 헤더) const authHeader = request.headers.get('authorization'); if (authHeader?.startsWith('Bearer ')) { return { isAuthenticated: true, authMode: 'bearer' }; } // 3. Sanctum 세션 쿠키 확인 (레거시 지원) const sessionCookie = request.cookies.get('laravel_session'); if (sessionCookie) { return { isAuthenticated: true, authMode: 'sanctum' }; } // 4. API Key 확인 const apiKey = request.headers.get('x-api-key'); if (apiKey) { return { isAuthenticated: true, authMode: 'api-key' }; } return { isAuthenticated: false, authMode: null }; } /** * 라우트 타입 확인 함수들 */ function isGuestOnlyRoute(pathname: string): boolean { return AUTH_CONFIG.guestOnlyRoutes.some(route => pathname === route || pathname.startsWith(route) ); } function isPublicRoute(pathname: string): boolean { return AUTH_CONFIG.publicRoutes.some(route => { // '/' 는 정확히 일치해야만 public if (route === '/') { return pathname === '/'; } // 다른 라우트는 시작 일치 허용 return pathname === route || pathname.startsWith(route + '/'); }); } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const userAgent = request.headers.get('user-agent') || ''; // 🚨 0️⃣ Internet Explorer Detection (최우선 처리) // IE 사용자는 지원 안내 페이지로 리다이렉트 if (isInternetExplorer(userAgent)) { // unsupported-browser.html 페이지 자체는 제외 (무한 리다이렉트 방지) if (!pathname.includes('unsupported-browser')) { console.log(`[IE Blocked] ${userAgent} attempted to access ${pathname}`); return NextResponse.redirect(new URL('/unsupported-browser.html', request.url)); } } // 1️⃣ 로케일 제거 const pathnameWithoutLocale = getPathnameWithoutLocale(pathname); // 2️⃣ Bot Detection (기존 로직) const isBotRequest = isBot(userAgent); // Bot Detection: Block bots from protected paths if (isBotRequest && (isProtectedPath(pathname) || isProtectedPath(pathnameWithoutLocale))) { console.log(`[Bot Blocked] ${userAgent} attempted to access ${pathname}`); return new NextResponse( JSON.stringify({ error: 'Access Denied', message: 'Automated access to this resource is not permitted.', code: 'BOT_ACCESS_DENIED' }), { status: 403, headers: { 'Content-Type': 'application/json', 'X-Robots-Tag': 'noindex, nofollow, noarchive, nosnippet', }, } ); } // 3️⃣ 정적 파일 제외 if ( pathname.includes('/_next/') || pathname.includes('/api/') || pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/) ) { return intlMiddleware(request); } // 4️⃣ 인증 체크 const { isAuthenticated, authMode } = checkAuthentication(request); // 4.5️⃣ MVP: /signup 접근 차단 → /login 리다이렉트 (2025-12-04) // 회원가입 기능은 운영 페이지로 이동 예정 if (pathnameWithoutLocale === '/signup' || pathnameWithoutLocale.startsWith('/signup/')) { console.log(`[Signup Blocked] Redirecting to /login from ${pathname}`); return NextResponse.redirect(new URL('/login', request.url)); } // 5️⃣ 게스트 전용 라우트 (로그인/회원가입) if (isGuestOnlyRoute(pathnameWithoutLocale)) { // 이미 로그인한 경우 대시보드로 if (isAuthenticated) { console.log(`[Already Authenticated] Redirecting to /dashboard from ${pathname}`); return NextResponse.redirect(new URL(AUTH_CONFIG.redirects.afterLogin, request.url)); } // 비로그인 상태면 접근 허용 return intlMiddleware(request); } // 6️⃣ 공개 라우트 (명시적으로 지정된 경우만) if (isPublicRoute(pathnameWithoutLocale)) { return intlMiddleware(request); } // 7️⃣ 기본 정책: 모든 페이지는 인증 필요 // guestOnlyRoutes와 publicRoutes가 아닌 모든 경로는 보호됨 if (!isAuthenticated) { console.log(`[Auth Required] Redirecting to /login from ${pathname}`); const url = new URL('/login', request.url); url.searchParams.set('redirect', pathname); return NextResponse.redirect(url); } // 8️⃣ 인증 모드 로깅 (디버깅용) if (isAuthenticated) { console.log(`[Authenticated] Mode: ${authMode}, Path: ${pathname}`); } // 9️⃣ i18n 미들웨어 실행 const intlResponse = intlMiddleware(request); // 🔟 보안 헤더 추가 intlResponse.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet'); intlResponse.headers.set('X-Content-Type-Options', 'nosniff'); intlResponse.headers.set('X-Frame-Options', 'DENY'); intlResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); // Bot 로깅 (모니터링용) if (isBotRequest) { console.log(`[Bot Allowed] ${userAgent} accessed ${pathname}`); } return intlResponse; } /** * Configure which paths the middleware should run on * * Matcher configuration: * - Excludes static files and assets * - Includes all app routes */ export const config = { matcher: [ /* * Match all pathnames except: * - api routes * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico, robots.txt * - files with extensions (images, etc.) */ '/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|robots\\.txt).*)', ], };