From 471cde39864c74d53d870f86ea937ea58deefaa9 Mon Sep 17 00:00:00 2001 From: Mayne0213 Date: Mon, 24 Nov 2025 23:18:11 +0900 Subject: [PATCH] FEAT(app): add password protection - Add authentication feature - Enable password login --- deploy/k8s/overlays/prod/kustomization.yaml | 2 +- .../nextjs/app/api/auth/password/route.ts | 84 +++++++++++++ services/nextjs/app/page.tsx | 7 +- services/nextjs/middleware.ts | 51 ++++++++ .../src/features/password-auth/index.ts | 2 + .../password-auth/ui/PasswordModal.tsx | 119 ++++++++++++++++++ services/nextjs/src/shared/lib/auth.ts | 27 ++++ 7 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 services/nextjs/app/api/auth/password/route.ts create mode 100644 services/nextjs/middleware.ts create mode 100644 services/nextjs/src/features/password-auth/index.ts create mode 100644 services/nextjs/src/features/password-auth/ui/PasswordModal.tsx create mode 100644 services/nextjs/src/shared/lib/auth.ts diff --git a/deploy/k8s/overlays/prod/kustomization.yaml b/deploy/k8s/overlays/prod/kustomization.yaml index 0e4cb92..f0dea25 100644 --- a/deploy/k8s/overlays/prod/kustomization.yaml +++ b/deploy/k8s/overlays/prod/kustomization.yaml @@ -13,7 +13,7 @@ commonLabels: # 이미지 태그 설정 images: - name: ghcr.io/mayne0213/todo - newTag: main-sha-be3027d173376dd5e5522c1e4379d72b9c4aa51e + newTag: main-sha-40d453dfd169d1ccefbfbaf774f50645500ace32 patchesStrategicMerge: - deployment-patch.yaml diff --git a/services/nextjs/app/api/auth/password/route.ts b/services/nextjs/app/api/auth/password/route.ts new file mode 100644 index 0000000..8cb9958 --- /dev/null +++ b/services/nextjs/app/api/auth/password/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import { corsHeaders, handleCorsPreFlight } from '@/shared/lib/cors' + +const PASSWORD = '5364' +const SESSION_COOKIE_NAME = 'todo-auth-session' + +// OPTIONS - CORS preflight 처리 +export async function OPTIONS() { + return handleCorsPreFlight() +} + +// POST - 비밀번호 인증 +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { password } = body + + if (!password) { + const response = NextResponse.json( + { success: false, error: '비밀번호를 입력해주세요' }, + { status: 400 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } + + if (password === PASSWORD) { + // 인증 성공 - 세션 쿠키 설정 + const response = NextResponse.json( + { success: true, message: '인증 성공' }, + { status: 200 } + ) + + // HttpOnly 쿠키로 세션 저장 (보안 강화) + response.cookies.set(SESSION_COOKIE_NAME, 'authenticated', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24, // 24시간 + path: '/', + }) + + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } else { + const response = NextResponse.json( + { success: false, error: '비밀번호가 일치하지 않습니다' }, + { status: 401 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } + } catch (error) { + console.error('Password auth error:', error) + const response = NextResponse.json( + { success: false, error: '서버 오류가 발생했습니다' }, + { status: 500 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response + } +} + +// GET - 인증 상태 확인 +export async function GET(request: NextRequest) { + const session = request.cookies.get(SESSION_COOKIE_NAME)?.value + + const response = NextResponse.json( + { success: true, authenticated: session === 'authenticated' }, + { status: 200 } + ) + Object.entries(corsHeaders()).forEach(([key, value]) => { + response.headers.set(key, value) + }) + return response +} diff --git a/services/nextjs/app/page.tsx b/services/nextjs/app/page.tsx index 2c1be76..110fe9a 100644 --- a/services/nextjs/app/page.tsx +++ b/services/nextjs/app/page.tsx @@ -1,6 +1,11 @@ import { TodoApp } from '@/src/widgets/todo-app' +import PasswordModal from '@/src/features/password-auth/ui/PasswordModal' export default function Home() { - return + return ( + + + + ) } diff --git a/services/nextjs/middleware.ts b/services/nextjs/middleware.ts new file mode 100644 index 0000000..22c000f --- /dev/null +++ b/services/nextjs/middleware.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { isAuthenticated, createUnauthorizedResponse } from '@/shared/lib/auth' + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // 공개 경로 (인증 불필요) + const publicPaths = [ + '/api/auth/password', // 비밀번호 인증 API + '/api/health', // 헬스체크 + ] + + // 공개 경로는 통과 + if (publicPaths.some(path => pathname.startsWith(path))) { + return NextResponse.next() + } + + // API 라우트 보호 + if (pathname.startsWith('/api/')) { + if (!isAuthenticated(request)) { + return createUnauthorizedResponse() + } + return NextResponse.next() + } + + // 페이지 라우트 보호 + if (pathname === '/' || pathname.startsWith('/app')) { + if (!isAuthenticated(request)) { + // 인증되지 않은 경우, 클라이언트에서 처리하도록 그대로 진행 + // (PasswordModal이 표시됨) + return NextResponse.next() + } + return NextResponse.next() + } + + return NextResponse.next() +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +} + diff --git a/services/nextjs/src/features/password-auth/index.ts b/services/nextjs/src/features/password-auth/index.ts new file mode 100644 index 0000000..0683994 --- /dev/null +++ b/services/nextjs/src/features/password-auth/index.ts @@ -0,0 +1,2 @@ +export { default as PasswordModal } from './ui/PasswordModal' + diff --git a/services/nextjs/src/features/password-auth/ui/PasswordModal.tsx b/services/nextjs/src/features/password-auth/ui/PasswordModal.tsx new file mode 100644 index 0000000..19bb930 --- /dev/null +++ b/services/nextjs/src/features/password-auth/ui/PasswordModal.tsx @@ -0,0 +1,119 @@ +'use client' + +import { useState, ReactNode, useEffect } from 'react' +import { Input, Button } from '../../../shared/ui' + +interface PasswordModalProps { + children: ReactNode +} + +export default function PasswordModal({ children }: PasswordModalProps) { + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isChecking, setIsChecking] = useState(true) + + // 컴포넌트 마운트 시 인증 상태 확인 + useEffect(() => { + checkAuthStatus() + }, []) + + const checkAuthStatus = async () => { + try { + const response = await fetch('/api/auth/password', { + method: 'GET', + credentials: 'include', + }) + const data = await response.json() + + if (data.success && data.authenticated) { + setIsAuthenticated(true) + } + } catch (error) { + console.error('인증 상태 확인 실패:', error) + } finally { + setIsChecking(false) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + + try { + const response = await fetch('/api/auth/password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ password }), + }) + + const data = await response.json() + + if (data.success) { + setIsAuthenticated(true) + setPassword('') + } else { + setError(data.error || '비밀번호가 일치하지 않습니다.') + setPassword('') + } + } catch (error) { + console.error('인증 요청 실패:', error) + setError('서버 오류가 발생했습니다. 다시 시도해주세요.') + setPassword('') + } + } + + // 인증 상태 확인 중일 때 로딩 표시 + if (isChecking) { + return ( +
+
+

