# 인증 시스템 설계 (Laravel Sanctum + Next.js 15) ## 📋 아키텍처 개요 ### 전체 구조 ``` ┌─────────────────────────────────────────────────────────────┐ │ Next.js Frontend │ ├─────────────────────────────────────────────────────────────┤ │ Middleware (Server) │ │ ├─ Bot Detection (기존) │ │ ├─ Authentication Check (신규) │ │ │ ├─ Protected Routes 가드 │ │ │ ├─ 세션 쿠키 확인 │ │ │ └─ 인증 실패 → /login 리다이렉트 │ │ └─ i18n Routing (기존) │ ├─────────────────────────────────────────────────────────────┤ │ API Client (lib/auth/sanctum.ts) │ │ ├─ CSRF 토큰 자동 처리 │ │ ├─ HTTP-only 쿠키 포함 (credentials: 'include') │ │ ├─ 에러 인터셉터 (401 → /login) │ │ └─ 재시도 로직 │ ├─────────────────────────────────────────────────────────────┤ │ Server Auth Utils (lib/auth/server-auth.ts) │ │ ├─ getServerSession() - Server Components용 │ │ └─ 쿠키 기반 세션 검증 │ ├─────────────────────────────────────────────────────────────┤ │ Auth Context (contexts/AuthContext.tsx) │ │ ├─ 클라이언트 사이드 상태 관리 │ │ ├─ 사용자 정보 캐싱 │ │ └─ login/logout/register 함수 │ └─────────────────────────────────────────────────────────────┘ ↓ HTTP + Cookies ┌─────────────────────────────────────────────────────────────┐ │ Laravel Backend (PHP) │ ├─────────────────────────────────────────────────────────────┤ │ Sanctum Middleware │ │ └─ 세션 기반 SPA 인증 (HTTP-only 쿠키) │ ├─────────────────────────────────────────────────────────────┤ │ API Endpoints │ │ ├─ GET /sanctum/csrf-cookie (CSRF 토큰 발급) │ │ ├─ POST /api/login (로그인) │ │ ├─ POST /api/register (회원가입) │ │ ├─ POST /api/logout (로그아웃) │ │ ├─ GET /api/user (현재 사용자 정보) │ │ └─ POST /api/forgot-password (비밀번호 재설정) │ └─────────────────────────────────────────────────────────────┘ ``` ### 핵심 설계 원칙 1. **가드 컴포넌트 없이 Middleware로 일괄 처리** - 모든 인증 체크를 middleware.ts에서 처리 - 라우트별로 가드 컴포넌트 불필요 - 중복 코드 제거 2. **세션 기반 인증 (Sanctum SPA 모드)** - HTTP-only 쿠키로 세션 관리 - XSS 공격 방어 - CSRF 토큰으로 보안 강화 3. **Server Components 우선** - 서버에서 인증 체크 및 데이터 fetch - 클라이언트 JS 번들 크기 감소 - SEO 최적화 ## 🔐 인증 플로우 ### 1. 로그인 플로우 ``` ┌─────────┐ 1. /login 접속 ┌──────────────┐ │ Browser │ ───────────────────────────→│ Next.js │ └─────────┘ │ Server │ ↓ └──────────────┘ │ 2. CSRF 토큰 요청 │ GET /sanctum/csrf-cookie ↓ ┌─────────┐ ┌──────────────┐ │ Browser │ ←───────────────────────────│ Laravel │ └─────────┘ XSRF-TOKEN 쿠키 │ Backend │ ↓ └──────────────┘ │ 3. 로그인 폼 제출 │ POST /api/login │ { email, password } │ Headers: X-XSRF-TOKEN ↓ ┌─────────┐ ┌──────────────┐ │ Browser │ ←───────────────────────────│ Laravel │ └─────────┘ laravel_session 쿠키 │ Sanctum │ ↓ (HTTP-only) └──────────────┘ │ 4. 보호된 페이지 접근 │ GET /dashboard │ Cookies: laravel_session ↓ ┌─────────┐ ┌──────────────┐ │ Browser │ ←───────────────────────────│ Next.js │ └─────────┘ 페이지 렌더링 │ Middleware │ (쿠키 확인 ✓) └──────────────┘ ``` ### 2. 보호된 페이지 접근 플로우 ``` 사용자 → /dashboard 접속 ↓ Middleware 실행 ↓ ┌─────────────────┐ │ 세션 쿠키 확인? │ └─────────────────┘ ↓ Yes ↓ No ↓ ↓ ↓ 페이지 렌더링 Redirect (Server /login?redirect=/dashboard Component) ``` ### 3. 미들웨어 체크 순서 ``` Request ↓ 1. Bot Detection Check ├─ Bot → 403 Forbidden └─ Human → Continue ↓ 2. Static Files Check ├─ Static → Skip Auth └─ Dynamic → Continue ↓ 3. Public Routes Check ├─ Public → Skip Auth └─ Protected → Continue ↓ 4. Session Cookie Check ├─ Valid Session → Continue └─ No Session → Redirect /login ↓ 5. Guest Only Routes Check ├─ Authenticated + /login → Redirect /dashboard └─ Continue ↓ 6. i18n Routing ↓ Response ``` ## 📁 파일 구조 ``` /src ├─ /lib │ └─ /auth │ ├─ sanctum.ts # Sanctum API 클라이언트 │ ├─ auth-config.ts # 인증 설정 (routes, URLs) │ └─ server-auth.ts # 서버 컴포넌트용 유틸 │ ├─ /contexts │ └─ AuthContext.tsx # 클라이언트 인증 상태 관리 │ ├─ /app/[locale] │ ├─ /(auth) # 인증 관련 라우트 그룹 │ │ ├─ /login │ │ │ └─ page.tsx # 로그인 페이지 │ │ ├─ /register │ │ │ └─ page.tsx # 회원가입 페이지 │ │ └─ /forgot-password │ │ └─ page.tsx # 비밀번호 재설정 │ │ │ ├─ /(protected) # 보호된 라우트 그룹 │ │ ├─ /dashboard │ │ │ └─ page.tsx │ │ ├─ /profile │ │ │ └─ page.tsx │ │ └─ /settings │ │ └─ page.tsx │ │ │ └─ layout.tsx # AuthProvider 추가 │ ├─ /middleware.ts # 통합 미들웨어 │ └─ /.env.local # 환경 변수 ``` ## 🛠️ 핵심 구현 포인트 ### 1. 인증 설정 (lib/auth/auth-config.ts) ```typescript export const AUTH_CONFIG = { // API 엔드포인트 apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000', // 완전 공개 라우트 (인증 체크 안함) publicRoutes: [ '/', '/about', '/contact', '/terms', '/privacy', ], // 인증 필요 라우트 protectedRoutes: [ '/dashboard', '/profile', '/settings', '/admin', '/tenant', '/users', '/reports', // ... ERP 경로들 ], // 게스트 전용 (로그인 후 접근 불가) guestOnlyRoutes: [ '/login', '/register', '/forgot-password', ], // 리다이렉트 설정 redirects: { afterLogin: '/dashboard', afterLogout: '/login', unauthorized: '/login', }, }; ``` ### 2. Sanctum API 클라이언트 (lib/auth/sanctum.ts) ```typescript class SanctumClient { private baseURL: string; private csrfToken: string | null = null; constructor() { this.baseURL = AUTH_CONFIG.apiUrl; } /** * CSRF 토큰 가져오기 * 로그인/회원가입 전에 반드시 호출 */ async getCsrfToken(): Promise { await fetch(`${this.baseURL}/sanctum/csrf-cookie`, { credentials: 'include', // 쿠키 포함 }); } /** * 로그인 */ async login(email: string, password: string): Promise { await this.getCsrfToken(); const response = await fetch(`${this.baseURL}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, credentials: 'include', body: JSON.stringify({ email, password }), }); if (!response.ok) { throw new Error('Login failed'); } return await response.json(); } /** * 회원가입 */ async register(data: RegisterData): Promise { await this.getCsrfToken(); const response = await fetch(`${this.baseURL}/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, credentials: 'include', body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); throw error; } return await response.json(); } /** * 로그아웃 */ async logout(): Promise { await fetch(`${this.baseURL}/api/logout`, { method: 'POST', credentials: 'include', }); } /** * 현재 사용자 정보 */ async getCurrentUser(): Promise { try { const response = await fetch(`${this.baseURL}/api/user`, { credentials: 'include', }); if (response.ok) { return await response.json(); } return null; } catch { return null; } } } export const sanctumClient = new SanctumClient(); ``` **핵심 포인트**: - `credentials: 'include'` - 모든 요청에 쿠키 포함 - CSRF 토큰은 쿠키로 자동 관리 (Laravel이 처리) - 에러 처리 일관성 ### 3. 서버 인증 유틸 (lib/auth/server-auth.ts) ```typescript import { cookies } from 'next/headers'; import { AUTH_CONFIG } from './auth-config'; /** * 서버 컴포넌트에서 세션 가져오기 */ export async function getServerSession(): Promise { const cookieStore = await cookies(); const sessionCookie = cookieStore.get('laravel_session'); if (!sessionCookie) { return null; } try { const response = await fetch(`${AUTH_CONFIG.apiUrl}/api/user`, { headers: { Cookie: `laravel_session=${sessionCookie.value}`, Accept: 'application/json', }, cache: 'no-store', // 항상 최신 데이터 }); if (response.ok) { return await response.json(); } } catch (error) { console.error('Failed to get server session:', error); } return null; } /** * 서버 컴포넌트에서 인증 필요 */ export async function requireAuth(): Promise { const user = await getServerSession(); if (!user) { redirect('/login'); } return user; } ``` **사용 예시**: ```typescript // app/(protected)/dashboard/page.tsx import { requireAuth } from '@/lib/auth/server-auth'; export default async function DashboardPage() { const user = await requireAuth(); // 인증 필요 return
Welcome {user.name}
; } ``` ### 4. Middleware 통합 (middleware.ts) ```typescript import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import createIntlMiddleware from 'next-intl/middleware'; import { locales, defaultLocale } from '@/i18n/config'; import { AUTH_CONFIG } from '@/lib/auth/auth-config'; const intlMiddleware = createIntlMiddleware({ locales, defaultLocale, localePrefix: 'as-needed', }); // 경로가 보호된 라우트인지 확인 function isProtectedRoute(pathname: string): boolean { return AUTH_CONFIG.protectedRoutes.some(route => pathname.startsWith(route) ); } // 경로가 공개 라우트인지 확인 function isPublicRoute(pathname: string): boolean { return AUTH_CONFIG.publicRoutes.some(route => pathname === route || pathname.startsWith(route) ); } // 경로가 게스트 전용인지 확인 function isGuestOnlyRoute(pathname: string): boolean { return AUTH_CONFIG.guestOnlyRoutes.some(route => pathname === route || pathname.startsWith(route) ); } // 로케일 제거 function stripLocale(pathname: string): string { for (const locale of locales) { if (pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`) { return pathname.slice(`/${locale}`.length) || '/'; } } return pathname; } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // 1. Bot Detection (기존 로직) // ... bot check code ... // 2. 정적 파일 제외 if ( pathname.includes('/_next/') || pathname.includes('/api/') || pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/) ) { return intlMiddleware(request); } // 3. 로케일 제거하여 경로 체크 const pathnameWithoutLocale = stripLocale(pathname); // 4. 세션 쿠키 확인 const sessionCookie = request.cookies.get('laravel_session'); const isAuthenticated = !!sessionCookie; // 5. 보호된 라우트 체크 if (isProtectedRoute(pathnameWithoutLocale) && !isAuthenticated) { const url = new URL('/login', request.url); url.searchParams.set('redirect', pathname); return NextResponse.redirect(url); } // 6. 게스트 전용 라우트 체크 (이미 로그인한 경우) if (isGuestOnlyRoute(pathnameWithoutLocale) && isAuthenticated) { return NextResponse.redirect( new URL(AUTH_CONFIG.redirects.afterLogin, request.url) ); } // 7. i18n 미들웨어 실행 return intlMiddleware(request); } export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', ], }; ``` **장점**: - 단일 진입점에서 모든 인증 처리 - 가드 컴포넌트 불필요 - 중복 코드 제거 - 성능 최적화 (서버 사이드 체크) ### 5. Auth Context (contexts/AuthContext.tsx) ```typescript 'use client'; import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { sanctumClient } from '@/lib/auth/sanctum'; import { useRouter } from 'next/navigation'; import { AUTH_CONFIG } from '@/lib/auth/auth-config'; interface User { id: number; name: string; email: string; } interface AuthContextType { user: User | null; loading: boolean; login: (email: string, password: string) => Promise; register: (data: RegisterData) => Promise; logout: () => Promise; refreshUser: () => Promise; } const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const router = useRouter(); // 초기 로드 시 사용자 정보 가져오기 useEffect(() => { sanctumClient.getCurrentUser() .then(setUser) .catch(() => setUser(null)) .finally(() => setLoading(false)); }, []); const login = async (email: string, password: string) => { const user = await sanctumClient.login(email, password); setUser(user); router.push(AUTH_CONFIG.redirects.afterLogin); }; const register = async (data: RegisterData) => { const user = await sanctumClient.register(data); setUser(user); router.push(AUTH_CONFIG.redirects.afterLogin); }; const logout = async () => { await sanctumClient.logout(); setUser(null); router.push(AUTH_CONFIG.redirects.afterLogout); }; const refreshUser = async () => { const user = await sanctumClient.getCurrentUser(); setUser(user); }; return ( {children} ); } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within AuthProvider'); } return context; } ``` **사용 예시**: ```typescript // components/LoginForm.tsx 'use client'; import { useAuth } from '@/contexts/AuthContext'; export function LoginForm() { const { login, loading } = useAuth(); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); await login(email, password); }; return
...
; } ``` ## 🔒 보안 고려사항 ### 1. CSRF 보호 **Next.js 측**: - 모든 상태 변경 요청 전에 `getCsrfToken()` 호출 - Laravel이 XSRF-TOKEN 쿠키 발급 - 브라우저가 자동으로 헤더에 포함 **Laravel 측** (백엔드 담당): ```php // config/sanctum.php 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')), ``` ### 2. 쿠키 보안 설정 **Laravel 측** (백엔드 담당): ```php // config/session.php 'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only 'http_only' => true, // JavaScript 접근 불가 'same_site' => 'lax', // CSRF 방지 ``` ### 3. CORS 설정 **Laravel 측** (백엔드 담당): ```php // config/cors.php 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'supports_credentials' => true, 'allowed_origins' => [env('FRONTEND_URL')], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, ``` ### 4. 환경 변수 ```env # .env.local (Next.js) NEXT_PUBLIC_API_URL=http://localhost:8000 NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 ``` ```env # .env (Laravel) FRONTEND_URL=http://localhost:3000 SANCTUM_STATEFUL_DOMAINS=localhost:3000 SESSION_DOMAIN=localhost SESSION_SECURE_COOKIE=false # 개발: false, 프로덕션: true ``` ### 5. XSS 방어 - HTTP-only 쿠키 사용 (JavaScript로 접근 불가) - 사용자 입력 sanitization (React가 기본으로 처리) - CSP 헤더 설정 (Next.js 설정) ### 6. Rate Limiting **Laravel 측** (백엔드 담당): ```php // routes/api.php Route::middleware(['throttle:login'])->group(function () { Route::post('/login', [AuthController::class, 'login']); }); // app/Http/Kernel.php 'login' => 'throttle:5,1', // 1분에 5번 ``` ## 📊 에러 처리 전략 ### 1. 에러 타입별 처리 ```typescript // lib/auth/sanctum.ts class ApiError extends Error { constructor( public status: number, public code: string, message: string, public errors?: Record ) { super(message); } } async function handleResponse(response: Response): Promise { if (response.ok) { return await response.json(); } const data = await response.json().catch(() => ({})); switch (response.status) { case 401: // 인증 실패 - 로그인 페이지로 window.location.href = '/login'; throw new ApiError(401, 'UNAUTHORIZED', 'Please login'); case 403: // 권한 없음 throw new ApiError(403, 'FORBIDDEN', 'Access denied'); case 422: // Validation 에러 throw new ApiError( 422, 'VALIDATION_ERROR', data.message || 'Validation failed', data.errors ); case 429: // Rate limit throw new ApiError(429, 'RATE_LIMIT', 'Too many requests'); case 500: // 서버 에러 throw new ApiError(500, 'SERVER_ERROR', 'Server error occurred'); default: throw new ApiError( response.status, 'UNKNOWN_ERROR', data.message || 'An error occurred' ); } } ``` ### 2. UI 에러 표시 ```typescript // components/LoginForm.tsx const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); try { await login(email, password); } catch (err) { if (err instanceof ApiError) { if (err.status === 422 && err.errors) { setFieldErrors(err.errors); } else { setError(err.message); } } else { setError('An unexpected error occurred'); } } ``` ### 3. 네트워크 에러 처리 ```typescript // 재시도 로직 async function fetchWithRetry( url: string, options: RequestInit, retries = 3 ): Promise { try { return await fetch(url, options); } catch (error) { if (retries > 0) { await new Promise(resolve => setTimeout(resolve, 1000)); return fetchWithRetry(url, options, retries - 1); } throw new Error('Network error. Please check your connection.'); } } ``` ## 🚀 성능 최적화 ### 1. Middleware 최적화 ```typescript // 정적 파일 조기 리턴 if (pathname.includes('/_next/') || pathname.match(/\.(ico|png|jpg)$/)) { return NextResponse.next(); } // 쿠키만 확인, API 호출 안함 const isAuthenticated = !!request.cookies.get('laravel_session'); ``` ### 2. 클라이언트 캐싱 ```typescript // AuthContext에서 사용자 정보 캐싱 // 페이지 이동 시 재요청 안함 const [user, setUser] = useState(null); ``` ### 3. Server Components 활용 ```typescript // 서버에서 데이터 fetch export default async function DashboardPage() { const user = await getServerSession(); const data = await fetchDashboardData(user.id); return ; } ``` ### 4. Parallel Data Fetching ```typescript // 병렬 데이터 요청 const [user, stats, notifications] = await Promise.all([ getServerSession(), fetchStats(), fetchNotifications(), ]); ``` ## 📝 구현 단계 ### Phase 1: 기본 인프라 설정 - [ ] 1.1 인증 설정 파일 생성 (`auth-config.ts`) - [ ] 1.2 Sanctum API 클라이언트 구현 (`sanctum.ts`) - [ ] 1.3 서버 인증 유틸리티 (`server-auth.ts`) - [ ] 1.4 타입 정의 (`types/auth.ts`) ### Phase 2: Middleware 통합 - [ ] 2.1 현재 middleware.ts 백업 - [ ] 2.2 인증 로직 추가 - [ ] 2.3 라우트 보호 로직 구현 - [ ] 2.4 리다이렉트 로직 구현 ### Phase 3: 클라이언트 상태 관리 - [ ] 3.1 AuthContext 생성 - [ ] 3.2 AuthProvider를 layout.tsx에 추가 - [ ] 3.3 useAuth 훅 테스트 ### Phase 4: 인증 페이지 구현 - [ ] 4.1 로그인 페이지 (`/login`) - [ ] 4.2 회원가입 페이지 (`/register`) - [ ] 4.3 비밀번호 재설정 (`/forgot-password`) - [ ] 4.4 폼 Validation (react-hook-form + zod) ### Phase 5: 보호된 페이지 구현 - [ ] 5.1 대시보드 페이지 (`/dashboard`) - [ ] 5.2 프로필 페이지 (`/profile`) - [ ] 5.3 설정 페이지 (`/settings`) ### Phase 6: 테스트 및 최적화 - [ ] 6.1 인증 플로우 테스트 - [ ] 6.2 에러 케이스 테스트 - [ ] 6.3 성능 측정 및 최적화 - [ ] 6.4 보안 점검 ## 🤔 검토 포인트 ### 1. 설계 관련 질문 - **Middleware 중심 설계가 적합한가?** - 장점: 중앙 집중식 관리, 중복 코드 제거 - 단점: 복잡도 증가 가능성 - **세션 쿠키만으로 충분한가?** - Sanctum SPA 모드는 세션 쿠키로 충분 - API 토큰 모드가 필요한 경우 추가 구현 필요 - **Server Components vs Client Components 비율은?** - 인증 체크: Server (Middleware + getServerSession) - 상태 관리: Client (AuthContext) - UI: 혼합 (페이지는 Server, 인터랙션은 Client) ### 2. 구현 우선순위 **높음 (즉시 필요)**: - auth-config.ts - sanctum.ts - middleware.ts 업데이트 - 로그인 페이지 **중간 (빠르게 필요)**: - AuthContext - 회원가입 페이지 - 대시보드 기본 구조 **낮음 (나중에)**: - 비밀번호 재설정 - 프로필 관리 - 고급 보안 기능 ### 3. Laravel 백엔드 체크리스트 백엔드 개발자가 확인해야 할 사항: ```php # 1. Sanctum 설치 및 설정 composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" # 2. config/sanctum.php 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost:3000')), # 3. config/cors.php 'supports_credentials' => true, 'allowed_origins' => [env('FRONTEND_URL')], # 4. API Routes Route::post('/login', [AuthController::class, 'login']); Route::post('/register', [AuthController::class, 'register']); Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum'); # 5. CORS 미들웨어 app/Http/Kernel.php에 \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class 추가 ``` ## 🎯 다음 액션 이 설계 문서를 검토 후: 1. **승인 시**: Phase 1부터 순차적으로 구현 시작 2. **수정 필요 시**: 피드백 반영 후 재설계 3. **질문 사항**: 불명확한 부분 명확화 질문이나 수정 사항이 있으면 알려주세요!