Files
sam-react-prod/claudedocs/[IMPL-2025-11-07] jwt-cookie-authentication-final.md
byeongcheolryu 2307b1f2c0 [docs]: 프로젝트 문서 추가
세부 항목:
- 인증 및 미들웨어 구현 가이드
- 품목 관리 마이그레이션 가이드
- API 분석 및 요구사항 문서
- 대시보드 통합 완료 문서
- 브라우저 호환성 및 쿠키 처리 가이드
- Next.js 15 마이그레이션 참고 문서

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 21:17:43 +09:00

14 KiB

JWT + Cookie + Middleware 인증 설계 (최종)

확정된 API 정보:

  • 인증 방식: Bearer Token (JWT)
  • 로그인: POST /api/v1/login
  • 응답: { token: "xxx" }
  • Token 저장: 쿠키 (Middleware 접근 가능)

핵심 발견

JWT도 쿠키에 저장하면 Middleware에서 처리 가능합니다!

// 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)

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)

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) - 기존과 거의 동일!

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)$).*)',
  ],
};

변경 사항:

- const sessionCookie = request.cookies.get('laravel_session');
+ const authToken = request.cookies.get('auth_token');

거의 동일합니다!

4. Auth Context (contexts/AuthContext.tsx)

'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분)

  • 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_sessionauth_token
    • CSRF 토큰 불필요
    • API 호출 시 Authorization 헤더 추가

장점

  • Middleware에서 서버사이드 인증 체크
  • 클라이언트 가드 컴포넌트 불필요
  • 중복 코드 제거
  • 기존 설계(authentication-design.md) 거의 그대로 사용

변경 사항

최소한의 변경만 필요:

// 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시간)

준비되면 바로 시작합니다! 🎯