[feat]: 보호된 대시보드 및 API 라우트 추가
- 인증된 사용자용 대시보드 페이지 구현 ((protected) 라우트 그룹) - API 엔드포인트 추가 (인증, 사용자 관리) - 커스텀 훅 추가 (useAuth) - 미들웨어 인증 로직 강화 - 환경변수 예제 업데이트 - 기존 dashboard 페이지 제거 후 보호된 라우트로 이동 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,18 @@ import { locales, defaultLocale } from '@/i18n/config';
|
||||
* 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
|
||||
* 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,
|
||||
@@ -114,21 +119,82 @@ function getPathnameWithoutLocale(pathname: string): string {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 체크 함수
|
||||
* 3가지 인증 방식 지원: Bearer Token/Sanctum/API-Key
|
||||
*/
|
||||
function checkAuthentication(request: NextRequest): {
|
||||
isAuthenticated: boolean;
|
||||
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
|
||||
} {
|
||||
// 1. Bearer Token 확인 (쿠키에서)
|
||||
// 클라이언트에서 localStorage → 쿠키로 복사하는 방식
|
||||
const tokenCookie = request.cookies.get('user_token');
|
||||
if (tokenCookie && tokenCookie.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 isProtectedRoute(pathname: string): boolean {
|
||||
return AUTH_CONFIG.protectedRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
);
|
||||
}
|
||||
|
||||
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') || '';
|
||||
|
||||
// Remove locale prefix for path checking
|
||||
// 1️⃣ 로케일 제거
|
||||
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);
|
||||
|
||||
// Check if request is from a bot
|
||||
// 2️⃣ Bot Detection (기존 로직)
|
||||
const isBotRequest = isBot(userAgent);
|
||||
|
||||
// Block bots from protected paths (check both with and without locale)
|
||||
// Bot Detection: Block bots from protected paths
|
||||
if (isBotRequest && (isProtectedPath(pathname) || isProtectedPath(pathnameWithoutLocale))) {
|
||||
console.log(`[Bot Blocked] ${userAgent} attempted to access ${pathname}`);
|
||||
|
||||
// Return 403 Forbidden with appropriate message
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: 'Access Denied',
|
||||
@@ -145,16 +211,59 @@ export function middleware(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Run i18n middleware for locale detection and routing
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Add security headers to the response
|
||||
// 🔟 보안 헤더 추가
|
||||
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');
|
||||
|
||||
// Log bot access attempts (for monitoring)
|
||||
// Bot 로깅 (모니터링용)
|
||||
if (isBotRequest) {
|
||||
console.log(`[Bot Allowed] ${userAgent} accessed ${pathname}`);
|
||||
}
|
||||
@@ -172,12 +281,13 @@ export function middleware(request: NextRequest) {
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* Match all pathnames except:
|
||||
* - api routes
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public files (images, etc.)
|
||||
* - favicon.ico, robots.txt
|
||||
* - files with extensions (images, etc.)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)',
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|robots\\.txt).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user