# JWT + Cookie + Middleware 인증 설계 (최종) **확정된 API 정보:** - 인증 방식: Bearer Token (JWT) - 로그인: `POST /api/v1/login` - 응답: `{ token: "xxx" }` - Token 저장: **쿠키** (Middleware 접근 가능) ## ✅ 핵심 발견 **JWT도 쿠키에 저장하면 Middleware에서 처리 가능합니다!** ```typescript // middleware.ts에서 JWT 토큰 쿠키 접근 const authToken = request.cookies.get('auth_token'); // ✅ 가능! if (!authToken) { redirect('/login'); } ``` 따라서 **기존 Middleware 설계를 거의 그대로 사용**할 수 있습니다. --- ## 📋 아키텍처 (기존과 동일) ``` ┌─────────────────────────────────────────────────────────────┐ │ Next.js Frontend │ ├─────────────────────────────────────────────────────────────┤ │ Middleware (Server) │ │ ├─ Bot Detection (기존) │ │ ├─ Authentication Check (신규) │ │ │ ├─ JWT Token 쿠키 확인 │ │ │ └─ 없으면 /login 리다이렉트 │ │ └─ i18n Routing (기존) │ ├─────────────────────────────────────────────────────────────┤ │ JWT Client (lib/auth/jwt-client.ts) │ │ ├─ Token을 쿠키에 저장 │ │ ├─ API 호출 시 Authorization 헤더 추가 │ │ └─ 401 응답 시 자동 로그아웃 │ ├─────────────────────────────────────────────────────────────┤ │ Auth Context (contexts/AuthContext.tsx) │ │ ├─ 사용자 정보 관리 │ │ └─ login/logout 함수 │ └─────────────────────────────────────────────────────────────┘ ↓ HTTP + Cookie + Authorization ┌─────────────────────────────────────────────────────────────┐ │ Laravel Backend │ ├─────────────────────────────────────────────────────────────┤ │ JWT Middleware │ │ └─ Bearer Token 검증 │ ├─────────────────────────────────────────────────────────────┤ │ API Endpoints │ │ ├─ POST /api/v1/login → { token: "xxx" } │ │ ├─ POST /api/v1/register │ │ ├─ GET /api/v1/user │ │ └─ POST /api/v1/logout │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 🔐 인증 플로우 ### 1. 로그인 ``` 1. POST /api/v1/login → { token: "eyJhbGci..." } 2. Token을 쿠키에 저장 document.cookie = 'auth_token=xxx; Secure; SameSite=Strict' 3. /dashboard 리다이렉트 4. Middleware가 쿠키 확인 ✓ 5. 페이지 렌더링 ``` ### 2. API 호출 ``` 1. 쿠키에서 Token 읽기 2. Authorization 헤더에 추가 Authorization: Bearer xxx 3. Laravel이 JWT 검증 4. 데이터 반환 ``` ### 3. 보호된 페이지 접근 ``` 사용자 → /dashboard ↓ Middleware 실행 ↓ auth_token 쿠키 확인 ↓ 있음 → 페이지 표시 없음 → /login 리다이렉트 ``` --- ## 🛠️ 핵심 구현 ### 1. Token 저장 (lib/auth/token-storage.ts) ```typescript export const tokenStorage = { /** * JWT를 쿠키에 저장 * - Middleware에서 접근 가능 * - Secure + SameSite로 보안 강화 */ set(token: string): void { const maxAge = 86400; // 24시간 document.cookie = `auth_token=${token}; path=/; max-age=${maxAge}; SameSite=Strict; Secure`; }, /** * 쿠키에서 Token 읽기 * - 클라이언트에서만 사용 */ get(): string | null { if (typeof window === 'undefined') return null; const match = document.cookie.match(/auth_token=([^;]+)/); return match ? match[1] : null; }, /** * Token 삭제 */ remove(): void { document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; } }; ``` ### 2. JWT Client (lib/auth/jwt-client.ts) ```typescript import { tokenStorage } from './token-storage'; class JwtClient { private baseURL = 'https://api.5130.co.kr'; /** * 로그인 */ async login(email: string, password: string): Promise { const response = await fetch(`${this.baseURL}/api/v1/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) { throw new Error('Login failed'); } const { token } = await response.json(); // ✅ Token을 쿠키에 저장 tokenStorage.set(token); // 사용자 정보 조회 return await this.getCurrentUser(); } /** * 현재 사용자 정보 */ async getCurrentUser(): Promise { const token = tokenStorage.get(); if (!token) { throw new Error('No token'); } const response = await fetch(`${this.baseURL}/api/v1/user`, { headers: { 'Authorization': `Bearer ${token}`, // ✅ Authorization 헤더 }, }); if (response.status === 401) { tokenStorage.remove(); throw new Error('Unauthorized'); } return await response.json(); } /** * 로그아웃 */ async logout(): Promise { const token = tokenStorage.get(); if (token) { await fetch(`${this.baseURL}/api/v1/logout`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, }, }); } // ✅ 쿠키 삭제 tokenStorage.remove(); } } export const jwtClient = new JwtClient(); ``` ### 3. 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'; const intlMiddleware = createIntlMiddleware({ locales, defaultLocale, localePrefix: 'as-needed', }); // 보호된 라우트 const PROTECTED_ROUTES = [ '/dashboard', '/profile', '/settings', '/admin', '/tenant', '/users', '/reports', ]; // 공개 라우트 const PUBLIC_ROUTES = [ '/', '/login', '/register', '/about', '/contact', ]; function isProtectedRoute(pathname: string): boolean { return PROTECTED_ROUTES.some(route => pathname.startsWith(route)); } function isPublicRoute(pathname: string): boolean { return PUBLIC_ROUTES.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. ✅ JWT Token 쿠키 확인 const authToken = request.cookies.get('auth_token'); const isAuthenticated = !!authToken; // 5. 보호된 라우트 체크 if (isProtectedRoute(pathnameWithoutLocale) && !isAuthenticated) { const url = new URL('/login', request.url); url.searchParams.set('redirect', pathname); return NextResponse.redirect(url); } // 6. 게스트 전용 라우트 (이미 로그인한 경우) if ( (pathnameWithoutLocale === '/login' || pathnameWithoutLocale === '/register') && isAuthenticated ) { return NextResponse.redirect(new URL('/dashboard', request.url)); } // 7. i18n 미들웨어 return intlMiddleware(request); } export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', ], }; ``` **변경 사항:** ```diff - const sessionCookie = request.cookies.get('laravel_session'); + const authToken = request.cookies.get('auth_token'); ``` 거의 동일합니다! ### 4. Auth Context (contexts/AuthContext.tsx) ```typescript 'use client'; import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { jwtClient } from '@/lib/auth/jwt-client'; import { useRouter } from 'next/navigation'; interface User { id: number; name: string; email: string; } interface AuthContextType { user: User | null; loading: boolean; login: (email: string, password: string) => Promise; logout: () => 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(() => { jwtClient.getCurrentUser() .then(setUser) .catch(() => setUser(null)) .finally(() => setLoading(false)); }, []); const login = async (email: string, password: string) => { const user = await jwtClient.login(email, password); setUser(user); router.push('/dashboard'); }; const logout = async () => { await jwtClient.logout(); setUser(null); router.push('/login'); }; return ( {children} ); } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within AuthProvider'); } return context; } ``` --- ## 📊 세션 쿠키 vs JWT 쿠키 비교 | 항목 | 세션 쿠키 (Sanctum) | JWT 쿠키 (현재) | |------|---------------------|------------------| | **쿠키 이름** | `laravel_session` | `auth_token` | | **Middleware 접근** | ✅ 가능 | ✅ 가능 | | **인증 체크** | 쿠키 존재 확인 | 쿠키 존재 확인 | | **API 호출** | 쿠키 자동 포함 | Authorization 헤더 | | **CSRF 토큰** | ✅ 필요 | ❌ 불필요 | | **서버 상태** | Stateful (세션 저장) | Stateless | | **보안** | HTTP-only 가능 | Secure + SameSite | | **구현 복잡도** | 동일 | 동일 | **결론:** Middleware 관점에서는 거의 동일합니다! --- ## 🎯 구현 순서 ### Phase 1: 기본 인프라 (30분) - [x] auth-config.ts - [ ] token-storage.ts - [ ] jwt-client.ts - [ ] types/auth.ts ### Phase 2: Middleware 통합 (20분) - [ ] middleware.ts 업데이트 - JWT 토큰 쿠키 체크 - Protected routes 가드 ### Phase 3: Auth Context (20분) - [ ] AuthContext.tsx - [ ] layout.tsx에 AuthProvider 추가 ### Phase 4: 로그인 페이지 (40분) - [ ] /login/page.tsx - [ ] LoginForm 컴포넌트 - [ ] Form validation (react-hook-form + zod) ### Phase 5: 테스트 (30분) - [ ] 로그인 → 대시보드 - [ ] 비로그인 → 대시보드 → /login 튕김 - [ ] 로그아웃 → 다시 튕김 **총 소요시간: 약 2시간 20분** --- ## ✅ 최종 정리 ### 핵심 포인트 1. **JWT를 쿠키에 저장** → Middleware 접근 가능 2. **기존 Middleware 설계 유지** → 가드 컴포넌트 불필요 3. **차이점은 미미함:** - 쿠키 이름: `laravel_session` → `auth_token` - CSRF 토큰 불필요 - API 호출 시 Authorization 헤더 추가 ### 장점 - ✅ Middleware에서 서버사이드 인증 체크 - ✅ 클라이언트 가드 컴포넌트 불필요 - ✅ 중복 코드 제거 - ✅ 기존 설계(authentication-design.md) 거의 그대로 사용 ### 변경 사항 **최소한의 변경만 필요:** ```typescript // 1. Token 저장: 쿠키 사용 tokenStorage.set(token); // 2. Middleware: 쿠키 이름만 변경 const authToken = request.cookies.get('auth_token'); // 3. API 호출: Authorization 헤더 추가 headers: { 'Authorization': `Bearer ${token}` } // 4. CSRF 토큰: 제거 // getCsrfToken() 불필요 ``` --- ## 🚀 다음 단계 1. ✅ 설계 확정 완료 2. ⏳ 디자인 컴포넌트 대기 3. ⏳ 백엔드 API 엔드포인트 확인 - POST /api/v1/register - GET /api/v1/user - POST /api/v1/logout 4. 🚀 구현 시작 (2-3시간) **준비되면 바로 시작합니다!** 🎯