세션 토큰(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>
116 lines
4.4 KiB
Python
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)
|