377 lines
11 KiB
Markdown
377 lines
11 KiB
Markdown
|
|
# HttpOnly Cookie Implementation - Security Upgrade
|
||
|
|
|
||
|
|
## 보안 개선 개요
|
||
|
|
|
||
|
|
### 이전 방식 (보안 위험: 🔴 7.6/10)
|
||
|
|
```typescript
|
||
|
|
// ❌ XSS 취약점: JavaScript로 토큰 접근 가능
|
||
|
|
localStorage.setItem('user_token', token);
|
||
|
|
document.cookie = `user_token=${token}; SameSite=Lax`; // Non-HttpOnly
|
||
|
|
```
|
||
|
|
|
||
|
|
**취약점:**
|
||
|
|
- localStorage는 모든 JavaScript에서 접근 가능
|
||
|
|
- XSS 공격 시 토큰 탈취 가능
|
||
|
|
- 쿠키가 HttpOnly가 아니어서 `document.cookie`로 읽기 가능
|
||
|
|
|
||
|
|
### 새로운 방식 (보안 위험: 🟢 2.8/10)
|
||
|
|
```typescript
|
||
|
|
// ✅ XSS 방어: JavaScript로 토큰 접근 불가능
|
||
|
|
Set-Cookie: user_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800
|
||
|
|
```
|
||
|
|
|
||
|
|
**보안 개선:**
|
||
|
|
- HttpOnly 쿠키: JavaScript에서 완전히 차단
|
||
|
|
- Secure: HTTPS 연결에서만 전송
|
||
|
|
- SameSite=Strict: CSRF 공격 방어
|
||
|
|
- 토큰이 클라이언트 JavaScript에 노출되지 않음
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 구현 세부사항
|
||
|
|
|
||
|
|
### 1. 로그인 프록시 (`src/app/api/auth/login/route.ts`)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export async function POST(request: NextRequest) {
|
||
|
|
const { user_id, user_pwd } = await request.json();
|
||
|
|
|
||
|
|
// PHP 백엔드 API 호출
|
||
|
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||
|
|
},
|
||
|
|
body: JSON.stringify({ user_id, user_pwd }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
// HttpOnly 쿠키 설정 (JavaScript 접근 불가)
|
||
|
|
const cookieOptions = [
|
||
|
|
`user_token=${data.user_token}`,
|
||
|
|
'HttpOnly', // ✅ JavaScript 접근 차단
|
||
|
|
'Secure', // ✅ HTTPS 전용
|
||
|
|
'SameSite=Strict', // ✅ CSRF 방어
|
||
|
|
'Path=/',
|
||
|
|
'Max-Age=604800', // 7일
|
||
|
|
].join('; ');
|
||
|
|
|
||
|
|
// 응답: 토큰은 제외하고 사용자 정보만 반환
|
||
|
|
return NextResponse.json(
|
||
|
|
{
|
||
|
|
message: data.message,
|
||
|
|
user: data.user,
|
||
|
|
tenant: data.tenant,
|
||
|
|
menus: data.menus,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
status: 200,
|
||
|
|
headers: { 'Set-Cookie': cookieOptions },
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. 로그아웃 프록시 (`src/app/api/auth/logout/route.ts`)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export async function POST(request: NextRequest) {
|
||
|
|
// HttpOnly 쿠키에서 토큰 읽기
|
||
|
|
const token = request.cookies.get('user_token')?.value;
|
||
|
|
|
||
|
|
if (token) {
|
||
|
|
// PHP 백엔드 로그아웃 API 호출
|
||
|
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/logout`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Authorization': `Bearer ${token}`,
|
||
|
|
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// HttpOnly 쿠키 삭제
|
||
|
|
const cookieOptions = [
|
||
|
|
'user_token=',
|
||
|
|
'HttpOnly',
|
||
|
|
'Secure',
|
||
|
|
'SameSite=Strict',
|
||
|
|
'Path=/',
|
||
|
|
'Max-Age=0', // 즉시 삭제
|
||
|
|
].join('; ');
|
||
|
|
|
||
|
|
return NextResponse.json(
|
||
|
|
{ message: 'Logged out successfully' },
|
||
|
|
{ status: 200, headers: { 'Set-Cookie': cookieOptions } }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. 클라이언트 로그인 (`src/components/auth/LoginPage.tsx`)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const handleLogin = async () => {
|
||
|
|
try {
|
||
|
|
// ✅ Next.js API Route로 프록시
|
||
|
|
const response = await fetch('/api/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
user_id: userId,
|
||
|
|
user_pwd: password,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
console.log('✅ 로그인 성공:', data.message);
|
||
|
|
console.log('📦 사용자 정보:', data.user);
|
||
|
|
console.log('🔐 토큰은 안전한 HttpOnly 쿠키에 저장됨 (JavaScript 접근 불가)');
|
||
|
|
|
||
|
|
// 대시보드로 이동
|
||
|
|
router.push("/dashboard");
|
||
|
|
} catch (err: any) {
|
||
|
|
console.error('❌ 로그인 실패:', err);
|
||
|
|
setError(err.message || t('invalidCredentials'));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. 클라이언트 로그아웃 (`src/app/[locale]/dashboard/page.tsx`)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const handleLogout = async () => {
|
||
|
|
try {
|
||
|
|
// ✅ Next.js API Route로 프록시
|
||
|
|
const response = await fetch('/api/auth/logout', {
|
||
|
|
method: 'POST',
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
console.log('✅ 로그아웃 완료: HttpOnly 쿠키 삭제됨');
|
||
|
|
}
|
||
|
|
|
||
|
|
router.push('/login');
|
||
|
|
} catch (error) {
|
||
|
|
console.error('로그아웃 처리 중 오류:', error);
|
||
|
|
router.push('/login');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. 미들웨어 인증 확인 (`src/middleware.ts`)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
function checkAuthentication(request: NextRequest): {
|
||
|
|
isAuthenticated: boolean;
|
||
|
|
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
|
||
|
|
} {
|
||
|
|
// 1. Bearer Token 확인 (HttpOnly 쿠키에서)
|
||
|
|
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' };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { isAuthenticated: false, authMode: null };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 테스트 가이드
|
||
|
|
|
||
|
|
### 1. 로그인 테스트
|
||
|
|
|
||
|
|
**단계:**
|
||
|
|
1. 브라우저에서 `http://localhost:3000/login` 접속
|
||
|
|
2. 로그인 정보 입력:
|
||
|
|
- User ID: `zomking`
|
||
|
|
- Password: 테스트 비밀번호
|
||
|
|
3. 로그인 버튼 클릭
|
||
|
|
|
||
|
|
**예상 결과:**
|
||
|
|
- ✅ 대시보드로 리다이렉트
|
||
|
|
- ✅ 브라우저 개발자 도구 → Application → Cookies에서 `user_token` 확인
|
||
|
|
- ✅ `user_token` 쿠키의 HttpOnly 플래그 확인 (체크되어 있어야 함)
|
||
|
|
- ✅ 콘솔에 "로그인 성공" 메시지 출력
|
||
|
|
|
||
|
|
**HttpOnly 쿠키 확인 방법:**
|
||
|
|
```javascript
|
||
|
|
// 브라우저 콘솔에서 실행
|
||
|
|
console.log(document.cookie);
|
||
|
|
// 결과: user_token이 보이지 않아야 함 (HttpOnly로 차단됨)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. 인증 상태 확인 테스트
|
||
|
|
|
||
|
|
**단계:**
|
||
|
|
1. 로그인 상태에서 주소창에 `http://localhost:3000/dashboard` 직접 입력
|
||
|
|
2. 페이지 새로고침 (F5)
|
||
|
|
|
||
|
|
**예상 결과:**
|
||
|
|
- ✅ 대시보드 페이지 정상 표시
|
||
|
|
- ✅ 로그인 페이지로 리다이렉트되지 않음
|
||
|
|
- ✅ 서버 터미널에 "[Auth Check] Token found in cookie" 로그 출력
|
||
|
|
|
||
|
|
### 3. 비로그인 상태 차단 테스트
|
||
|
|
|
||
|
|
**단계:**
|
||
|
|
1. 로그아웃 버튼 클릭 또는 쿠키 수동 삭제
|
||
|
|
2. 주소창에 `http://localhost:3000/dashboard` 직접 입력
|
||
|
|
|
||
|
|
**예상 결과:**
|
||
|
|
- ✅ 로그인 페이지로 자동 리다이렉트
|
||
|
|
- ✅ URL에 `?redirect=/dashboard` 파라미터 포함
|
||
|
|
- ✅ 서버 터미널에 "[Auth Required] Redirecting to /login" 로그 출력
|
||
|
|
|
||
|
|
### 4. 로그아웃 테스트
|
||
|
|
|
||
|
|
**단계:**
|
||
|
|
1. 로그인 상태에서 대시보드의 "Logout" 버튼 클릭
|
||
|
|
|
||
|
|
**예상 결과:**
|
||
|
|
- ✅ 로그인 페이지로 리다이렉트
|
||
|
|
- ✅ 브라우저 개발자 도구 → Cookies에서 `user_token` 쿠키 삭제됨
|
||
|
|
- ✅ 콘솔에 "로그아웃 완료: HttpOnly 쿠키 삭제됨" 메시지 출력
|
||
|
|
- ✅ 다시 `/dashboard` 접근 시 로그인 페이지로 리다이렉트
|
||
|
|
|
||
|
|
### 5. XSS 방어 확인 (보안 테스트)
|
||
|
|
|
||
|
|
**단계:**
|
||
|
|
1. 로그인 상태에서 브라우저 콘솔 열기
|
||
|
|
2. 다음 코드 실행:
|
||
|
|
```javascript
|
||
|
|
// localStorage 토큰 읽기 시도
|
||
|
|
console.log('localStorage token:', localStorage.getItem('user_token'));
|
||
|
|
// 결과: null (토큰이 localStorage에 없음)
|
||
|
|
|
||
|
|
// 쿠키 토큰 읽기 시도
|
||
|
|
console.log('cookie token:', document.cookie);
|
||
|
|
// 결과: user_token이 보이지 않음 (HttpOnly로 차단됨)
|
||
|
|
```
|
||
|
|
|
||
|
|
**예상 결과:**
|
||
|
|
- ✅ `localStorage.getItem('user_token')` → `null`
|
||
|
|
- ✅ `document.cookie` → `user_token`이 포함되지 않음
|
||
|
|
- ✅ JavaScript로 토큰 접근 완전히 차단 확인
|
||
|
|
|
||
|
|
### 6. 서버 터미널 로그 확인
|
||
|
|
|
||
|
|
**로그인 시:**
|
||
|
|
```
|
||
|
|
✅ Login successful - Token stored in HttpOnly cookie
|
||
|
|
```
|
||
|
|
|
||
|
|
**미들웨어 실행 시:**
|
||
|
|
```
|
||
|
|
[Auth Check] Token found in cookie
|
||
|
|
[Auth Check] User authenticated with bearer mode
|
||
|
|
```
|
||
|
|
|
||
|
|
**로그아웃 시:**
|
||
|
|
```
|
||
|
|
✅ Backend logout API called successfully
|
||
|
|
✅ Logout complete - HttpOnly cookie cleared
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 보안 비교표
|
||
|
|
|
||
|
|
| 항목 | 이전 방식 (localStorage) | 새로운 방식 (HttpOnly Cookie) |
|
||
|
|
|------|------------------------|------------------------------|
|
||
|
|
| **XSS 공격** | 🔴 취약 (7.6/10) | 🟢 방어 (2.8/10) |
|
||
|
|
| **JavaScript 접근** | ❌ 가능 (`localStorage.getItem()`) | ✅ 차단 (HttpOnly) |
|
||
|
|
| **document.cookie 접근** | ❌ 가능 | ✅ 차단 (HttpOnly) |
|
||
|
|
| **CSRF 방어** | ⚠️ 부분적 (SameSite=Lax) | ✅ 강화 (SameSite=Strict) |
|
||
|
|
| **HTTPS 강제** | ❌ 없음 | ✅ Secure 플래그 |
|
||
|
|
| **토큰 노출** | ❌ 클라이언트에 노출 | ✅ 클라이언트에서 숨김 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 삭제된 파일
|
||
|
|
|
||
|
|
다음 파일들은 더 이상 필요하지 않아 삭제되었습니다:
|
||
|
|
|
||
|
|
1. `src/lib/api/auth/sanctum-client.ts` - 직접 PHP API 호출 및 localStorage 사용
|
||
|
|
2. `src/lib/api/auth/token-storage.ts` - localStorage 기반 토큰 저장 관리
|
||
|
|
|
||
|
|
**이유:**
|
||
|
|
- HttpOnly 쿠키 방식으로 전환하면서 localStorage 사용 불필요
|
||
|
|
- Next.js Route Handlers가 PHP API 프록시 역할 수행
|
||
|
|
- 토큰은 서버 측에서만 처리 (클라이언트 코드에서 토큰 관리 불필요)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 환경 변수
|
||
|
|
|
||
|
|
`.env.local` 파일에 필요한 환경 변수:
|
||
|
|
|
||
|
|
```env
|
||
|
|
NEXT_PUBLIC_API_URL=https://api.5130.co.kr
|
||
|
|
NEXT_PUBLIC_API_KEY=42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a
|
||
|
|
NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000
|
||
|
|
NEXT_PUBLIC_AUTH_MODE=sanctum
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 다음 보안 개선 단계 (향후 계획)
|
||
|
|
|
||
|
|
### Option 2: Backend Session (더 높은 보안)
|
||
|
|
- PHP Laravel에서 세션 기반 인증으로 전환
|
||
|
|
- 프론트엔드는 세션 ID만 관리
|
||
|
|
- 보안 위험: 🟢 1.5/10
|
||
|
|
|
||
|
|
### Option 3: BFF Pattern (엔터프라이즈급)
|
||
|
|
- Backend For Frontend 패턴 구현
|
||
|
|
- Next.js API Routes가 모든 인증 로직 담당
|
||
|
|
- PHP API는 내부 API로만 사용
|
||
|
|
- 보안 위험: 🟢 1.2/10
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 트러블슈팅
|
||
|
|
|
||
|
|
### 문제: 쿠키가 설정되지 않음
|
||
|
|
**원인:** Secure 플래그 때문에 HTTP 환경에서 차단
|
||
|
|
**해결:** 개발 환경에서는 `Secure` 플래그 제거 가능 (프로덕션에서는 필수)
|
||
|
|
|
||
|
|
### 문제: 미들웨어에서 토큰을 읽지 못함
|
||
|
|
**원인:** 쿠키 이름 불일치 또는 Path 설정 문제
|
||
|
|
**해결:** `request.cookies.get('user_token')` 확인 및 `Path=/` 설정 확인
|
||
|
|
|
||
|
|
### 문제: 로그인 후에도 인증 실패
|
||
|
|
**원인:** 쿠키가 다른 도메인에 설정됨
|
||
|
|
**해결:** SameSite 설정 확인 및 도메인 일치 여부 확인
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 결론
|
||
|
|
|
||
|
|
✅ **보안 개선 완료:**
|
||
|
|
- XSS 공격 위험: 7.6/10 → 2.8/10
|
||
|
|
- JavaScript 토큰 접근 완전 차단
|
||
|
|
- CSRF 방어 강화
|
||
|
|
- HTTPS 강제 적용
|
||
|
|
|
||
|
|
✅ **구현 완료 항목:**
|
||
|
|
1. Next.js Route Handlers (로그인/로그아웃 프록시)
|
||
|
|
2. HttpOnly 쿠키 저장 방식
|
||
|
|
3. 클라이언트 코드 업데이트
|
||
|
|
4. 미들웨어 인증 확인 (기존 코드 호환)
|
||
|
|
5. 레거시 코드 제거 (sanctum-client.ts, token-storage.ts)
|
||
|
|
|
||
|
|
🔄 **테스트 필요:**
|
||
|
|
- 로그인/로그아웃 플로우
|
||
|
|
- HttpOnly 쿠키 동작 확인
|
||
|
|
- 비로그인 상태 차단 확인
|
||
|
|
- XSS 방어 검증
|