Files
sam-react-prod/src/middleware.ts

316 lines
9.1 KiB
TypeScript
Raw Normal View History

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';
// 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
*/
function checkAuthentication(request: NextRequest): {
isAuthenticated: boolean;
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
} {
// 1. Bearer Token 확인 (쿠키에서)
// access_token 또는 refresh_token이 있으면 인증된 것으로 간주
const accessToken = request.cookies.get('access_token');
const refreshToken = request.cookies.get('refresh_token');
if ((accessToken && accessToken.value) || (refreshToken && 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 function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const userAgent = request.headers.get('user-agent') || '';
// 🚨 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 } = 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⃣ 게스트 전용 라우트 (로그인/회원가입)
if (isGuestOnlyRoute(pathnameWithoutLocale)) {
// 이미 로그인한 경우 대시보드로
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);
}
// 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).*)',
],
};