Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
# FastAPI
########################################

# Local development only; deployed runtime defaults to verification enabled.
TURNSTILE_ENABLED=false

# 서버 설정
HOST=0.0.0.0
PORT=8000
Expand Down
4 changes: 4 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
# Development runs locally with compose.dev.yaml; OCI only hosts production.
# Never copy local-development sample passwords into a deployed environment.
DEBUG=false
TURNSTILE_ENABLED=true
TURNSTILE_SECRET_KEY=replace-on-server
TURNSTILE_ALLOWED_HOSTNAMES=backtest.yeonjae.kr
TURNSTILE_EXPECTED_ACTION=backtest
LOG_LEVEL=INFO
DATABASE_HOST=replace-with-private-db-host
DATABASE_PORT=3306
Expand Down
12 changes: 12 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@

---

## 2026-10-25 백테스트 계산 사람 인증

- 계산 POST의 FastAPI 의존성에서 Cloudflare Siteverify를 검증한 뒤 기존 실행기로
전달한다. 토큰 누락·위조·만료·재사용·hostname/action 불일치는 403, 설정 누락·통신
장애는 503으로 거부한다. 계산·DB I/O·슬롯 점유 전에 차단하며 기존 JSON 계약은 유지한다.
- 기본 설정은 인증 활성화이며 로컬 개발·단위 테스트만 명시적으로 끈다. 비밀키는
런타임 환경 파일에만 보관한다. 네 조회 API와 헬스체크는 인증 대상이 아니다.
- 새 외부 SDK 의존성·토큰 DB·세션 캐시는 추가하지 않았다. Cloudflare의 일회용 토큰을
검증하고, 기존 컨테이너 전체/IP별 동시 실행 제한도 유지한다.
- Docker 검증: 단위 테스트 558개와 계산 golden master 1개 통과. API 계약 검사는
새 인증 헤더와 403 응답만 허용하고 기존 요청·응답 스키마의 동일성을 확인한다.

## 2026-10-25 네 종류의 독립 조회

- **A-39**: 통합 GET `/api/v1/market-data`를 `/prices`, `/exchange-rates`,
Expand Down
2 changes: 2 additions & 0 deletions backtest_be_fast/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
HOST=0.0.0.0
PORT=8000
DEBUG=false
# Local development only. Never disable verification in the deployed .env.
TURNSTILE_ENABLED=false
LOG_LEVEL=INFO

# 보안
Expand Down
7 changes: 5 additions & 2 deletions backtest_be_fast/app/api/v1/endpoints/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
포트폴리오 백테스트 실행 및 관련 데이터를 반환하는 FastAPI 엔드포인트입니다.
"""

from fastapi import APIRouter, Request, status
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
import logging
import asyncio
Expand All @@ -20,6 +20,7 @@
from ....core.client_ip import client_limit_key, parse_trusted_networks, resolve_client_ip
from ....services.backtest_runner import BacktestRejected, get_backtest_runner
from ..decorators import handle_portfolio_errors
from ..turnstile import require_turnstile

logger = logging.getLogger(__name__)
router = APIRouter()
Expand Down Expand Up @@ -176,9 +177,11 @@ def _rejection_response(exc: BacktestRejected) -> JSONResponse:
status_code=status.HTTP_200_OK,
summary="포트폴리오 백테스트 실행",
description="포트폴리오 백테스트 실행 및 관련 데이터 반환",
dependencies=[Depends(require_turnstile)],
responses={
403: {"description": "사람 인증 누락·실패·만료 또는 토큰 재사용 — 계산을 시작하지 않음"},
429: {"description": "같은 클라이언트(IP)의 동시 실행 상한 초과 — 대기 없이 즉시 거부"},
503: {"description": "동시 실행 슬롯 대기 시간 초과 — 작업을 시작하지 않음"},
503: {"description": "사람 인증 검증 불가 또는 동시 실행 슬롯 대기 시간 초과 — 작업을 시작하지 않음"},
504: {"description": "실행 시간 초과 — 작업에 취소 신호를 보냄"},
},
)
Expand Down
58 changes: 58 additions & 0 deletions backtest_be_fast/app/api/v1/turnstile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Validate human verification before accepting calculation work.

Only Cloudflare's Siteverify response is authoritative. No result/token cache:
Siteverify enforces expiry and single use, including across backend workers.
"""
import asyncio
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Annotated

