Files
sam-react-prod/src/middleware.ts
유병철 2b8a19b4af feat(WEB): 컴포넌트 레지스트리 UI 개선 및 middleware 업데이트
- ComponentRegistryClient 기능 확장 및 UI 개선
- middleware 라우팅 로직 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:59:46 +09:00

344 lines
10 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)
*
* 🔴 중요: 미들웨어에서 토큰 갱신(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).*)',
],
};