diff --git a/backend/package-lock.json b/backend/package-lock.json index 5d36156..575cabd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "@fastify/swagger": "^9.0.0", "@scalar/fastify-api-reference": "^1.25.0", @@ -1151,6 +1152,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@fastify/send": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index b9764d2..fa71e5c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "@fastify/cors": "^11.2.0", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "@fastify/swagger": "^9.0.0", "@scalar/fastify-api-reference": "^1.25.0", diff --git a/backend/src/app.js b/backend/src/app.js index b9c3277..9479267 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -6,6 +6,7 @@ import fastifyCors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import fastifySwagger from '@fastify/swagger'; import multipart from '@fastify/multipart'; +import rateLimit from '@fastify/rate-limit'; import config from './config/index.js'; import * as schemas from './schemas/index.js'; @@ -50,6 +51,11 @@ export async function buildApp(opts = {}) { }, }); + // rate-limit 플러그인 등록 (특정 라우트에만 적용) + await fastify.register(rateLimit, { + global: false, + }); + // 플러그인 등록 (순서 중요) await fastify.register(dbPlugin); await fastify.register(redisPlugin); diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js index 9280f6e..1fb6b49 100644 --- a/backend/src/routes/albums/index.js +++ b/backend/src/routes/albums/index.js @@ -183,7 +183,11 @@ export default async function albumsRoutes(fastify) { if (part.type === 'file' && part.fieldname === 'cover') { coverBuffer = await part.toBuffer(); } else if (part.fieldname === 'data') { - data = JSON.parse(part.value); + try { + data = JSON.parse(part.value); + } catch { + return badRequest(reply, '잘못된 JSON 형식입니다.'); + } } } @@ -230,7 +234,11 @@ export default async function albumsRoutes(fastify) { if (part.type === 'file' && part.fieldname === 'cover') { coverBuffer = await part.toBuffer(); } else if (part.fieldname === 'data') { - data = JSON.parse(part.value); + try { + data = JSON.parse(part.value); + } catch { + return badRequest(reply, '잘못된 JSON 형식입니다.'); + } } } diff --git a/backend/src/routes/albums/photos.js b/backend/src/routes/albums/photos.js index 11ce1c3..3f6464a 100644 --- a/backend/src/routes/albums/photos.js +++ b/backend/src/routes/albums/photos.js @@ -97,7 +97,13 @@ export default async function photosRoutes(fastify) { const buffer = await part.toBuffer(); files.push({ buffer, mimetype: part.mimetype }); } else if (part.fieldname === 'metadata') { - metadata = JSON.parse(part.value); + try { + metadata = JSON.parse(part.value); + } catch { + reply.raw.write(`data: ${JSON.stringify({ error: '잘못된 metadata JSON 형식입니다.' })}\n\n`); + reply.raw.end(); + return; + } } else if (part.fieldname === 'startNumber') { startNumber = parseInt(part.value) || null; } else if (part.fieldname === 'photoType') { diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 8078868..94c1ef3 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -11,6 +11,19 @@ export default async function authRoutes(fastify, opts) { * 관리자 로그인 */ fastify.post('/login', { + config: { + rateLimit: { + max: 5, + timeWindow: '1 minute', + continueExceeding: true, // 차단 중 시도하면 타이머 리셋 (마지막 시도 기준 1분) + keyGenerator: (request) => request.ip, + errorResponseBuilder: () => ({ + statusCode: 429, + error: 'Too Many Requests', + message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.', + }), + }, + }, schema: { tags: ['auth'], summary: '관리자 로그인', @@ -37,6 +50,14 @@ export default async function authRoutes(fastify, opts) { }, }, }, + 429: { + type: 'object', + properties: { + statusCode: { type: 'integer' }, + error: { type: 'string' }, + message: { type: 'string' }, + }, + }, }, }, }, async (request, reply) => { diff --git a/docs/improvements.md b/docs/improvements.md index 05a2905..cd68831 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -539,8 +539,8 @@ await writeFile(dictPath, content, 'utf-8'); | 이슈 | 우선순위 | 상태 | |------|---------|------| -| 로그인 Rate Limit | Medium | ⬜ 미해결 | -| Multipart JSON 파싱 | Medium | ⬜ 미해결 | +| 로그인 Rate Limit | Medium | ✅ 해결됨 | +| Multipart JSON 파싱 | Medium | ✅ 해결됨 | ## Phase 3: 외부 서비스 안정성 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7b7f7c3..715d8c0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { BrowserRouter } from 'react-router-dom'; +import { BrowserRouter, useLocation } from 'react-router-dom'; import { BrowserView, MobileView } from 'react-device-detect'; // 공통 컴포넌트 @@ -8,6 +8,9 @@ import { ScrollToTop } from '@/components/common'; // 라우트 import { PCPublicRoutes, PCAdminRoutes, MobileRoutes } from '@/routes'; +// 스토어 +import { useAuthStore } from '@/stores'; + /** * PC 환경에서 body에 클래스 추가하는 래퍼 */ @@ -19,6 +22,26 @@ function PCWrapper({ children }) { return children; } +/** + * PC 라우트 - admin 경로일 때만 AdminRoutes 렌더링 + */ +function PCRoutes() { + const location = useLocation(); + const isAdminPath = location.pathname.startsWith('/admin'); + const { _hasHydrated } = useAuthStore(); + + // admin 경로에서 hydration 완료 전까지 빈 화면 + if (isAdminPath && !_hasHydrated) { + return null; + } + + return ( + + {isAdminPath ? : } + + ); +} + /** * 프로미스나인 팬사이트 메인 앱 * react-device-detect를 사용한 PC/Mobile 분리 @@ -30,10 +53,7 @@ function App() { {/* PC 뷰 */} - - - - + {/* Mobile 뷰 */} diff --git a/frontend/src/components/pc/admin/layout/Header.jsx b/frontend/src/components/pc/admin/layout/Header.jsx index acdbfec..7681e09 100644 --- a/frontend/src/components/pc/admin/layout/Header.jsx +++ b/frontend/src/components/pc/admin/layout/Header.jsx @@ -3,15 +3,17 @@ * 모든 Admin 페이지에서 공통으로 사용하는 헤더 * 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함 */ -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { LogOut } from 'lucide-react'; import { useAuthStore } from '@/stores'; function AdminHeader({ user }) { + const navigate = useNavigate(); const logout = useAuthStore((state) => state.logout); const handleLogout = () => { logout(); + navigate('/admin', { replace: true }); }; return ( diff --git a/frontend/src/components/pc/admin/layout/Layout.jsx b/frontend/src/components/pc/admin/layout/Layout.jsx index 1ca9ccd..63059b5 100644 --- a/frontend/src/components/pc/admin/layout/Layout.jsx +++ b/frontend/src/components/pc/admin/layout/Layout.jsx @@ -4,10 +4,17 @@ * 헤더 고정 + 본문 스크롤 구조 */ import { useLocation } from 'react-router-dom'; +import { useAuthStore } from '@/stores'; import Header from './Header'; function AdminLayout({ user, children }) { const location = useLocation(); + const { token } = useAuthStore(); + + // 토큰이 없으면 아무것도 렌더링하지 않음 (useAdminAuth에서 리다이렉트 처리) + if (!token) { + return null; + } // 일정 목록 페이지만 내부 스크롤 처리 (하위 페이지는 레이아웃 스크롤 사용) const isSchedulePage = location.pathname === '/admin/schedule'; diff --git a/frontend/src/hooks/pc/admin/useAdminAuth.js b/frontend/src/hooks/pc/admin/useAdminAuth.js index 356c995..f048155 100644 --- a/frontend/src/hooks/pc/admin/useAdminAuth.js +++ b/frontend/src/hooks/pc/admin/useAdminAuth.js @@ -29,14 +29,14 @@ export function useAdminAuth(options = {}) { staleTime: 1000 * 60 * 5, // 5분 캐시 }); - // 토큰 없거나 검증 실패 시 처리 - // 리다이렉트는 DOM 조작이므로 useEffect 사용 허용 + // 토큰 검증 실패 시 (토큰 만료 등) 로그아웃 후 리다이렉트 + // 참고: 토큰이 없는 경우는 라우트 가드(RequireAuth)에서 처리 useEffect(() => { - if (required && (!token || isError)) { + if (required && isError) { logoutRef.current(); - navigate(redirectTo); + navigate(redirectTo, { replace: true }); } - }, [token, isError, required, navigate, redirectTo]); + }, [isError, required, navigate, redirectTo]); return { user: data?.user || user, @@ -64,10 +64,12 @@ export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') { }); useEffect(() => { - if (data?.valid) { + // token이 있고 검증이 성공한 경우에만 리다이렉트 + // 로그아웃 시 캐시된 data가 남아있어도 token이 없으면 리다이렉트하지 않음 + if (token && data?.valid) { navigate(redirectTo); } - }, [data, navigate, redirectTo]); + }, [token, data, navigate, redirectTo]); return { isLoading: !!token && isLoading, diff --git a/frontend/src/routes/pc/admin/index.jsx b/frontend/src/routes/pc/admin/index.jsx index e18f321..afee464 100644 --- a/frontend/src/routes/pc/admin/index.jsx +++ b/frontend/src/routes/pc/admin/index.jsx @@ -1,7 +1,27 @@ -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores'; // 관리자 페이지 import AdminLogin from '@/pages/pc/admin/login/Login'; + +/** + * 인증 필수 라우트 가드 + * token이 없으면 로그인 페이지로 즉시 리다이렉트 + */ +function RequireAuth({ children }) { + const { token, _hasHydrated } = useAuthStore(); + + // Hydration 완료 전에는 아무것도 렌더링하지 않음 + if (!_hasHydrated) { + return null; + } + + if (!token) { + return ; + } + + return children; +} import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard'; import AdminMembers from '@/pages/pc/admin/members/Members'; import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit'; @@ -24,21 +44,21 @@ export default function AdminRoutes() { return ( } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> ); diff --git a/frontend/src/stores/useAuthStore.js b/frontend/src/stores/useAuthStore.js index db68731..f46ac0c 100644 --- a/frontend/src/stores/useAuthStore.js +++ b/frontend/src/stores/useAuthStore.js @@ -12,6 +12,12 @@ const useAuthStore = create( token: null, user: null, isAuthenticated: false, + _hasHydrated: false, + + // Hydration 완료 설정 + setHasHydrated: (state) => { + set({ _hasHydrated: state }); + }, // 로그인 login: (token, user) => { @@ -38,6 +44,9 @@ const useAuthStore = create( user: state.user, isAuthenticated: state.isAuthenticated, }), + onRehydrateStorage: () => (state) => { + state?.setHasHydrated(true); + }, } ) );