Files
sam-react-prod/src/middleware.ts
byeongcheolryu 751e65f59b fix: 품목관리 수정 기능 버그 수정 및 Sales 페이지 추가
## 품목관리 수정 버그 수정
- FG(제품) 수정 시 품목명 반영 안되는 문제 해결
  - productName → name 필드 매핑 추가
  - FG 품목코드 = 품목명 동기화 로직 추가
- Materials(SM, RM, CS) 수정페이지 진입 오류 해결
- UNIQUE 제약조건 위반 오류 해결

## Sales 페이지
- 거래처관리 (client-management-sales-admin) 페이지 구현
- 견적관리 (quote-management) 페이지 구현
- 관련 컴포넌트 및 훅 추가

## 기타
- 회원가입 페이지 차단 처리
- 디버깅용 콘솔 로그 추가 (PUT 요청/응답 확인용)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 20:52:42 +09:00

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