Files
tk-factory-services/system1-factory/fastapi-bridge/main.py
Hyungi Ahn f09c86ee01 fix(security): CRITICAL 보안 이슈 13건 일괄 수정
- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳)
- SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩
- SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지)
- SEC-39: Python Dockerfile 4개 non-root user + chown
- SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함)
- SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized
- QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑
- SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by
- SEC-33: files.py 17개 미인증 엔드포인트 인증 추가
- SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자)
- SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함
- SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리
- SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가
- SEC-63: proxyInputController 에러 메시지 노출 제거
- QA-103: pageAccessRoutes error→message 통일
- SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation
- QA-99: tbm-mobile/create 캐시 버스팅 갱신
- QA-100,101: ESC 키 리스너 cleanup 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:48:58 +09:00

314 lines
10 KiB
Python

"""
FastAPI 브릿지 메인 애플리케이션
Phase 1: 기본 프록시 기능
"""
import asyncio
import logging
from typing import Any, Dict
import aiohttp
import jwt as pyjwt
import uvicorn
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from config import settings
from cache import cache_manager, cached
from analytics import analytics_manager, AnalyticsMiddleware
# 로깅 설정
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL))
logger = logging.getLogger(__name__)
# FastAPI 앱 생성
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description="Technical Korea FastAPI Bridge - Express.js API 프록시"
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 분석 미들웨어 추가
app.add_middleware(AnalyticsMiddleware)
# 정적 파일 서빙 설정
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/css", StaticFiles(directory="static/css"), name="css")
app.mount("/js", StaticFiles(directory="static/js"), name="js")
app.mount("/img", StaticFiles(directory="static/img"), name="img")
app.mount("/pages", StaticFiles(directory="static/pages"), name="pages")
app.mount("/components", StaticFiles(directory="static/components"), name="components")
# HTTP 클라이언트 세션 (전역)
http_session: aiohttp.ClientSession = None
@app.on_event("startup")
async def startup_event():
"""앱 시작시 초기화"""
global http_session
# 캐시 매니저 연결
await cache_manager.connect()
# HTTP 클라이언트 세션 생성
connector = aiohttp.TCPConnector(
limit=settings.MAX_CONNECTIONS,
ttl_dns_cache=300,
use_dns_cache=True,
)
timeout = aiohttp.ClientTimeout(total=settings.PROXY_TIMEOUT)
http_session = aiohttp.ClientSession(
connector=connector,
timeout=timeout
)
logger.info(f"🚀 {settings.PROJECT_NAME} v{settings.VERSION} 시작됨")
logger.info(f"📍 포트: {settings.FASTAPI_PORT}")
logger.info(f"🔗 Express.js API: {settings.EXPRESS_API_URL}")
# 캐시 상태 로깅
cache_stats = cache_manager.get_stats()
logger.info(f"💾 캐시: {cache_stats['cache_type']} ({'연결됨' if cache_stats['redis_available'] else '메모리 모드'})")
@app.on_event("shutdown")
async def shutdown_event():
"""앱 종료시 정리"""
global http_session
# 캐시 매니저 연결 종료
await cache_manager.disconnect()
if http_session:
await http_session.close()
logger.info("✅ FastAPI 브릿지가 정상적으로 종료되었습니다")
@app.get("/")
async def root():
"""루트 경로 - index.html 서빙"""
return FileResponse("static/index.html")
@app.get("/status")
async def status():
"""서비스 상태 정보"""
return {
"service": settings.PROJECT_NAME,
"version": settings.VERSION,
"status": "healthy",
"express_api": settings.EXPRESS_API_URL
}
@app.get("/health")
@cached(prefix="health", ttl=settings.CACHE_HEALTH_TTL)
async def health_check():
"""헬스체크 엔드포인트 (캐싱 적용)"""
try:
# Express.js API 헬스체크
async with http_session.get(f"{settings.EXPRESS_API_URL}/api/health") as response:
express_status = "healthy" if response.status == 200 else "unhealthy"
express_data = await response.json() if response.status == 200 else None
except Exception as e:
logger.error(f"Express.js 헬스체크 실패: {e}")
express_status = "unhealthy"
express_data = None
import datetime
return {
"fastapi_bridge": "healthy",
"express_api": express_status,
"express_data": express_data,
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"cached": True
}
@app.get("/cache/stats")
async def cache_stats():
"""캐시 통계 조회"""
stats = cache_manager.get_stats()
return {
"cache_stats": stats,
"settings": {
"default_ttl": settings.CACHE_DEFAULT_TTL,
"health_ttl": settings.CACHE_HEALTH_TTL,
"auth_ttl": settings.CACHE_AUTH_TTL,
"api_ttl": settings.CACHE_API_TTL,
"static_ttl": settings.CACHE_STATIC_TTL
}
}
@app.delete("/cache/clear")
async def clear_cache():
"""캐시 전체 삭제"""
cleared_count = await cache_manager.clear_pattern("*")
return {
"message": "캐시가 삭제되었습니다",
"cleared_keys": cleared_count
}
# ==========================================
# Phase 4: 분석 및 모니터링 API
# ==========================================
@app.get("/analytics/summary")
async def analytics_summary():
"""분석 요약 통계"""
return {
"analytics": analytics_manager.get_summary_stats(),
"cache": cache_manager.get_stats(),
"prediction": analytics_manager.predict_load()
}
@app.get("/analytics/endpoints")
async def analytics_endpoints(limit: int = 20):
"""엔드포인트별 성능 통계"""
return {
"top_endpoints": analytics_manager.get_top_endpoints(limit),
"total_tracked": len(analytics_manager.endpoint_stats)
}
@app.get("/analytics/trends")
async def analytics_trends(hours: int = 24):
"""시간대별 트렌드 분석"""
return {
"trends": analytics_manager.get_hourly_trends(hours),
"period_hours": hours
}
@app.get("/analytics/clients")
async def analytics_clients():
"""클라이언트 분석"""
return analytics_manager.get_client_analysis()
@app.get("/analytics/dashboard")
async def analytics_dashboard():
"""종합 대시보드 데이터"""
return {
"summary": analytics_manager.get_summary_stats(),
"top_endpoints": analytics_manager.get_top_endpoints(10),
"trends_6h": analytics_manager.get_hourly_trends(6),
"clients": analytics_manager.get_client_analysis(),
"cache_stats": cache_manager.get_stats(),
"prediction": analytics_manager.predict_load(),
"server_info": {
"version": settings.VERSION,
"environment": settings.NODE_ENV,
"redis_available": cache_manager.is_redis_available
}
}
def _verify_proxy_token(request: Request) -> dict:
"""프록시 요청의 JWT 토큰을 검증하여 사용자 정보 반환"""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
token = auth_header.split(" ", 1)[1]
if not settings.JWT_SECRET:
logger.warning("JWT_SECRET이 설정되지 않아 토큰 검증을 건너뜁니다")
return {}
try:
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
return payload
except pyjwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
# JWT 검증 (defense in depth — Express 백엔드도 자체 검증함)
user_payload = _verify_proxy_token(request)
user_id = user_payload.get("user_id", user_payload.get("id", "anon"))
# Express.js API URL 구성
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
# 요청 데이터 준비
headers = dict(request.headers)
headers.pop("host", None) # host 헤더 제거
params = dict(request.query_params)
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
if request.method == "GET":
cache_key = cache_manager._generate_key("api", path, _uid=str(user_id), **params)
cached_result = await cache_manager.get(cache_key)
if cached_result is not None:
logger.info(f"🟢 캐시 히트: GET {target_url}")
return JSONResponse(
content=cached_result,
status_code=200
)
try:
# 요청 바디 읽기 (있는 경우)
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
if body:
body = body.decode("utf-8") if isinstance(body, bytes) else body
logger.info(f"🔗 프록시: {request.method} {target_url}")
# Express.js API로 요청 전달
async with http_session.request(
method=request.method,
url=target_url,
headers=headers,
params=params,
data=body
) as response:
# 응답 데이터 읽기
response_data = await response.text()
# JSON으로 파싱 시도
try:
response_json = await response.json()
except:
response_json = {"data": response_data}
logger.info(f"✅ 응답: {response.status} ({len(response_data)} bytes)")
# GET 요청 성공시 캐시에 저장
if request.method == "GET" and response.status == 200:
await cache_manager.set(cache_key, response_json, settings.CACHE_API_TTL)
logger.info(f"💾 캐시 저장: GET {target_url}")
return JSONResponse(
content=response_json,
status_code=response.status,
headers=dict(response.headers)
)
except aiohttp.ClientTimeout:
logger.error(f"⏰ 타임아웃: {target_url}")
raise HTTPException(status_code=504, detail="Gateway Timeout")
except aiohttp.ClientError as e:
logger.error(f"🚫 클라이언트 오류: {e}")
raise HTTPException(status_code=502, detail="Bad Gateway")
except Exception as e:
logger.error(f"💥 예상치 못한 오류: {e}")
raise HTTPException(status_code=500, detail="Internal Server Error")
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=settings.FASTAPI_PORT,
reload=settings.NODE_ENV == "development",
log_level=settings.LOG_LEVEL.lower()
)