하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
415 lines
18 KiB
Python
415 lines
18 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)): ...
|
|
|
|
[2026-10-02 검증 루프 2회차]
|
|
- H-2: 세션 토큰은 서명/만료만이 아니라 **사용자 is_active** 까지 확인한다
|
|
(`is_valid_credential_v2`). 이전에는 revoke 후에도 토큰 만료(24h)까지 통과했다.
|
|
사용자 상태는 `SESSION_ACTIVE_CACHE_SECONDS` 동안 캐시한다.
|
|
- H-2: 위험 쓰기 엔드포인트는 관리자만(`require_admin_for_listed_routes`). API 키(서버 간)는 관리자.
|
|
- M-5: 세션 JWT 서명키를 SECRET_KEY 에서 파생한 별도 키로 분리했다. API_KEY 미설정 시
|
|
API 키 = SECRET_KEY 이므로, 이전에는 API 키를 아는 쪽이 임의 사용자 세션을 위조할 수 있었다.
|
|
- M-2: `client_ip_v2` — 신뢰 프록시에서 온 X-Real-IP 만 사용한다.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import ipaddress
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import Depends, HTTPException, Request, 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"
|
|
# 세션 서명키 파생 컨텍스트 (M-5). 바꾸면 기존 세션이 전부 무효가 된다.
|
|
_SESSION_KEY_CONTEXT = b"autostock-session-jwt-v2"
|
|
|
|
# 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 _session_signing_key() -> str:
|
|
"""세션 JWT 서명키 — SECRET_KEY 에서 HMAC 으로 파생 (M-5).
|
|
|
|
API_KEY 미설정 시 API 키가 SECRET_KEY 와 같아지므로, SECRET_KEY 로 직접 서명하면
|
|
API 키 보유자(MCP 서버·스크립트)가 임의 사용자(관리자 포함) 세션을 위조할 수 있었다.
|
|
"""
|
|
return hmac.new(settings.SECRET_KEY.encode(), _SESSION_KEY_CONTEXT, hashlib.sha256).hexdigest()
|
|
|
|
|
|
def _session_verify_keys() -> list[str]:
|
|
"""검증에 허용할 서명키 목록.
|
|
|
|
하위호환: 배포 전 발급된 세션(SECRET_KEY 직접 서명)은 **API_KEY 가 별도로 설정돼
|
|
있을 때만** 받아준다 — 그 경우 API 키 보유자가 SECRET_KEY 를 모르므로 위조 위험이 없다.
|
|
API_KEY 미설정 환경에서는 옛 토큰을 거부한다(배포 후 1회 재로그인).
|
|
"""
|
|
keys = [_session_signing_key()]
|
|
if settings.API_KEY and settings.API_KEY != settings.SECRET_KEY:
|
|
keys.append(settings.SECRET_KEY)
|
|
return keys
|
|
|
|
|
|
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, _session_signing_key(), algorithm=_SESSION_ALG), expires_at
|
|
|
|
|
|
def verify_session_token(token: str) -> str | None:
|
|
"""세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치).
|
|
|
|
⚠️ 서명·만료만 본다 — 사용자 활성 여부는 `is_valid_credential_v2` 가 확인한다.
|
|
"""
|
|
for key in _session_verify_keys():
|
|
try:
|
|
payload = jwt.decode(token, key, algorithms=[_SESSION_ALG], audience=_SESSION_AUD)
|
|
except JWTError:
|
|
continue
|
|
sub = payload.get("sub")
|
|
return sub if isinstance(sub, str) and sub else None
|
|
return None
|
|
|
|
|
|
def is_valid_credential(token: str) -> bool:
|
|
"""X-API-Key 값이 API 키이거나 서명이 유효한 세션 토큰이면 True.
|
|
|
|
⚠️ 동기 레거시 판정 — 사용자 is_active 를 보지 않는다. 인증 경로는
|
|
`is_valid_credential_v2` 를 쓸 것(require_api_key/optional_api_key/WebSocket 은 전환됨).
|
|
"""
|
|
if _constant_time_compare(token, _get_api_key()):
|
|
return True
|
|
return verify_session_token(token) is not None
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# H-2: 세션 사용자 상태(is_active/is_admin) 확인 — 짧은 TTL 캐시
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@dataclass(frozen=True)
|
|
class SessionUserFlags:
|
|
exists: bool
|
|
is_active: bool
|
|
is_admin: bool
|
|
|
|
|
|
# username -> (flags, monotonic fetched_at)
|
|
_session_user_cache: dict[str, tuple[SessionUserFlags, float]] = {}
|
|
|
|
|
|
def invalidate_session_user_cache(username: str | None = None) -> None:
|
|
"""승인/회수 직후 호출 — 캐시 TTL 을 기다리지 않고 즉시 반영한다."""
|
|
if username is None:
|
|
_session_user_cache.clear()
|
|
else:
|
|
_session_user_cache.pop(username, None)
|
|
|
|
|
|
async def _fetch_session_user_flags_impl(username: str) -> SessionUserFlags:
|
|
"""DB 에서 사용자 상태 조회 (SELECT 전용, commit 없음).
|
|
|
|
엔드포인트 의존성(HTTP 요청·WebSocket 핸드셰이크)에서만 호출된다 —
|
|
실시간 매매 계층이 아니다(CLAUDE.md §3.1 대상 아님).
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
from app.core.database import AsyncSessionLocal
|
|
from app.models.models import User
|
|
|
|
# DB-READ-EXCEPTION: 인증 의존성의 사용자 상태 SELECT 전용(commit 없음). HTTP 요청·WebSocket
|
|
# 핸드셰이크 단계에서만, 캐시 미스(TTL SESSION_ACTIVE_CACHE_SECONDS)일 때만 실행 — 매매 실시간 계층 아님.
|
|
async with AsyncSessionLocal() as session:
|
|
row = (
|
|
await session.execute(
|
|
select(User.is_active, User.is_admin).where(User.username == username)
|
|
)
|
|
).first()
|
|
if row is None:
|
|
return SessionUserFlags(exists=False, is_active=False, is_admin=False)
|
|
return SessionUserFlags(exists=True, is_active=bool(row[0]), is_admin=bool(row[1]))
|
|
|
|
|
|
async def get_session_user_flags(username: str) -> SessionUserFlags:
|
|
"""캐시 우선 사용자 상태 조회.
|
|
|
|
DB 조회 실패 시: 이전 캐시값이 있으면(만료됐어도) 그것을 쓰고, 없으면 503.
|
|
401 을 돌려주면 프론트가 토큰을 지우고 로그아웃시키므로 일시 장애에는 503 이 맞다.
|
|
"""
|
|
now = time.monotonic()
|
|
cached = _session_user_cache.get(username)
|
|
ttl = max(0, int(settings.SESSION_ACTIVE_CACHE_SECONDS))
|
|
if cached is not None and (now - cached[1]) < ttl:
|
|
return cached[0]
|
|
try:
|
|
flags = await _fetch_session_user_flags_impl(username)
|
|
except Exception as e:
|
|
if cached is not None:
|
|
logger.warning(
|
|
"[Security] 사용자 상태 조회 실패 — 만료된 캐시값 사용(user=%s): %s", username, e
|
|
)
|
|
return cached[0]
|
|
logger.error("[Security] 사용자 상태 조회 실패 — 인증 보류(user=%s): %s", username, e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="인증 상태를 확인할 수 없습니다. 잠시 후 다시 시도하세요.",
|
|
)
|
|
_session_user_cache[username] = (flags, now)
|
|
if len(_session_user_cache) > 1000:
|
|
for k in [k for k, (_, t) in _session_user_cache.items() if (now - t) > ttl]:
|
|
_session_user_cache.pop(k, None)
|
|
return flags
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CredentialInfo:
|
|
kind: str # "api_key" | "session"
|
|
username: str
|
|
is_admin: bool
|
|
|
|
|
|
async def resolve_credential_v2(token: str | None) -> CredentialInfo | None:
|
|
"""X-API-Key 값 해석. API 키 → 관리자. 세션 → 서명·만료 + **활성 사용자**만. 그 외 None."""
|
|
if not token:
|
|
return None
|
|
if _constant_time_compare(token, _get_api_key()):
|
|
return CredentialInfo(kind="api_key", username="api-key", is_admin=True)
|
|
username = verify_session_token(token)
|
|
if not username:
|
|
return None
|
|
flags = await get_session_user_flags(username)
|
|
if not flags.exists or not flags.is_active:
|
|
logger.warning(
|
|
"[Security] %s 사용자 세션 토큰 거부: user=%s",
|
|
"삭제된" if not flags.exists else "비활성(회수/미승인)", username,
|
|
)
|
|
return None
|
|
return CredentialInfo(kind="session", username=username, is_admin=flags.is_admin)
|
|
|
|
|
|
async def is_valid_credential_v2(token: str | None) -> bool:
|
|
"""`is_valid_credential` 의 비동기 판 — 세션 사용자의 is_active 까지 확인 (H-2)."""
|
|
return (await resolve_credential_v2(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 await is_valid_credential_v2(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 await is_valid_credential_v2(api_key):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
|
|
)
|
|
return api_key
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# H-2: 위험 쓰기 엔드포인트 — 관리자 전용
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# (METHOD, /api/v1 이후 라우트 템플릿). 라우터 파일을 건드리지 않고 한 곳에서 관리한다.
|
|
# 새 위험 엔드포인트를 만들면 여기에 추가할 것.
|
|
ADMIN_ONLY_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
|
("POST", "/risk/mdd/reset"),
|
|
("PUT", "/strategy-config/{key}"),
|
|
("PUT", "/strategy-config"),
|
|
("POST", "/strategy-config/{key}/reset"),
|
|
("POST", "/strategy-config/reset-all"),
|
|
("PATCH", "/strategy-config/{key}/toggle"),
|
|
("POST", "/daytrading/close-all"),
|
|
("POST", "/daytrading/execute"),
|
|
("POST", "/daytrading-v2/close-all"),
|
|
("POST", "/playbook/circuit-breaker"),
|
|
("DELETE", "/playbook/circuit-breaker"),
|
|
("POST", "/mcp/tools/call"),
|
|
})
|
|
# 접두 일치 — 하위 전 경로·전 메서드가 관리자 전용
|
|
ADMIN_ONLY_PREFIXES: tuple[str, ...] = ("/debug/",)
|
|
_API_PREFIX = "/api/v1"
|
|
|
|
|
|
def is_admin_only_route(method: str, route_path: str) -> bool:
|
|
"""라우트 템플릿(예: /api/v1/strategy-config/{key})이 관리자 전용인가."""
|
|
path = route_path[len(_API_PREFIX):] if route_path.startswith(_API_PREFIX) else route_path
|
|
if any(path.startswith(p) for p in ADMIN_ONLY_PREFIXES):
|
|
return True
|
|
return (method.upper(), path) in ADMIN_ONLY_ROUTES
|
|
|
|
|
|
async def _assert_admin_impl(api_key: str | None) -> CredentialInfo:
|
|
info = await resolve_credential_v2(api_key)
|
|
if info is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="유효하지 않은 API 키이거나 만료된 세션입니다. 다시 로그인하세요.",
|
|
)
|
|
if not info.is_admin:
|
|
logger.warning("[Security] 관리자 전용 엔드포인트 — 비관리자 거부: user=%s", info.username)
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
|
|
return info
|
|
|
|
|
|
async def require_admin_v2(api_key: str | None = Security(_api_key_header)) -> str:
|
|
"""관리자 전용 의존성 (DB 세션 의존 없음). API 키 또는 활성 관리자 세션만 통과."""
|
|
info = await _assert_admin_impl(api_key)
|
|
return info.username
|
|
|
|
|
|
async def require_admin_for_listed_routes(
|
|
request: Request, api_key: str | None = Security(_api_key_header)
|
|
) -> None:
|
|
"""api_router 전역 의존성 — `ADMIN_ONLY_ROUTES` 에 해당하는 요청만 관리자 확인.
|
|
|
|
그 외 라우트에는 아무것도 하지 않는다(각 라우터의 require/optional 정책이 그대로 적용).
|
|
"""
|
|
route = request.scope.get("route")
|
|
route_path = getattr(route, "path", None) or request.url.path
|
|
if not is_admin_only_route(request.method, route_path):
|
|
return
|
|
await _assert_admin_impl(api_key)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# M-2: 클라이언트 IP — 신뢰 프록시의 X-Real-IP 만 사용
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
_trusted_cache: tuple[str, list] | None = None
|
|
|
|
|
|
def _trusted_proxy_networks() -> list:
|
|
global _trusted_cache
|
|
raw = settings.TRUSTED_PROXY_CIDRS or ""
|
|
if _trusted_cache is not None and _trusted_cache[0] == raw:
|
|
return _trusted_cache[1]
|
|
nets = []
|
|
for part in raw.split(","):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
nets.append(ipaddress.ip_network(part, strict=False))
|
|
except ValueError:
|
|
logger.warning("[Security] TRUSTED_PROXY_CIDRS 항목 무시(형식 오류): %r", part)
|
|
_trusted_cache = (raw, nets)
|
|
return nets
|
|
|
|
|
|
def _is_trusted_proxy(host: str | None) -> bool:
|
|
if not host:
|
|
return False
|
|
try:
|
|
ip = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return False
|
|
return any(ip in net for net in _trusted_proxy_networks())
|
|
|
|
|
|
def client_ip_v2(request) -> str:
|
|
"""요청자 IP.
|
|
|
|
- 직전 홉(소켓 주소)이 `TRUSTED_PROXY_CIDRS` 안이면 nginx 가 덮어쓴 `X-Real-IP` 를 쓴다.
|
|
- 그 외에는 소켓 주소. **X-Forwarded-For 는 보지 않는다** — 클라이언트가 임의로 넣을 수
|
|
있어 로그인 잠금을 우회하는 데 쓰였다(첫 값 신뢰).
|
|
"""
|
|
peer = request.client.host if getattr(request, "client", None) else None
|
|
if _is_trusted_proxy(peer):
|
|
real = (request.headers.get("x-real-ip") or "").strip()
|
|
if real:
|
|
try:
|
|
ipaddress.ip_address(real)
|
|
return real
|
|
except ValueError:
|
|
logger.warning("[Security] X-Real-IP 형식 오류 무시: %r", real[:64])
|
|
return peer or "unknown"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# M-5: 키 강도 점검
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
_WEAK_SECRETS = frozenset({
|
|
"change-this-secret-key",
|
|
"auto_stock_secret_key_change_in_production_2024",
|
|
"secret",
|
|
"your-secret-key-here",
|
|
})
|
|
|
|
|
|
def security_key_issues() -> list[str]:
|
|
"""키 설정 문제 목록(값은 노출하지 않는다). 빈 리스트면 정상."""
|
|
issues: list[str] = []
|
|
min_len = int(settings.MIN_SECRET_KEY_LENGTH)
|
|
sk = settings.SECRET_KEY or ""
|
|
if sk.lower() in _WEAK_SECRETS:
|
|
issues.append("SECRET_KEY 가 기본/예제 값이다")
|
|
elif len(sk) < min_len:
|
|
issues.append(f"SECRET_KEY 길이 {len(sk)} < {min_len}")
|
|
if not settings.API_KEY:
|
|
issues.append("API_KEY 미설정 — API 키가 SECRET_KEY 로 폴백된다(키 분리 권장)")
|
|
elif len(settings.API_KEY) < min_len:
|
|
issues.append(f"API_KEY 길이 {len(settings.API_KEY)} < {min_len}")
|
|
return issues
|
|
|
|
|
|
def generate_secure_key(length: int = 64) -> str:
|
|
"""보안 키 생성 유틸리티 (운영 환경 시크릿 생성용)."""
|
|
return secrets.token_urlsafe(length)
|