from fastapi import Header, HTTPException, Request

from ...core.config import settings

SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
DENIED = "사람 인증이 필요하거나 만료되었습니다. 다시 인증한 뒤 실행해 주세요."
UNAVAILABLE = "사람 인증을 확인할 수 없습니다. 잠시 후 다시 시도해 주세요."


def _siteverify(token: str, secret: str) -> dict:
data = urllib.parse.urlencode({"secret": secret, "response": token}).encode("utf-8")
request = urllib.request.Request(SITEVERIFY_URL, data=data, method="POST")
with urllib.request.urlopen(request, timeout=settings.turnstile_timeout_seconds) as response:
# Never log responses or submitted credentials, even on failure.
body = response.read(16_385)
if len(body) > 16_384:
raise ValueError("Oversized Siteverify response")
result = json.loads(body)
if not isinstance(result, dict):
raise ValueError("Invalid Siteverify response")
return result


async def require_turnstile(
request: Request,
token: Annotated[str | None, Header(alias="X-Turnstile-Token",
description="Turnstile single-use token for the backtest action")] = None,
) -> None:
if not settings.turnstile_enabled:
return
secret = settings.turnstile_secret_key.get_secret_value().strip()
hosts = {host.strip() for host in settings.turnstile_allowed_hostnames.split(",") if host.strip()}
if not secret or not hosts or not settings.turnstile_expected_action:
raise HTTPException(503, UNAVAILABLE)
if (not token or not token.strip() or len(token) > 2048
or len(request.headers.getlist("X-Turnstile-Token")) != 1):
raise HTTPException(403, DENIED)
try:
result = await asyncio.to_thread(_siteverify, token, secret)
except (OSError, ValueError, urllib.error.URLError):
# Fail closed; external errors must not reach the simulation/error decorator.
raise HTTPException(503, UNAVAILABLE) from None
if (result.get("success") is not True or not isinstance(result.get("hostname"), str)
or result.get("hostname") not in hosts
or result.get("action") != settings.turnstile_expected_action):
raise HTTPException(403, DENIED)
10 changes: 9 additions & 1 deletion backtest_be_fast/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import json
from typing import Optional
from pydantic_settings import BaseSettings
from pydantic import Field
from pydantic import Field, SecretStr
from pydantic import field_validator


Expand All @@ -45,6 +45,14 @@ class Settings(BaseSettings):
host: str = Field(default="0.0.0.0", env="HOST")
port: int = Field(default=8000, env="PORT")
debug: bool = Field(default=False, env="DEBUG")

# Enabled by default: missing production configuration must never allow a job.
# Disable explicitly only for local development/tests.
turnstile_enabled: bool = True
turnstile_secret_key: SecretStr = SecretStr("")
turnstile_allowed_hostnames: str = ""
turnstile_expected_action: str = "backtest"
turnstile_timeout_seconds: float = Field(default=5.0, gt=0, le=10)

