feat: 보안 강화 및 인증 개선 (Phase 2)

- 로그인 Rate Limit 추가 (5회/분, 마지막 시도 기준 리셋)
- Multipart JSON 파싱 에러 처리 추가
- 로그아웃 시 무한 리다이렉트 버그 수정
- 인증 라우트 가드(RequireAuth) 추가로 비로그인 접근 차단
- Zustand hydration 대기로 페이지 깜빡임 해결
- admin/public 라우트 조건부 렌더링으로 경로 매칭 경고 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-23 20:47:05 +09:00
parent 4edef16310
commit e852f215a3
13 changed files with 158 additions and 34 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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);

View file

@ -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 형식입니다.');
}
}
}

View file

@ -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') {

View file

@ -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) => {

View file

@ -539,8 +539,8 @@ await writeFile(dictPath, content, 'utf-8');
| 이슈 | 우선순위 | 상태 |
|------|---------|------|
| 로그인 Rate Limit | Medium | ⬜ 미해결 |
| Multipart JSON 파싱 | Medium | ⬜ 미해결 |
| 로그인 Rate Limit | Medium | ✅ 해결됨 |
| Multipart JSON 파싱 | Medium | ✅ 해결됨 |
## Phase 3: 외부 서비스 안정성

View file

@ -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 (
<PCWrapper>
{isAdminPath ? <PCAdminRoutes /> : <PCPublicRoutes />}
</PCWrapper>
);
}
/**
* 프로미스나인 팬사이트 메인
* react-device-detect를 사용한 PC/Mobile 분리
@ -30,10 +53,7 @@ function App() {
{/* PC 뷰 */}
<BrowserView>
<PCWrapper>
<PCAdminRoutes />
<PCPublicRoutes />
</PCWrapper>
<PCRoutes />
</BrowserView>
{/* Mobile 뷰 */}

View file

@ -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 (

View file

@ -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';

View file

@ -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,

View file

@ -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 <Navigate to="/admin" replace />;
}
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 (
<Routes>
<Route path="/admin" element={<AdminLogin />} />
<Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/admin/members" element={<AdminMembers />} />
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
<Route path="/admin/albums" element={<AdminAlbums />} />
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
<Route path="/admin/schedule" element={<AdminSchedules />} />
<Route path="/admin/schedule/new" element={<AdminScheduleFormPage />} />
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit/youtube" element={<AdminYouTubeEditForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
<Route path="/admin/dashboard" element={<RequireAuth><AdminDashboard /></RequireAuth>} />
<Route path="/admin/members" element={<RequireAuth><AdminMembers /></RequireAuth>} />
<Route path="/admin/members/:name/edit" element={<RequireAuth><AdminMemberEdit /></RequireAuth>} />
<Route path="/admin/albums" element={<RequireAuth><AdminAlbums /></RequireAuth>} />
<Route path="/admin/albums/new" element={<RequireAuth><AdminAlbumForm /></RequireAuth>} />
<Route path="/admin/albums/:id/edit" element={<RequireAuth><AdminAlbumForm /></RequireAuth>} />
<Route path="/admin/albums/:albumId/photos" element={<RequireAuth><AdminAlbumPhotos /></RequireAuth>} />
<Route path="/admin/schedule" element={<RequireAuth><AdminSchedules /></RequireAuth>} />
<Route path="/admin/schedule/new" element={<RequireAuth><AdminScheduleFormPage /></RequireAuth>} />
<Route path="/admin/schedule/new-legacy" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
<Route path="/admin/schedule/:id/edit" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
<Route path="/admin/*" element={<AdminNotFound />} />
</Routes>
);

View file

@ -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);
},
}
)
);