Files
auto-stock/backend/app/core/security.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

436 lines
19 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
WS_AUTH_SUBPROTOCOL = "autostock.auth"
def extract_ws_subprotocol_token(subprotocol_header: str | None) -> tuple[str | None, str]:
"""Sec-WebSocket-Protocol 헤더에서 (토큰, 협상할 서브프로토콜) 추출.
클라이언트: new WebSocket(url, ["autostock.auth", token]) →
헤더 "autostock.auth, <token>". "autostock.auth" 다음 항목이 토큰.
반환 subprotocol 은 accept(subprotocol=...) 에 넘길 값("" 이면 협상 없음).
토큰은 쿼리스트링과 달리 프록시 액세스 로그에 남지 않는다.
"""
if not subprotocol_header:
return None, ""
items = [p.strip() for p in subprotocol_header.split(",") if p.strip()]
if WS_AUTH_SUBPROTOCOL not in items:
return None, ""
idx = items.index(WS_AUTH_SUBPROTOCOL)
token = items[idx + 1] if idx + 1 < len(items) else None
return token, WS_AUTH_SUBPROTOCOL
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)