확인 중...

+
+
+ ) + } + + if (isAuthenticated) { + return <>{children} + } + + return ( +
+
+

+ 비밀번호 입력 +

+

+ Todo 앱에 접근하려면 비밀번호를 입력하세요. +

+
+ { + setPassword(e.target.value) + setError('') + }} + className="w-full h-12 border-gray-300 focus:border-green-500 focus:ring-green-500/20" + autoFocus + /> + {error && ( +

{error}

+ )} + +
+
+
+ ) +} + diff --git a/services/nextjs/src/shared/lib/auth.ts b/services/nextjs/src/shared/lib/auth.ts new file mode 100644 index 0000000..e8c1942 --- /dev/null +++ b/services/nextjs/src/shared/lib/auth.ts @@ -0,0 +1,27 @@ +import { NextRequest } from 'next/server' + +export const SESSION_COOKIE_NAME = 'todo-auth-session' + +/** + * 요청이 인증되었는지 확인 + */ +export function isAuthenticated(request: NextRequest): boolean { + const session = request.cookies.get(SESSION_COOKIE_NAME)?.value + return session === 'authenticated' +} + +/** + * 인증되지 않은 요청에 대한 응답 생성 + */ +export function createUnauthorizedResponse() { + return new Response( + JSON.stringify({ success: false, error: '인증이 필요합니다' }), + { + status: 401, + headers: { + 'Content-Type': 'application/json', + }, + } + ) +} +