From 0706a45b51c4ee15034c136872467eed75f04d2a Mon Sep 17 00:00:00 2001 From: kyj0503 Date: Sun, 25 Oct 2026 20:13:26 +0900 Subject: [PATCH] feat(fe): verify human users before running backtests --- .env.example | 3 + .github/workflows/cicd.yml | 10 ++ Dockerfile | 4 + README.md | 2 + docs/api-data-flow.md | 16 ++ nginx.prod.conf | 8 +- .../components/PortfolioBacktestForm.tsx | 18 ++- .../components/TurnstileVerification.tsx | 97 ++++++++++++ .../__tests__/TurnstileVerification.test.tsx | 141 ++++++++++++++++++ .../__tests__/marketDataLifecycle.test.ts | 33 ++++ .../backtest/hooks/usePortfolioBacktest.ts | 4 +- .../backtest/services/backtestService.ts | 6 +- src/vite-env.d.ts | 1 + 13 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 src/features/backtest/components/TurnstileVerification.tsx create mode 100644 src/features/backtest/components/__tests__/TurnstileVerification.test.tsx diff --git a/.env.example b/.env.example index b83d8b7..8cc1d8d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ # Public build settings only; never put backend/API secrets in VITE_* variables. VITE_API_BASE_URL= +# Public sitekey (never the secret). Empty disables the widget only in local dev. +# Local end-to-end tests may use Cloudflare's documented dummy sitekey. +VITE_TURNSTILE_SITE_KEY= # Native Vite development; Compose overrides this to host.docker.internal. FASTAPI_PROXY_TARGET=http://localhost:8000 diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index ec844d1..5325479 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -49,11 +49,21 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Require production Turnstile sitekey + env: + TURNSTILE_SITEKEY: ${{ vars.VITE_TURNSTILE_SITE_KEY }} + run: | + test -n "$TURNSTILE_SITEKEY" + case "$TURNSTILE_SITEKEY" in + [123]x000000*) echo 'Production must not use a dummy sitekey'; exit 1 ;; + esac - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 id: build with: context: . target: runtime + build-args: | + VITE_TURNSTILE_SITE_KEY=${{ vars.VITE_TURNSTILE_SITE_KEY }} platforms: linux/arm64 push: true tags: | diff --git a/Dockerfile b/Dockerfile index 650a4eb..ffd3edd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,6 +62,10 @@ RUN npm audit --audit-level=high --json > /tmp/audit.json 2>/dev/null || true; \ # Stage 4: Build the React application FROM deps AS build +# Public widget identifier only. The secret stays in the backend runtime .env. +ARG VITE_TURNSTILE_SITE_KEY +ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} + # Copy rest of sources COPY . . diff --git a/README.md b/README.md index b15b066..48b4a8d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ docker compose -f compose.dev.yaml up -d --build localhost:5173에서 접속합니다. Docker의 Vite 프록시는 호스트의 백엔드 :8000을 사용합니다. 네이티브 실행은 `.env.example`을 참고하여 `npm ci`, `npm run dev`를 사용합니다. `VITE_API_BASE_URL`은 비워 두어야 합니다. API 호출에 이미 `/api/v1/...` 전체 경로가 들어갑니다. +운영 빌드는 공개 `VITE_TURNSTILE_SITE_KEY`가 필요합니다. 계산 전에 사람 인증을 수행하고, +백엔드가 검증합니다. 위젯 비밀키는 백엔드 런타임 `.env`에서만 관리합니다. 계산 결과를 먼저 표시하고 주가·환율·벤치마크·뉴스는 네 API로 각각 조회·재시도합니다. 참고 자료의 조회·재시도·취소 흐름은 [API 데이터 흐름](docs/api-data-flow.md)을 참고합니다. diff --git a/docs/api-data-flow.md b/docs/api-data-flow.md index 74b07a2..8a23991 100644 --- a/docs/api-data-flow.md +++ b/docs/api-data-flow.md @@ -47,3 +47,19 @@ Docker 테스트는 네 자료 중 하나가 느리거나 실패한 경우, 병 배포용 FE·BE·격리 MySQL 브라우저 검사에서는 합성 시세·뉴스를 사용한다. 조회 네 개를 503으로 만든 뒤 하나씩 복구하고, 해당 GET만 증가하며 계산 POST 호출 수는 그대로인지 접근 로그로 확인한다. 운영 자격증명·운영 DB는 사용하지 않는다. + +## 계산 전 사람 인증 + +운영 화면은 Cloudflare Turnstile 인증을 완료해야 계산 버튼이 활성화된다. +공개 sitekey는 GitHub Actions Variable `VITE_TURNSTILE_SITE_KEY` → Docker 빌드 인자 +→ Vite 번들로 전달한다. 비밀키는 프론트엔드·GitHub에 넣지 않는다. + +토큰은 POST `/api/v1/backtest`의 `X-Turnstile-Token` 헤더로만 보내며 요청 JSON은 +변경하지 않는다. 백엔드 Siteverify 검증을 통과해야 계산이 시작된다. 403은 재인증, +503은 검증 불가/서버 혼잡 메시지로 표시한다. 성공·실패·취소 후에는 새 위젯을 +렌더링해 일회용 토큰을 다시 받는다. 입력값 검증 실패는 토큰을 소비하지 않는다. +만료·위젯 오류 시 버튼을 잠그며, 로딩 실패는 인증 재시도 버튼을 제공한다. + +아래 네 조회와 각 재시도는 토큰을 보내거나 계산을 재실행하지 않는다. +sitekey가 없는 운영 빌드는 계산 버튼을 잠근다. 위젯 없는 실행은 로컬 개발에서만 +허용하며, 자동 통합 검증은 Cloudflare 공식 더미 키를 사용한다. diff --git a/nginx.prod.conf b/nginx.prod.conf index 4c3f6b7..53792a7 100644 --- a/nginx.prod.conf +++ b/nginx.prod.conf @@ -25,8 +25,8 @@ server { # index-*.js에서 confirmed: e=document.createElement("style") 후 cssText # 대입). 앱에 nonce가 배선되어 있지 않아 style-src에서 'unsafe-inline'을 # 빼면 모달/드롭다운/셀렉트를 열 때마다 콘솔 에러와 함께 스크롤 잠금이 - # 깨진다. script-src는 인라인 스크립트/eval이 전혀 없어 'self'만으로 충분. - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; + # 깨진다. Turnstile 공식 스크립트·iframe만 추가 허용한다. 인라인 스크립트/eval은 허용하지 않는다. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; # P3-13: gzip 압축. base nginx.conf(이미지 기본)는 gzip이 꺼져 있고 이 리포는 # 그 파일을 건드릴 수 없으므로 서버 레벨에서 켠다. text/html은 gzip 모듈이 @@ -67,7 +67,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; add_header Cache-Control "public, max-age=31536000, immutable" always; try_files $uri =404; } @@ -79,7 +79,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; add_header Content-Type text/plain; return 200 'ok'; } diff --git a/src/features/backtest/components/PortfolioBacktestForm.tsx b/src/features/backtest/components/PortfolioBacktestForm.tsx index 45232e2..de397df 100644 --- a/src/features/backtest/components/PortfolioBacktestForm.tsx +++ b/src/features/backtest/components/PortfolioBacktestForm.tsx @@ -9,6 +9,7 @@ import CommissionForm from './CommissionForm'; import PortfolioForm from './PortfolioForm'; import { useBacktestForm } from '../hooks/useBacktestForm'; import { useFormValidation } from '../hooks/useFormValidation'; +import { TurnstileVerification } from './TurnstileVerification'; import { Button } from '@/shared/ui/button'; import { FormSection } from '@/shared/components'; import { @@ -20,7 +21,7 @@ import { } from '@/shared/ui/dialog'; interface PortfolioBacktestFormProps { - onSubmit: (request: BacktestRequest) => Promise; + onSubmit: (request: BacktestRequest, turnstileToken?: string) => Promise; loading?: boolean; } @@ -28,6 +29,10 @@ const PortfolioBacktestForm: React.FC = ({ onSubmit, const { state, actions } = useBacktestForm(); const { errors, validateForm } = useFormValidation(); const [showErrorModal, setShowErrorModal] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(''); + const [verificationAttempt, setVerificationAttempt] = useState(0); + const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? ''; + const verificationRequired = import.meta.env.PROD || !!siteKey; const generateStrategyParams = () => { const strategyParams = state.strategy.strategyParams; @@ -36,6 +41,7 @@ const PortfolioBacktestForm: React.FC = ({ onSubmit, const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (loading || state.ui.isLoading || (verificationRequired && !turnstileToken)) return; actions.setLoading(true); const isFormValid = validateForm(state); @@ -70,7 +76,7 @@ const PortfolioBacktestForm: React.FC = ({ onSubmit, rebalance_frequency: resolveRebalanceFrequency( state.settings.rebalanceFrequency, strategy, state.portfolio.length ) - }); + }, turnstileToken || undefined); } catch (error) { // 백엔드/네트워크 에러는 usePortfolioBacktest 훅이 extractErrorMessage로 // 실제 메시지를 추출해 페이지 레벨 Alert에 표시한다. 이 모달은 위쪽의 @@ -80,6 +86,9 @@ const PortfolioBacktestForm: React.FC = ({ onSubmit, // 닫아도 쓸모없는 Alert만 남는 문제가 있었다 (P2-29). console.error('백테스트 실행 중 오류:', extractErrorMessage(error)); } finally { + // Siteverify tokens are single-use, including a rejected/failed attempt. + setTurnstileToken(''); + setVerificationAttempt(value => value + 1); actions.setLoading(false); } }; @@ -178,11 +187,14 @@ const PortfolioBacktestForm: React.FC = ({ onSubmit, + {verificationRequired && } + {/* 제출 버튼 */}
} +
+ )} + + ); +} diff --git a/src/features/backtest/components/__tests__/TurnstileVerification.test.tsx b/src/features/backtest/components/__tests__/TurnstileVerification.test.tsx new file mode 100644 index 0000000..92610eb --- /dev/null +++ b/src/features/backtest/components/__tests__/TurnstileVerification.test.tsx @@ -0,0 +1,141 @@ +import { StrictMode } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TurnstileVerification, type TurnstileApi } from '../TurnstileVerification'; +import PortfolioBacktestForm from '../PortfolioBacktestForm'; +import type { BacktestRequest } from '../../model/types/api-types'; + +vi.mock('../DateRangeForm', () => ({ default: () => null })); +vi.mock('../StrategyForm', () => ({ default: () => null })); +vi.mock('../CommissionForm', () => ({ default: () => null })); +vi.mock('../PortfolioForm', () => ({ default: () => null })); +const validation = vi.hoisted(() => ({ valid: true })); +vi.mock('../../hooks/useFormValidation', () => ({ useFormValidation: () => ({ + errors: [], validateForm: () => validation.valid, +}) })); + +type Options = Parameters[1]; +let options: Options[]; +let api: TurnstileApi; +beforeEach(() => { + options = []; + api = { render: vi.fn((_container, config) => { options.push(config); return `widget-${options.length}`; }), remove: vi.fn() }; + window.turnstile = api; + validation.valid = true; + vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'public-sitekey'); +}); +afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); window.turnstile = undefined; }); + +describe('Turnstile calculation gate', () => { + it('keeps submit disabled until verified, then invalidates expired/error/timeout tokens', async () => { + render(); + const button = screen.getByRole('button', { name: '백테스트 실행' }); + expect(button).toBeDisabled(); + await waitFor(() => expect(options).toHaveLength(1)); + expect(options[0]!).toMatchObject({ sitekey: 'public-sitekey', action: 'backtest', 'response-field': false }); + for (const callback of ['expired-callback', 'error-callback', 'timeout-callback'] as const) { + act(() => options[0]!.callback('fresh-token')); + expect(button).toBeEnabled(); + act(() => options[0]![callback]()); + expect(button).toBeDisabled(); + } + }); + + it.each([false, true])('forwards a token separately and renews after success/error (error=%s)', async failure => { + const submit = vi.fn<(request: BacktestRequest, token?: string) => Promise>(async () => { + if (failure) throw new Error('request failed'); + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { container } = render(); + await waitFor(() => expect(options).toHaveLength(1)); + // Even a programmatic submit without the widget must be blocked by the form. + fireEvent.submit(container.querySelector('form')!); + expect(submit).not.toHaveBeenCalled(); + act(() => options[0]!.callback('single-use-token')); + fireEvent.submit(container.querySelector('form')!); + await waitFor(() => expect(options).toHaveLength(2)); + expect(submit).toHaveBeenCalledOnce(); + expect(submit.mock.calls[0]!).toHaveLength(2); + expect(submit.mock.calls[0]![1]).toBe('single-use-token'); + expect(submit.mock.calls[0]![0]).not.toHaveProperty('turnstileToken'); + expect(api.remove).toHaveBeenCalledWith('widget-1'); + expect(screen.getByRole('button', { name: '백테스트 실행' })).toBeDisabled(); + vi.restoreAllMocks(); + }); + + it('does not consume a token when local form validation fails', async () => { + validation.valid = false; + const submit = vi.fn(); + const { container } = render(); + await waitFor(() => expect(options).toHaveLength(1)); + act(() => options[0]!.callback('not-yet-consumed')); + fireEvent.submit(container.querySelector('form')!); + expect(submit).not.toHaveBeenCalled(); + expect(api.remove).not.toHaveBeenCalled(); + expect(options).toHaveLength(1); + }); + + it('recovers widget failure and ignores callbacks from a removed widget', async () => { + const onToken = vi.fn(); + const { unmount } = render(); + await waitFor(() => expect(options).toHaveLength(1)); + act(() => options[0]!['error-callback']()); + fireEvent.click(screen.getByRole('button', { name: '인증 다시 시도' })); + await waitFor(() => expect(options).toHaveLength(2)); + onToken.mockClear(); + act(() => options[0]!.callback('stale')); + expect(onToken).not.toHaveBeenCalled(); + unmount(); + act(() => options[1]!.callback('unmounted')); + expect(onToken).not.toHaveBeenCalled(); + expect(api.remove).toHaveBeenCalledTimes(2); + }); + + it('fails closed when the public production configuration is absent', () => { + vi.stubEnv('PROD', true); + vi.stubEnv('VITE_TURNSTILE_SITE_KEY', ''); + render(); + expect(screen.getByRole('alert')).toHaveTextContent('인증을 불러오지 못했습니다'); + expect(screen.getByRole('button', { name: '백테스트 실행' })).toBeDisabled(); + expect(api.render).not.toHaveBeenCalled(); + }); + + it('fails closed when the vendor script is blocked and allows an explicit retry', async () => { + window.turnstile = undefined; + let script!: HTMLScriptElement; + // Prevent happy-dom from fetching the actual vendor script. Drive its load/error events. + vi.spyOn(document.head, 'appendChild').mockImplementation((node: T): T => { + if (node instanceof HTMLScriptElement) script = node; + return node; + }); + render(); + expect(window.turnstile).toBeUndefined(); + expect(script).toBeDefined(); + expect(script.src).toBe('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'); + fireEvent.error(script); + expect(await screen.findByRole('alert')).toHaveTextContent('인증을 불러오지 못했습니다'); + window.turnstile = api; + fireEvent.click(screen.getByRole('button', { name: '인증 다시 시도' })); + await waitFor(() => expect(options).toHaveLength(1)); + act(() => options[0]!.callback('retry-token')); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders only after the official script has loaded', async () => { + window.turnstile = undefined; + let script!: HTMLScriptElement; + // Prevent happy-dom from fetching the actual vendor script. Drive its load/error events. + vi.spyOn(document.head, 'appendChild').mockImplementation((node: T): T => { + if (node instanceof HTMLScriptElement) script = node; + return node; + }); + render(); + expect(window.turnstile).toBeUndefined(); + expect(script).toBeDefined(); + expect(api.render).not.toHaveBeenCalled(); + window.turnstile = api; + fireEvent.load(script); + await waitFor(() => expect(options).toHaveLength(1)); + script.remove(); + }); +}); diff --git a/src/features/backtest/hooks/__tests__/marketDataLifecycle.test.ts b/src/features/backtest/hooks/__tests__/marketDataLifecycle.test.ts index 5c453cc..b027fc2 100644 --- a/src/features/backtest/hooks/__tests__/marketDataLifecycle.test.ts +++ b/src/features/backtest/hooks/__tests__/marketDataLifecycle.test.ts @@ -25,6 +25,39 @@ function deferred() { } describe('independent market-data lifecycles', () => { + it('forwards human verification only to calculation; read retries need no token', async () => { + const seen: (string | null)[] = []; + server.use( + http.post(`${origin}/api/v1/backtest`, async ({ request: incoming }) => { + expect(incoming.headers.get('X-Turnstile-Token')).toBe('one-use-token'); + expect(await incoming.json()).not.toHaveProperty('turnstileToken'); + return HttpResponse.json(makePortfolioResponse()); + }), + ...MARKET_SECTIONS.map(section => http.get(`${origin}/api/v1/${paths[section]}`, ({ request: incoming }) => { + seen.push(incoming.headers.get('X-Turnstile-Token')); + return success(); + })), + ); + const { result } = renderHook(() => useBacktest()); + await act(async () => { await result.current.runBacktest(request, 'one-use-token'); }); + await waitFor(() => expect(seen).toHaveLength(4)); + await waitFor(() => expect(result.current.marketReads.news.phase).toBe('success')); + act(() => result.current.retryMarketData('news')); + await waitFor(() => expect(seen).toHaveLength(5)); + expect(seen.every(value => value === null)).toBe(true); + }); + + it('shows a verification rejection without starting reference reads', async () => { + server.use(http.post(`${origin}/api/v1/backtest`, () => HttpResponse.json( + { detail: '사람 인증이 필요합니다.' }, { status: 403 }, + ))); + const { result } = renderHook(() => useBacktest()); + await act(async () => { await expect(result.current.runBacktest(request, 'expired')).rejects.toThrow(); }); + expect(result.current.error).toBe('사람 인증이 필요합니다.'); + expect(result.current.result).toBeNull(); + expect(MARKET_SECTIONS.every(section => result.current.marketReads[section].phase === 'idle')).toBe(true); + }); + it('shows calculation and other feeds while news is pending; merges concurrent results', async () => { const release = deferred(); let body: BacktestRequest | undefined; diff --git a/src/features/backtest/hooks/usePortfolioBacktest.ts b/src/features/backtest/hooks/usePortfolioBacktest.ts index 1acc0c7..7df550c 100644 --- a/src/features/backtest/hooks/usePortfolioBacktest.ts +++ b/src/features/backtest/hooks/usePortfolioBacktest.ts @@ -116,7 +116,7 @@ export function useBacktest() { } }, []); - const runBacktest = useCallback(async (request: BacktestRequest) => { + const runBacktest = useCallback(async (request: BacktestRequest, turnstileToken?: string) => { abortCurrent(); const controller = new AbortController(); activeControllerRef.current = controller; @@ -128,7 +128,7 @@ export function useBacktest() { const data = await BacktestService.executeBacktest({ ...request, include_stock_data: false, include_volatility_events: false, include_exchange_rates: false, include_benchmarks: false, include_news: false, - }, controller.signal); + }, controller.signal, turnstileToken); if (activeControllerRef.current === controller) { setResult(data); if ('portfolio_statistics' in data.data && Array.isArray(data.data.portfolio_composition)) { diff --git a/src/features/backtest/services/backtestService.ts b/src/features/backtest/services/backtestService.ts index d100695..4451048 100644 --- a/src/features/backtest/services/backtestService.ts +++ b/src/features/backtest/services/backtestService.ts @@ -17,8 +17,10 @@ export class BacktestService { /** * 백테스트 실행 (단일 종목 또는 포트폴리오) */ - static async executeBacktest(request: BacktestRequest, signal?: AbortSignal): Promise { - const response = await apiClient.post('/api/v1/backtest', request, { signal }); + static async executeBacktest(request: BacktestRequest, signal?: AbortSignal, turnstileToken?: string): Promise { + const response = await apiClient.post('/api/v1/backtest', request, { + signal, headers: turnstileToken ? { 'X-Turnstile-Token': turnstileToken } : undefined, + }); return response.data; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 0b66880..9e67275 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; + readonly VITE_TURNSTILE_SITE_KEY?: string; readonly DEV: boolean; readonly PROD: boolean; readonly MODE: string;