Files
sam-react-prod/src/middleware.ts
유병철 1d7b028693 feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
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>
2026-01-19 17:31:28 +09:00

660 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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).*)',
],
};