2025-11-06 13:33:00 +09:00
|
|
|
|
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
|
2025-11-10 09:38:59 +09:00
|
|
|
|
* 3. Authentication and authorization (Sanctum/Bearer/API-Key)
|
2025-11-06 13:33:00 +09:00
|
|
|
|
*
|
2026-01-30 09:32:04 +09:00
|
|
|
|
* 🔴 중요: 미들웨어에서 토큰 갱신(refresh)을 절대 하지 않음!
|
|
|
|
|
|
* - 미들웨어는 Edge Runtime, PROXY/ServerApiClient는 Node.js Runtime
|
|
|
|
|
|
* - 별개 프로세스라 refresh_token 캐시가 공유되지 않음
|
|
|
|
|
|
* - 미들웨어가 refresh_token을 소비하면 PROXY에서 사용할 토큰이 없어짐
|
|
|
|
|
|
* - 토큰 갱신은 PROXY(/api/proxy)와 ServerApiClient에서만 처리
|
2025-11-06 13:33:00 +09:00
|
|
|
|
*/
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// Auth configuration
|
|
|
|
|
|
import { AUTH_CONFIG } from '@/lib/api/auth/auth-config';
|
|
|
|
|
|
|
2025-11-06 13:33:00 +09:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-13 14:32:14 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-06 13:33:00 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2025-11-06 13:48:11 +09:00
|
|
|
|
* Note: Currently unused but kept for future use
|
2025-11-06 13:33:00 +09:00
|
|
|
|
*/
|
2025-11-06 13:48:11 +09:00
|
|
|
|
function _isPublicPath(pathname: string): boolean {
|
2025-11-06 13:33:00 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 인증 체크 함수
|
|
|
|
|
|
* 3가지 인증 방식 지원: Bearer Token/Sanctum/API-Key
|
2026-01-16 15:19:09 +09:00
|
|
|
|
*
|
2026-01-30 09:32:04 +09:00
|
|
|
|
* refresh_token만 있는 경우도 인증된 것으로 간주
|
|
|
|
|
|
* (실제 토큰 갱신은 PROXY에서 처리)
|
2025-11-10 09:38:59 +09:00
|
|
|
|
*/
|
|
|
|
|
|
function checkAuthentication(request: NextRequest): {
|
|
|
|
|
|
isAuthenticated: boolean;
|
|
|
|
|
|
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
|
|
|
|
|
|
} {
|
|
|
|
|
|
// 1. Bearer Token 확인 (쿠키에서)
|
2025-11-10 17:25:56 +09:00
|
|
|
|
const accessToken = request.cookies.get('access_token');
|
|
|
|
|
|
const refreshToken = request.cookies.get('refresh_token');
|
2026-01-16 15:19:09 +09:00
|
|
|
|
|
2026-01-30 09:32:04 +09:00
|
|
|
|
// access_token 또는 refresh_token이 있으면 인증된 것으로 간주
|
|
|
|
|
|
// refresh_token만 있는 경우: 페이지 접근 허용, 실제 API 호출 시 PROXY에서 갱신 처리
|
|
|
|
|
|
if (accessToken?.value || refreshToken?.value) {
|
2026-01-16 15:19:09 +09:00
|
|
|
|
return {
|
|
|
|
|
|
isAuthenticated: true,
|
|
|
|
|
|
authMode: 'bearer',
|
|
|
|
|
|
};
|
2025-11-10 09:38:59 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Bearer Token 확인 (Authorization 헤더)
|
|
|
|
|
|
const authHeader = request.headers.get('authorization');
|
|
|
|
|
|
if (authHeader?.startsWith('Bearer ')) {
|
2026-01-30 09:32:04 +09:00
|
|
|
|
return { isAuthenticated: true, authMode: 'bearer' };
|
2025-11-10 09:38:59 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Sanctum 세션 쿠키 확인 (레거시 지원)
|
|
|
|
|
|
const sessionCookie = request.cookies.get('laravel_session');
|
|
|
|
|
|
if (sessionCookie) {
|
2026-01-30 09:32:04 +09:00
|
|
|
|
return { isAuthenticated: true, authMode: 'sanctum' };
|
2025-11-10 09:38:59 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. API Key 확인
|
|
|
|
|
|
const apiKey = request.headers.get('x-api-key');
|
|
|
|
|
|
if (apiKey) {
|
2026-01-30 09:32:04 +09:00
|
|
|
|
return { isAuthenticated: true, authMode: 'api-key' };
|
2025-11-10 09:38:59 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 09:32:04 +09:00
|
|
|
|
return { isAuthenticated: false, authMode: null };
|
2025-11-10 09:38:59 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 라우트 타입 확인 함수들
|
|
|
|
|
|
*/
|
|
|
|
|
|
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 + '/');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 15:19:09 +09:00
|
|
|
|
export async function middleware(request: NextRequest) {
|
2025-11-06 13:33:00 +09:00
|
|
|
|
const { pathname } = request.nextUrl;
|
|
|
|
|
|
const userAgent = request.headers.get('user-agent') || '';
|
|
|
|
|
|
|
2026-01-14 13:46:56 +09:00
|
|
|
|
// 🚨 -1️⃣ Next.js 내부 요청 필터링
|
|
|
|
|
|
// 동적 라우트 세그먼트가 리터럴로 포함된 요청은 Next.js 내부 컴파일/prefetch
|
|
|
|
|
|
// 예: /[locale]/settings/... 형태의 요청은 실제 사용자 요청이 아님
|
|
|
|
|
|
if (pathname.includes('[') && pathname.includes(']')) {
|
|
|
|
|
|
return NextResponse.next();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-13 14:32:14 +09:00
|
|
|
|
// 🚨 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// 1️⃣ 로케일 제거
|
2025-11-06 13:33:00 +09:00
|
|
|
|
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);
|
|
|
|
|
|
|
2026-02-12 14:59:46 +09:00
|
|
|
|
// 1.5️⃣ 프로덕션 환경에서 /dev/ 경로 차단
|
|
|
|
|
|
if (process.env.NODE_ENV === 'production' && (
|
|
|
|
|
|
pathnameWithoutLocale.startsWith('/dev/') || pathnameWithoutLocale === '/dev'
|
|
|
|
|
|
)) {
|
|
|
|
|
|
return new NextResponse(null, { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// 2️⃣ Bot Detection (기존 로직)
|
2025-11-06 13:33:00 +09:00
|
|
|
|
const isBotRequest = isBot(userAgent);
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// Bot Detection: Block bots from protected paths
|
2025-11-06 13:33:00 +09:00
|
|
|
|
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',
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// 3️⃣ 정적 파일 제외
|
|
|
|
|
|
if (
|
|
|
|
|
|
pathname.includes('/_next/') ||
|
|
|
|
|
|
pathname.includes('/api/') ||
|
|
|
|
|
|
pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return intlMiddleware(request);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4️⃣ 인증 체크
|
2026-01-30 09:32:04 +09:00
|
|
|
|
const { isAuthenticated, authMode } = checkAuthentication(request);
|
2025-11-10 09:38:59 +09:00
|
|
|
|
|
2025-12-04 20:52:42 +09:00
|
|
|
|
// 4.5️⃣ MVP: /signup 접근 차단 → /login 리다이렉트 (2025-12-04)
|
|
|
|
|
|
// 회원가입 기능은 운영 페이지로 이동 예정
|
|
|
|
|
|
if (pathnameWithoutLocale === '/signup' || pathnameWithoutLocale.startsWith('/signup/')) {
|
|
|
|
|
|
return NextResponse.redirect(new URL('/login', request.url));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// 5️⃣ 게스트 전용 라우트 (로그인/회원가입)
|
2026-01-16 15:19:09 +09:00
|
|
|
|
// 대전제: "게스트 전용" = 로그인 안 한 사람만 접근 가능
|
|
|
|
|
|
// 이미 로그인한 사람이 오면 → /dashboard로 보냄
|
2025-11-10 09:38:59 +09:00
|
|
|
|
if (isGuestOnlyRoute(pathnameWithoutLocale)) {
|
2026-01-30 09:32:04 +09:00
|
|
|
|
// access_token 또는 refresh_token 있음 → 인증 상태로 간주 → /dashboard로
|
|
|
|
|
|
// refresh_token만 있는 경우: /dashboard에서 PROXY가 갱신 처리
|
|
|
|
|
|
// 만약 refresh_token도 만료되었다면 /dashboard에서 API 실패 → /login으로 리다이렉트됨
|
2025-11-10 09:38:59 +09:00
|
|
|
|
if (isAuthenticated) {
|
|
|
|
|
|
return NextResponse.redirect(new URL(AUTH_CONFIG.redirects.afterLogin, request.url));
|
|
|
|
|
|
}
|
2026-01-16 15:19:09 +09:00
|
|
|
|
|
|
|
|
|
|
// 쿠키 아예 없음 = 비로그인 → 로그인 페이지 표시
|
2025-11-10 09:38:59 +09:00
|
|
|
|
return intlMiddleware(request);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 6️⃣ 공개 라우트 (명시적으로 지정된 경우만)
|
|
|
|
|
|
if (isPublicRoute(pathnameWithoutLocale)) {
|
|
|
|
|
|
return intlMiddleware(request);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 7️⃣ 기본 정책: 모든 페이지는 인증 필요
|
|
|
|
|
|
// guestOnlyRoutes와 publicRoutes가 아닌 모든 경로는 보호됨
|
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
|
const url = new URL('/login', request.url);
|
2026-02-09 16:14:06 +09:00
|
|
|
|
// Open Redirect 방지: 내부 경로만 허용
|
|
|
|
|
|
const isInternalPath = pathname.startsWith('/') && !pathname.startsWith('//') && !pathname.includes('://');
|
|
|
|
|
|
if (isInternalPath) {
|
|
|
|
|
|
url.searchParams.set('redirect', pathname);
|
|
|
|
|
|
}
|
2025-11-10 09:38:59 +09:00
|
|
|
|
return NextResponse.redirect(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 20:55:11 +09:00
|
|
|
|
// 8️⃣ i18n 미들웨어 실행
|
2025-11-06 13:33:00 +09:00
|
|
|
|
const intlResponse = intlMiddleware(request);
|
|
|
|
|
|
|
2025-11-10 09:38:59 +09:00
|
|
|
|
// 🔟 보안 헤더 추가
|
2025-11-06 13:33:00 +09:00
|
|
|
|
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');
|
2026-02-09 16:14:06 +09:00
|
|
|
|
intlResponse.headers.set('Content-Security-Policy', [
|
|
|
|
|
|
"default-src 'self'",
|
2026-02-12 14:15:09 +09:00
|
|
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net",
|
2026-02-09 16:14:06 +09:00
|
|
|
|
"style-src 'self' 'unsafe-inline'",
|
|
|
|
|
|
"img-src 'self' data: blob: https:",
|
|
|
|
|
|
"font-src 'self' data: https://fonts.gstatic.com",
|
2026-02-12 14:15:09 +09:00
|
|
|
|
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net",
|
|
|
|
|
|
"frame-src *.daum.net *.daumcdn.net",
|
2026-02-09 16:14:06 +09:00
|
|
|
|
"frame-ancestors 'none'",
|
|
|
|
|
|
"base-uri 'self'",
|
|
|
|
|
|
"form-action 'self'",
|
|
|
|
|
|
].join('; '));
|
2025-11-06 13:33:00 +09:00
|
|
|
|
|
|
|
|
|
|
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: [
|
|
|
|
|
|
/*
|
2025-11-10 09:38:59 +09:00
|
|
|
|
* Match all pathnames except:
|
|
|
|
|
|
* - api routes
|
2025-11-06 13:33:00 +09:00
|
|
|
|
* - _next/static (static files)
|
|
|
|
|
|
* - _next/image (image optimization files)
|
2025-11-10 09:38:59 +09:00
|
|
|
|
* - favicon.ico, robots.txt
|
|
|
|
|
|
* - files with extensions (images, etc.)
|
2025-11-06 13:33:00 +09:00
|
|
|
|
*/
|
2025-11-10 09:38:59 +09:00
|
|
|
|
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|robots\\.txt).*)',
|
2025-11-06 13:33:00 +09:00
|
|
|
|
],
|
2026-01-30 09:32:04 +09:00
|
|
|
|
};
|