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'; /** * ๐Ÿ”„ ๋ฏธ๋“ค์›จ์–ด ์ „์šฉ ํ† ํฐ ๊ฐฑ์‹  ์บ์‹œ (Edge Runtime) * * ๋ชฉ์ : ํŽ˜์ด์ง€ ๋ Œ๋”๋ง ์ „์— ํ† ํฐ์„ ๋ฏธ๋ฆฌ ๊ฐฑ์‹ ํ•˜์—ฌ race condition ๋ฐฉ์ง€ * - auth/check์™€ serverFetch๊ฐ€ ๋™์‹œ์— refresh_token์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฌธ์ œ ํ•ด๊ฒฐ * - ๋ฏธ๋“ค์›จ์–ด์—์„œ ๋จผ์ € ๊ฐฑ์‹ ํ•˜๋ฉด ์ดํ›„ ์š”์ฒญ๋“ค์€ ์ƒˆ access_token ์‚ฌ์šฉ * * ์ฃผ์˜: Edge Runtime์—์„œ๋Š” ๋ชจ๋“ˆ ๋ ˆ๋ฒจ ๋ณ€์ˆ˜๊ฐ€ ์š”์ฒญ ๊ฐ„ ๊ณต์œ ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ * ๋”ฐ๋ผ์„œ 5์ดˆ ์บ์‹œ๋กœ ๊ฐ™์€ ์š”์ฒญ ๋‚ด ์ค‘๋ณต ๊ฐฑ์‹ ๋งŒ ๋ฐฉ์ง€ */ let middlewareRefreshCache: { promise: Promise<{ success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number }> | null; timestamp: number; result: { success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number } | null; } = { promise: null, timestamp: 0, result: null, }; const MIDDLEWARE_REFRESH_CACHE_TTL = 5000; // 5์ดˆ // 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 * * ๐Ÿ”„ ์ถ”๊ฐ€: needsRefresh - access_token์ด ์—†๊ณ  refresh_token๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ * ์ด ๊ฒฝ์šฐ ๋ฏธ๋“ค์›จ์–ด์—์„œ ์‚ฌ์ „ ๊ฐฑ์‹ ํ•˜์—ฌ ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ race condition ๋ฐฉ์ง€ */ function checkAuthentication(request: NextRequest): { isAuthenticated: boolean; authMode: 'sanctum' | 'bearer' | 'api-key' | null; needsRefresh: boolean; refreshToken: string | null; } { // 1. Bearer Token ํ™•์ธ (์ฟ ํ‚ค์—์„œ) const accessToken = request.cookies.get('access_token'); const refreshToken = request.cookies.get('refresh_token'); // ๐Ÿ”„ access_token์ด ์—†๊ณ  refresh_token๋งŒ ์žˆ์œผ๋ฉด ์‚ฌ์ „ ๊ฐฑ์‹  ํ•„์š” if (!accessToken?.value && refreshToken?.value) { return { isAuthenticated: true, authMode: 'bearer', needsRefresh: true, refreshToken: refreshToken.value, }; } // access_token์ด ์žˆ์œผ๋ฉด ๊ฐฑ์‹  ๋ถˆํ•„์š” if (accessToken?.value) { return { isAuthenticated: true, authMode: 'bearer', needsRefresh: false, refreshToken: refreshToken?.value || null, }; } // 2. Bearer Token ํ™•์ธ (Authorization ํ—ค๋”) const authHeader = request.headers.get('authorization'); if (authHeader?.startsWith('Bearer ')) { return { isAuthenticated: true, authMode: 'bearer', needsRefresh: false, refreshToken: null }; } // 3. Sanctum ์„ธ์…˜ ์ฟ ํ‚ค ํ™•์ธ (๋ ˆ๊ฑฐ์‹œ ์ง€์›) const sessionCookie = request.cookies.get('laravel_session'); if (sessionCookie) { return { isAuthenticated: true, authMode: 'sanctum', needsRefresh: false, refreshToken: null }; } // 4. API Key ํ™•์ธ const apiKey = request.headers.get('x-api-key'); if (apiKey) { return { isAuthenticated: true, authMode: 'api-key', needsRefresh: false, refreshToken: null }; } return { isAuthenticated: false, authMode: null, needsRefresh: false, refreshToken: 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 + '/'); }); } /** * ๐Ÿ”„ ๋ฏธ๋“ค์›จ์–ด์—์„œ ํ† ํฐ ๊ฐฑ์‹  (ํŽ˜์ด์ง€ ๋ Œ๋”๋ง ์ „) * * ๋ชฉ์ : Race Condition ๋ฐฉ์ง€ * - ๋ฌธ์ œ: auth/check์™€ serverFetch๊ฐ€ ๋™์‹œ์— refresh_token ์‚ฌ์šฉ * - ํ•ด๊ฒฐ: ๋ฏธ๋“ค์›จ์–ด์—์„œ ๋จผ์ € ๊ฐฑ์‹ ํ•˜์—ฌ ํŽ˜์ด์ง€ ๋กœ๋“œ ์ „์— ์ƒˆ ํ† ํฐ ์ค€๋น„ * * 5์ดˆ ์บ์‹ฑ์œผ๋กœ ์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€ */ async function refreshTokenInMiddleware( refreshToken: string ): Promise<{ success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number }> { const now = Date.now(); // 1. ์บ์‹œ๋œ ์„ฑ๊ณต ๊ฒฐ๊ณผ๊ฐ€ ์œ ํšจํ•˜๋ฉด ์ฆ‰์‹œ ๋ฐ˜ํ™˜ if (middlewareRefreshCache.result && middlewareRefreshCache.result.success && now - middlewareRefreshCache.timestamp < MIDDLEWARE_REFRESH_CACHE_TTL) { console.log(`๐Ÿ”ต [Middleware] Using cached refresh result (age: ${now - middlewareRefreshCache.timestamp}ms)`); return middlewareRefreshCache.result; } // 2. ์ง„ํ–‰ ์ค‘์ธ refresh๊ฐ€ ์žˆ์œผ๋ฉด ๊ธฐ๋‹ค๋ฆผ if (middlewareRefreshCache.promise && !middlewareRefreshCache.result && now - middlewareRefreshCache.timestamp < MIDDLEWARE_REFRESH_CACHE_TTL) { console.log(`๐Ÿ”ต [Middleware] Waiting for ongoing refresh...`); return middlewareRefreshCache.promise; } // 3. ์ด์ „ refresh๊ฐ€ ์‹คํŒจํ–ˆ์œผ๋ฉด ์บ์‹œ ์ดˆ๊ธฐํ™” if (middlewareRefreshCache.result && !middlewareRefreshCache.result.success) { middlewareRefreshCache.promise = null; middlewareRefreshCache.result = null; } // 4. ์ƒˆ refresh ์‹œ์ž‘ console.log(`๐Ÿ”„ [Middleware] Starting pre-refresh before page render...`); middlewareRefreshCache.timestamp = now; middlewareRefreshCache.result = null; middlewareRefreshCache.promise = (async () => { try { const refreshUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`; const response = await fetch(refreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-API-KEY': process.env.API_KEY || '', }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { console.warn('๐Ÿ”ด [Middleware] Pre-refresh failed:', response.status); return { success: false }; } const data = await response.json(); console.log('โœ… [Middleware] Pre-refresh successful'); return { success: true, accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, }; } catch (error) { console.error('๐Ÿ”ด [Middleware] Pre-refresh error:', error); return { success: false }; } })(); middlewareRefreshCache.result = await middlewareRefreshCache.promise; return middlewareRefreshCache.result; } 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(']')) { // console.log(`[Internal Request Skip] Dynamic segment in path: ${pathname}`); return NextResponse.next(); } // ๐Ÿšจ 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, needsRefresh, refreshToken } = 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๏ธโƒฃ ๊ฒŒ์ŠคํŠธ ์ „์šฉ ๋ผ์šฐํŠธ (๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…) // ๋Œ€์ „์ œ: "๊ฒŒ์ŠคํŠธ ์ „์šฉ" = ๋กœ๊ทธ์ธ ์•ˆ ํ•œ ์‚ฌ๋žŒ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ // ์ด๋ฏธ ๋กœ๊ทธ์ธํ•œ ์‚ฌ๋žŒ์ด ์˜ค๋ฉด โ†’ /dashboard๋กœ ๋ณด๋ƒ„ if (isGuestOnlyRoute(pathnameWithoutLocale)) { // ๐Ÿ”„ needsRefresh์ธ ๊ฒฝ์šฐ: ๋จผ์ € refresh ์‹œ๋„ํ•ด์„œ "์ง„์งœ ๋กœ๊ทธ์ธ ์ƒํƒœ"์ธ์ง€ ํ™•์ธ // refresh_token๋งŒ ์žˆ๋Š” ์ƒํƒœ = "๋กœ๊ทธ์ธ ๊ฐ€๋Šฅ์„ฑ ์žˆ์Œ" (ํ™•์ • ์•„๋‹˜) if (needsRefresh && refreshToken) { console.log(`๐Ÿ”„ [Middleware] Verifying auth status on guest route: ${pathname}`); const refreshResult = await refreshTokenInMiddleware(refreshToken); if (refreshResult.success && refreshResult.accessToken) { // โœ… refresh ์„ฑ๊ณต = ์ง„์งœ ๋กœ๊ทธ์ธ๋จ โ†’ /dashboard๋กœ (๊ฒŒ์ŠคํŠธ ์ „์šฉ์ด๋‹ˆ๊นŒ) console.log(`โœ… [Middleware] Authenticated, redirecting to dashboard from guest route`); const isProduction = process.env.NODE_ENV === 'production'; const response = NextResponse.redirect(new URL(AUTH_CONFIG.redirects.afterLogin, request.url)); // ์ƒˆ ์ฟ ํ‚ค ์„ค์ • response.headers.append('Set-Cookie', [ `access_token=${refreshResult.accessToken}`, 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', `Max-Age=${refreshResult.expiresIn || 7200}`, ].join('; ')); response.headers.append('Set-Cookie', [ `refresh_token=${refreshResult.refreshToken}`, 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=604800', ].join('; ')); response.headers.append('Set-Cookie', [ 'is_authenticated=true', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', `Max-Age=${refreshResult.expiresIn || 7200}`, ].join('; ')); return response; } else { // โŒ refresh ์‹คํŒจ = ๋กœ๊ทธ์ธ ์•ˆ ๋จ โ†’ ์ฟ ํ‚ค ์‚ญ์ œ ํ›„ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ํ‘œ์‹œ (์™•๋ณต ์—†์ด!) console.log(`๐Ÿ”ด [Middleware] Not authenticated, showing guest page directly`); const isProduction = process.env.NODE_ENV === 'production'; const intlResponse = intlMiddleware(request); // ๋งŒ๋ฃŒ๋œ ์ฟ ํ‚ค ์‚ญ์ œ intlResponse.headers.append('Set-Cookie', [ 'access_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=0', ].join('; ')); intlResponse.headers.append('Set-Cookie', [ 'refresh_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=0', ].join('; ')); intlResponse.headers.append('Set-Cookie', [ 'is_authenticated=', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=0', ].join('; ')); return intlResponse; } } // access_token ์žˆ์Œ = ํ™•์‹คํžˆ ๋กœ๊ทธ์ธ๋จ โ†’ /dashboard๋กœ 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); } // 7๏ธโƒฃ.5๏ธโƒฃ ๐Ÿ”„ ํ† ํฐ ์‚ฌ์ „ ๊ฐฑ์‹  (Race Condition ๋ฐฉ์ง€) // access_token์ด ์—†๊ณ  refresh_token๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ, ํŽ˜์ด์ง€ ๋ Œ๋”๋ง ์ „์— ๋ฏธ๋ฆฌ ๊ฐฑ์‹  // ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด auth/check์™€ serverFetch๊ฐ€ ๋™์‹œ์— refresh_token์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฌธ์ œ ๋ฐฉ์ง€ if (needsRefresh && refreshToken) { console.log(`๐Ÿ”„ [Middleware] Pre-refreshing token before page render: ${pathname}`); const refreshResult = await refreshTokenInMiddleware(refreshToken); if (refreshResult.success && refreshResult.accessToken) { const isProduction = process.env.NODE_ENV === 'production'; // ๐Ÿ†• request headers์— ์ƒˆ ํ† ํฐ ์„ค์ • (๊ฐ™์€ ์š”์ฒญ ๋‚ด ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ฝ์„ ์ˆ˜ ์žˆ๋„๋ก) // Set-Cookie๋Š” ์‘๋‹ต ํ—ค๋”์—๋งŒ ์„ค์ •๋˜์–ด ๊ฐ™์€ ์š”์ฒญ ๋‚ด cookies()๋กœ ์ฝ์„ ์ˆ˜ ์—†์Œ // ๋”ฐ๋ผ์„œ request headers๋กœ ์ƒˆ ํ† ํฐ์„ ์ „๋‹ฌํ•˜์—ฌ serverFetch์—์„œ ์‚ฌ์šฉํ•˜๋„๋ก ํ•จ const requestHeaders = new Headers(request.headers); requestHeaders.set('x-refreshed-access-token', refreshResult.accessToken); requestHeaders.set('x-refreshed-refresh-token', refreshResult.refreshToken || ''); // intlMiddleware ํšจ๊ณผ๋ฅผ ๋จผ์ € ๊ฐ€์ ธ์˜ด const intlResponse = intlMiddleware(request); // ์ƒˆ response ์ƒ์„ฑ: request headers ์ „๋‹ฌ + intlResponse ํ—ค๋” ๋ณต์‚ฌ const response = NextResponse.next({ request: { headers: requestHeaders, }, }); // intlResponse์˜ ํ—ค๋”๋ฅผ ๋ณต์‚ฌ (locale ๊ด€๋ จ ํ—ค๋” ๋“ฑ) intlResponse.headers.forEach((value, key) => { if (key.toLowerCase() !== 'set-cookie') { response.headers.set(key, value); } }); // ์ƒˆ access_token ์ฟ ํ‚ค ์„ค์ • (ํด๋ผ์ด์–ธํŠธ์˜ ๋‹ค์Œ ์š”์ฒญ์„ ์œ„ํ•ด) const accessTokenCookie = [ `access_token=${refreshResult.accessToken}`, 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', `Max-Age=${refreshResult.expiresIn || 7200}`, ].join('; '); // ์ƒˆ refresh_token ์ฟ ํ‚ค ์„ค์ • const refreshTokenCookie = [ `refresh_token=${refreshResult.refreshToken}`, 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=604800', // 7 days (ํ•˜๋“œ์ฝ”๋”ฉ ์œ ์ง€) ].join('; '); // ํ† ํฐ ๊ฐฑ์‹  ์‹ ํ˜ธ ์ฟ ํ‚ค (ํด๋ผ์ด์–ธํŠธ์—์„œ ๊ฐ์ง€์šฉ) const tokenRefreshedCookie = [ `token_refreshed_at=${Date.now()}`, ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=60', ].join('; '); // ์ธ์ฆ ์ƒํƒœ ์ฟ ํ‚ค const isAuthenticatedCookie = [ 'is_authenticated=true', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', `Max-Age=${refreshResult.expiresIn || 7200}`, ].join('; '); response.headers.append('Set-Cookie', accessTokenCookie); response.headers.append('Set-Cookie', refreshTokenCookie); response.headers.append('Set-Cookie', tokenRefreshedCookie); response.headers.append('Set-Cookie', isAuthenticatedCookie); // ๋ณด์•ˆ ํ—ค๋” ์ถ”๊ฐ€ response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); console.log(`โœ… [Middleware] Pre-refresh complete, new tokens set in cookies and request headers`); return response; } else { // ๊ฐฑ์‹  ์‹คํŒจ ์‹œ ์ฟ ํ‚ค ์‚ญ์ œ ํ›„ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ // ๐Ÿ”ด CRITICAL: ์ฟ ํ‚ค๋ฅผ ์‚ญ์ œํ•˜์ง€ ์•Š์œผ๋ฉด ๋ฌดํ•œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋ฃจํ”„ ๋ฐœ์ƒ // - /login ์ ‘๊ทผ ์‹œ refresh_token ์žˆ์œผ๋ฉด isAuthenticated=true ํŒ์ • // - "Already Authenticated" โ†’ /dashboard๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ // - ๋‹ค์‹œ needsRefresh=true โ†’ pre-refresh ์‹œ๋„ โ†’ 401 ์‹คํŒจ โ†’ /login // - ๋ฌดํ•œ ๋ฃจํ”„! console.warn(`๐Ÿ”ด [Middleware] Pre-refresh failed, clearing cookies and redirecting to login`); const isProduction = process.env.NODE_ENV === 'production'; const url = new URL('/login', request.url); url.searchParams.set('redirect', pathname); const response = NextResponse.redirect(url); // ์ฟ ํ‚ค ์‚ญ์ œ (Max-Age=0์œผ๋กœ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ) const clearAccessToken = [ 'access_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=0', ].join('; '); const clearRefreshToken = [ 'refresh_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=0', ].join('; '); const clearIsAuthenticated = [ 'is_authenticated=', ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', 'Max-Age=0', ].join('; '); response.headers.append('Set-Cookie', clearAccessToken); response.headers.append('Set-Cookie', clearRefreshToken); response.headers.append('Set-Cookie', clearIsAuthenticated); return response; } } // 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).*)', ], };