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) * * ๐Ÿ”ด ์ค‘์š”: ๋ฏธ๋“ค์›จ์–ด์—์„œ ํ† ํฐ ๊ฐฑ์‹ (refresh)์„ ์ ˆ๋Œ€ ํ•˜์ง€ ์•Š์Œ! * - ๋ฏธ๋“ค์›จ์–ด๋Š” Edge Runtime, PROXY/ServerApiClient๋Š” Node.js Runtime * - ๋ณ„๊ฐœ ํ”„๋กœ์„ธ์Šค๋ผ refresh_token ์บ์‹œ๊ฐ€ ๊ณต์œ ๋˜์ง€ ์•Š์Œ * - ๋ฏธ๋“ค์›จ์–ด๊ฐ€ refresh_token์„ ์†Œ๋น„ํ•˜๋ฉด PROXY์—์„œ ์‚ฌ์šฉํ•  ํ† ํฐ์ด ์—†์–ด์ง * - ํ† ํฐ ๊ฐฑ์‹ ์€ PROXY(/api/proxy)์™€ ServerApiClient์—์„œ๋งŒ ์ฒ˜๋ฆฌ */ // 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 * * refresh_token๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ๋„ ์ธ์ฆ๋œ ๊ฒƒ์œผ๋กœ ๊ฐ„์ฃผ * (์‹ค์ œ ํ† ํฐ ๊ฐฑ์‹ ์€ PROXY์—์„œ ์ฒ˜๋ฆฌ) */ function checkAuthentication(request: NextRequest): { isAuthenticated: boolean; authMode: 'sanctum' | 'bearer' | 'api-key' | null; } { // 1. Bearer Token ํ™•์ธ (์ฟ ํ‚ค์—์„œ) const accessToken = request.cookies.get('access_token'); const refreshToken = request.cookies.get('refresh_token'); // access_token ๋˜๋Š” refresh_token์ด ์žˆ์œผ๋ฉด ์ธ์ฆ๋œ ๊ฒƒ์œผ๋กœ ๊ฐ„์ฃผ // refresh_token๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ: ํŽ˜์ด์ง€ ์ ‘๊ทผ ํ—ˆ์šฉ, ์‹ค์ œ API ํ˜ธ์ถœ ์‹œ PROXY์—์„œ ๊ฐฑ์‹  ์ฒ˜๋ฆฌ if (accessToken?.value || 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 async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const userAgent = request.headers.get('user-agent') || ''; // ๐Ÿšจ -1๏ธโƒฃ Next.js ๋‚ด๋ถ€ ์š”์ฒญ ํ•„ํ„ฐ๋ง // ๋™์  ๋ผ์šฐํŠธ ์„ธ๊ทธ๋จผํŠธ๊ฐ€ ๋ฆฌํ„ฐ๋Ÿด๋กœ ํฌํ•จ๋œ ์š”์ฒญ์€ Next.js ๋‚ด๋ถ€ ์ปดํŒŒ์ผ/prefetch // ์˜ˆ: /[locale]/settings/... ํ˜•ํƒœ์˜ ์š”์ฒญ์€ ์‹ค์ œ ์‚ฌ์šฉ์ž ์š”์ฒญ์ด ์•„๋‹˜ if (pathname.includes('[') && pathname.includes(']')) { return NextResponse.next(); } // ๐Ÿšจ 0๏ธโƒฃ Internet Explorer Detection (์ตœ์šฐ์„  ์ฒ˜๋ฆฌ) // IE ์‚ฌ์šฉ์ž๋Š” ์ง€์› ์•ˆ๋‚ด ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ if (isInternetExplorer(userAgent)) { // unsupported-browser.html ํŽ˜์ด์ง€ ์ž์ฒด๋Š” ์ œ์™ธ (๋ฌดํ•œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋ฐฉ์ง€) if (!pathname.includes('unsupported-browser')) { return NextResponse.redirect(new URL('/unsupported-browser.html', request.url)); } } // 1๏ธโƒฃ ๋กœ์ผ€์ผ ์ œ๊ฑฐ const pathnameWithoutLocale = getPathnameWithoutLocale(pathname); // 1.5๏ธโƒฃ ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ /dev/ ๊ฒฝ๋กœ ์ฐจ๋‹จ if (process.env.NODE_ENV === 'production' && ( pathnameWithoutLocale.startsWith('/dev/') || pathnameWithoutLocale === '/dev' )) { return new NextResponse(null, { status: 404 }); } // 2๏ธโƒฃ Bot Detection (๊ธฐ์กด ๋กœ์ง) const isBotRequest = isBot(userAgent); // Bot Detection: Block bots from protected paths if (isBotRequest && (isProtectedPath(pathname) || isProtectedPath(pathnameWithoutLocale))) { 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/')) { return NextResponse.redirect(new URL('/login', request.url)); } // 5๏ธโƒฃ ๊ฒŒ์ŠคํŠธ ์ „์šฉ ๋ผ์šฐํŠธ (๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…) // ๋Œ€์ „์ œ: "๊ฒŒ์ŠคํŠธ ์ „์šฉ" = ๋กœ๊ทธ์ธ ์•ˆ ํ•œ ์‚ฌ๋žŒ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ // ์ด๋ฏธ ๋กœ๊ทธ์ธํ•œ ์‚ฌ๋žŒ์ด ์˜ค๋ฉด โ†’ /dashboard๋กœ ๋ณด๋ƒ„ if (isGuestOnlyRoute(pathnameWithoutLocale)) { // access_token ๋˜๋Š” refresh_token ์žˆ์Œ โ†’ ์ธ์ฆ ์ƒํƒœ๋กœ ๊ฐ„์ฃผ โ†’ /dashboard๋กœ // refresh_token๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ: /dashboard์—์„œ PROXY๊ฐ€ ๊ฐฑ์‹  ์ฒ˜๋ฆฌ // ๋งŒ์•ฝ refresh_token๋„ ๋งŒ๋ฃŒ๋˜์—ˆ๋‹ค๋ฉด /dashboard์—์„œ API ์‹คํŒจ โ†’ /login์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋จ if (isAuthenticated) { 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) { const url = new URL('/login', request.url); // Open Redirect ๋ฐฉ์ง€: ๋‚ด๋ถ€ ๊ฒฝ๋กœ๋งŒ ํ—ˆ์šฉ const isInternalPath = pathname.startsWith('/') && !pathname.startsWith('//') && !pathname.includes('://'); if (isInternalPath) { url.searchParams.set('redirect', pathname); } return NextResponse.redirect(url); } // 8๏ธโƒฃ 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'); intlResponse.headers.set('Content-Security-Policy', [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net", "frame-src *.daum.net *.daumcdn.net", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ].join('; ')); 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).*)', ], };