Files
auto-stock/backend/app/core/security.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 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>
2026-10-02 12:01:31 +09:00

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)