- auth.py 신규: POST /api/v1/auth/login → JWT 세션 토큰(HS256, SECRET_KEY 서명, 24h) GET /api/v1/auth/me 자격 확인. IP당 5회 실패 시 300초 잠금(인메모리) - config: ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_PASSWORD_HASH(bcrypt) / SESSION_TOKEN_EXPIRE_HOURS / LOGIN_MAX_FAILURES / LOGIN_LOCKOUT_SECONDS - security: create_session_token / verify_session_token / is_valid_credential 추가. require_api_key·optional_api_key 는 시그니처 유지, X-API-Key 값으로 API 키 또는 세션 토큰 모두 수용 → 프론트 api.ts·useWebSocket·MCP 서버 호출 경로 무변경 - main.py WebSocket 인증도 is_valid_credential 로 통일 - bcrypt 검증은 passlib 대신 bcrypt 직접 사용 (passlib 1.7.4 ↔ bcrypt 5.0 비호환) - Login.tsx: 아이디/비밀번호 폼, 401/429/503 별 안내 메시지 - .env.example 에 관리자 계정 항목 추가 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
4.0 KiB
Python
113 lines
4.0 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):
|
|
logger.warning("[Security] API 키/세션 토큰 불일치 — 요청 거부")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
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_403_FORBIDDEN,
|
|
detail="유효하지 않은 API 키입니다.",
|
|
)
|
|
return api_key
|
|
|
|
|
|
def generate_secure_key(length: int = 64) -> str:
|
|
"""보안 키 생성 유틸리티 (운영 환경 시크릿 생성용)."""
|
|
return secrets.token_urlsafe(length)
|