# CORS 설정
# CORS 설정: read raw env as string and expose a parsed property to avoid
Expand Down
6 changes: 6 additions & 0 deletions backtest_be_fast/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
단위 테스트는 DB 없이 실행 가능하도록 설계되었습니다.
"""

import os

# Unit/golden tests use no external credentials or network. Gate tests explicitly
# enable verification and exercise rejection before any job can start.
os.environ["TURNSTILE_ENABLED"] = "false"

import pytest
import asyncio
from typing import AsyncGenerator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@
from concurrent.futures import ThreadPoolExecutor
import json
import math
import os
from pathlib import Path
import urllib.error
import urllib.parse
import urllib.request


def verify(base):
def verify(base, token=None):
def call(path, payload=None):
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(base + path, data=body, headers={'Content-Type': 'application/json'})
headers = {'Content-Type': 'application/json'}
if payload is not None and token:
headers['X-Turnstile-Token'] = token
req = urllib.request.Request(base + path, data=body, headers=headers)
try:
response = urllib.request.urlopen(req, timeout=35)
except urllib.error.HTTPError as exc:
Expand Down Expand Up @@ -86,7 +90,8 @@ def call(path, payload=None):
url = urllib.parse.urlsplit(args.base_url)
if url.scheme != 'http' or url.hostname not in ('localhost', '127.0.0.1', '::1'):
parser.error('Only a local HTTP test stack is allowed')
report = verify(args.base_url.rstrip('/'))
# Only documented dummy tokens, against the localhost restriction above.
report = verify(args.base_url.rstrip('/'), os.getenv('TURNSTILE_TEST_TOKEN'))
if args.report:
args.report.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, indent=2))
7 changes: 7 additions & 0 deletions backtest_be_fast/tests/unit/test_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,12 @@ def test_preserves_production_routes_requests_and_responses():
assert set(actual['paths']) == set(expected['paths']) | reads
for path in reads:
assert 'get' in actual['paths'].pop(path)
# Only the calculation's verification header/403 response are additive.
post = actual['paths']['/api/v1/backtest']['post']
header = post.pop('parameters')
assert len(header) == 1
assert header[0]['name'] == 'X-Turnstile-Token'
assert header[0]['in'] == 'header' and header[0]['required'] is False
assert set(post['responses'].pop('403')) == {'description'}
for section in ("paths", "components"):
assert _contract(actual[section]) == expected[section]
140 changes: 140 additions & 0 deletions backtest_be_fast/tests/unit/test_turnstile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Human verification must reject before reserving a slot or doing data I/O."""
import json
from unittest.mock import Mock
import urllib.error

import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr

from app.main import app
from app.api.v1 import turnstile
from app.api.v1.endpoints import backtest
from app.core.config import Settings

pytestmark = pytest.mark.unit
PAYLOAD = {"portfolio": [{"symbol": "AAPL", "amount": 1000}],
"start_date": "2023-01-01", "end_date": "2023-12-31", "strategy": "buy_hold_strategy"}
GOOD = {"success": True, "hostname": "backtest.yeonjae.kr", "action": "backtest"}


@pytest.fixture
def gate(monkeypatch):
for name, value in {"turnstile_enabled": True, "turnstile_secret_key": SecretStr("test-only-secret"),
"turnstile_allowed_hostnames": "backtest.yeonjae.kr",
"turnstile_expected_action": "backtest"}.items():
monkeypatch.setattr(turnstile.settings, name, value)
verifier = Mock(return_value=GOOD)
runner_factory = Mock()
repository = Mock()
monkeypatch.setattr(turnstile, "_siteverify", verifier)
monkeypatch.setattr(backtest, "get_backtest_runner", runner_factory)
monkeypatch.setattr(backtest, "get_stock_repository", repository)
return verifier, runner_factory, repository


def test_enabled_by_default_and_secret_repr_redacted(monkeypatch):
monkeypatch.delenv("TURNSTILE_ENABLED")
settings = Settings(_env_file=None, turnstile_secret_key="not-a-real-secret")
assert settings.turnstile_enabled is True
assert "not-a-real-secret" not in repr(settings)


@pytest.mark.parametrize("headers", [{}, {"X-Turnstile-Token": ""}, {"X-Turnstile-Token": " "},
{"X-Turnstile-Token": "a" * 2049}, [("X-Turnstile-Token", "one"), ("X-Turnstile-Token", "two")]])
def test_missing_malformed_tokens_do_not_verify_or_start_work(gate, headers):
response = TestClient(app).post("/api/v1/backtest", json=PAYLOAD, headers=headers)
assert response.status_code == 403
for mock in gate:
mock.assert_not_called()


