Files
auto-stock/backend/app/core/security.py
shuim 62c324e346 fix: 세션 만료 시 화면 전체가 오류로 멈춤 — 무효/만료 자격증명은 401로 응답, 프론트는 토큰 제거 후 로그인 화면
세션 토큰(24h)이 만료되면 백엔드가 403을 돌려주는데 프론트는 401만 로그아웃 처리해
만료된 토큰을 계속 보내며 모든 패널이 '유효하지 않은 API 키' 오류로 멈췄음.
- require_api_key/optional_api_key: 무효 자격증명 → 401 (403은 권한 부족 전용 유지)
- api.ts: 403이라도 '유효하지 않은 API 키' 메시지면 토큰 제거 → 로그인 화면 (구버전 백엔드 호환)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:36:42 +09:00

116 lines
4.4 KiB
Python

"""API 보안 모듈
- API 키 기반 인증 (Bearer token)
- 매매 실행 및 관리 API 보호
- 읽기 전용 엔드포인트는 인증 선택적
사용법:
from app.core.security import require_api_key, optional_api_key
@router.post("/execute", dependencies=[Depends(require_api_key)])
async def execute_trade(...): ...
@router.get("/data")
async def get_data(api_key: str | None = Depends(optional_api_key)): ...
"""
import hmac
import logging
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import APIKeyHeader
from jose import JWTError, jwt
from app.core.config import settings
logger = logging.getLogger(__name__)
# 로그인 세션 토큰(JWT, HS256). 헤더는 기존 X-API-Key 를 그대로 사용하므로
# 프론트 api 클라이언트·WebSocket 은 변경 없이 세션 토큰을 실어 보낼 수 있다.
_SESSION_ALG = "HS256"
_SESSION_AUD = "autostock-dashboard"
# API 키 헤더 스키마
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def _constant_time_compare(a: str, b: str) -> bool:
"""타이밍 공격 방지를 위한 상수 시간 비교."""
return hmac.compare_digest(a.encode(), b.encode())
def _get_api_key() -> str:
"""API 인증용 키 반환. API_KEY가 설정되면 우선 사용, 없으면 SECRET_KEY 폴백."""
return settings.API_KEY if settings.API_KEY else settings.SECRET_KEY
def create_session_token(username: str) -> tuple[str, datetime]:
"""로그인 성공 시 발급하는 세션 토큰. (token, 만료시각 UTC) 반환."""
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.SESSION_TOKEN_EXPIRE_HOURS)
payload = {"sub": username, "aud": _SESSION_AUD, "exp": expires_at, "iat": datetime.now(timezone.utc)}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=_SESSION_ALG), expires_at
def verify_session_token(token: str) -> str | None:
"""세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치)."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[_SESSION_ALG], audience=_SESSION_AUD)
except JWTError:
return None
sub = payload.get("sub")
return sub if isinstance(sub, str) and sub else None
def is_valid_credential(token: str) -> bool:
"""X-API-Key 값이 API 키이거나 유효한 세션 토큰이면 True."""
if _constant_time_compare(token, _get_api_key()):
return True
return verify_session_token(token) is not None
async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str:
"""매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용.
X-API-Key 헤더가 없거나 불일치하면 401 반환.
"""
if not api_key:
logger.warning("[Security] API 키 누락 — 요청 거부")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API 키가 필요합니다. X-API-Key 헤더를 설정하세요.",
)
if not is_valid_credential(api_key):
# 401: 자격증명 자체가 무효(잘못된 키·만료된 세션) — 프론트가 저장된 토큰을
# 지우고 로그인 화면으로 보내는 트리거. 403은 권한 부족(관리자 전용)에만 사용.
# (세션 24h 만료 후 403이 돌아와 화면 전체가 오류로 멈추던 문제, 2026-09-22)
logger.warning("[Security] API 키/세션 토큰 불일치 — 요청 거부")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
)
return api_key
async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> str | None:
"""읽기 전용 엔드포인트에서 선택적 인증.
키가 있으면 검증, 없으면 None 반환 (인증 없이 접근 허용).
"""
if not api_key:
return None
if not is_valid_credential(api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
)
return api_key
def generate_secure_key(length: int = 64) -> str:
"""보안 키 생성 유틸리티 (운영 환경 시크릿 생성용)."""
return secrets.token_urlsafe(length)