Phase 2 완료 (4개): - 노무관리, 단가관리(건설), 입금, 출금 Phase 3 라우팅 구조 변경 완료 (22개): - 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A - 현장관리, 실행내역, 견적관리, 견적(테스트) - 입찰관리, 이슈관리, 현장설명회, 견적서(건설) - 협력업체, 시공관리, 기성관리, 품목관리(건설) - 회계 도메인: 거래처, 매출, 세금계산서, 매입 신규 컴포넌트: - ErrorCard: 에러 페이지 UI 통일 - ServerErrorPage: V2 페이지 에러 처리 필수 - V2 Client 컴포넌트 및 Config 파일들 총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
660 lines
22 KiB
TypeScript
660 lines
22 KiB
TypeScript
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).*)',
|
||
],
|
||
}; |