491 lines
14 KiB
Markdown
491 lines
14 KiB
Markdown
|
|
# 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<User> {
|
||
|
|
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<User> {
|
||
|
|
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<void> {
|
||
|
|
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<void>;
|
||
|
|
logout: () => Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||
|
|
|
||
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
|
|
const [user, setUser] = useState<User | null>(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 (
|
||
|
|
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||
|
|
{children}
|
||
|
|
</AuthContext.Provider>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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시간)
|
||
|
|
|
||
|
|
**준비되면 바로 시작합니다!** 🎯
|