"""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, ". "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)