@pytest.mark.parametrize("result", [
{"success": False, "error-codes": ["invalid-input-response"]},
{"success": False, "error-codes": ["timeout-or-duplicate"]},
{**GOOD, "success": "true"}, {**GOOD, "hostname": "evil.example"},
{**GOOD, "hostname": "backtest.yeonjae.kr.evil.example"},
{**GOOD, "action": "login"}, {"success": True}, {**GOOD, "hostname": []},
])
def test_invalid_expired_replayed_or_wrong_scope_tokens_never_start_work(gate, result):
verifier, runner, repository = gate
verifier.return_value = result
response = TestClient(app).post("/api/v1/backtest", json=PAYLOAD,
headers={"X-Turnstile-Token": "opaque-token", "Host": "evil.example"})
assert response.status_code == 403
verifier.assert_called_once_with("opaque-token", "test-only-secret")
runner.assert_not_called()
repository.assert_not_called()


@pytest.mark.parametrize("error", [TimeoutError("secret should not leak"),
urllib.error.URLError("secret should not leak"), ValueError("secret should not leak")])
def test_outage_is_fail_closed_and_does_not_leak_details(gate, error):
verifier, runner, repository = gate
verifier.side_effect = error
response = TestClient(app).post("/api/v1/backtest", json=PAYLOAD, headers={"X-Turnstile-Token": "token"})
assert response.status_code == 503
assert "secret" not in response.text
runner.assert_not_called()
repository.assert_not_called()


@pytest.mark.parametrize("name,value", [("turnstile_secret_key", SecretStr("")),
("turnstile_allowed_hostnames", ""), ("turnstile_expected_action", "")])
def test_missing_configuration_does_not_bypass(gate, monkeypatch, name, value):
monkeypatch.setattr(turnstile.settings, name, value)
assert TestClient(app).post("/api/v1/backtest", json=PAYLOAD,
headers={"X-Turnstile-Token": "token"}).status_code == 503
for mock in gate:
mock.assert_not_called()


def test_valid_token_is_checked_before_existing_runner_and_replay_rejected(gate):
verifier, factory, repository = gate
class Runner:
async def run(self, work, **kwargs):
verifier.assert_called_once()
return {"status": "success", "data": {"calculation": "existing-result"}}
factory.return_value = Runner()
http = TestClient(app)
headers = {"X-Turnstile-Token": "single-use-token"}
first = http.post("/api/v1/backtest", json=PAYLOAD, headers=headers)
assert first.status_code == 200
verifier.return_value = {"success": False, "error-codes": ["timeout-or-duplicate"]}
assert http.post("/api/v1/backtest", json=PAYLOAD, headers=headers).status_code == 403
factory.assert_called_once()
repository.assert_not_called()


@pytest.mark.parametrize("path", ["prices", "exchange-rates", "benchmarks", "news"])
def test_read_routes_do_not_require_verification(gate, path):
# Missing required query -> existing schema validation, not a human challenge.
assert TestClient(app).get("/api/v1/" + path).status_code == 422
gate[0].assert_not_called()


def test_siteverify_uses_fixed_https_post_bounded_timeout_and_no_remoteip(monkeypatch):
response = Mock()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
response.read.return_value = json.dumps(GOOD).encode()
urlopen = Mock(return_value=response)
monkeypatch.setattr(turnstile.urllib.request, "urlopen", urlopen)
assert turnstile._siteverify("a+token", "test-only-secret") == GOOD
request = urlopen.call_args.args[0]
assert request.full_url == turnstile.SITEVERIFY_URL
assert request.get_method() == "POST"
assert request.data == b"secret=test-only-secret&response=a%2Btoken"
assert 0 < urlopen.call_args.kwargs["timeout"] <= 10
response.read.assert_called_once_with(16_385)


@pytest.mark.parametrize("body", [b"not-json", b"[]", b"x" * 16_385])
def test_malformed_siteverify_payload_rejected(monkeypatch, body):
response = Mock()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
response.read.return_value = body
monkeypatch.setattr(turnstile.urllib.request, "urlopen", Mock(return_value=response))
with pytest.raises(ValueError):
turnstile._siteverify("token", "test-only-secret")
1 change: 1 addition & 0 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ services:
- .env
environment:
- DEBUG=true
- TURNSTILE_ENABLED=${TURNSTILE_ENABLED:-false}
- WATCHFILES_FORCE_POLLING=false
volumes:
- ./backtest_be_fast:/app
Expand Down
Loading