diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 239de7f..bfc00ac 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -160,7 +160,26 @@ "Bash(/usr/bin/grep -nE \"^ [a-z0-9_-]+:\" docker-compose.yml)", "Bash(/usr/bin/grep -nE \"^#{1,3} \" deploy/NAS-SETUP-GUIDE.md)", "Bash(/usr/bin/head -30)", - "Bash(/usr/bin/grep -nE \"stage\\\\\\(|DEPLOY_DIR|sh '|sh \\\\\"\" Jenkinsfile)" + "Bash(/usr/bin/grep -nE \"stage\\\\\\(|DEPLOY_DIR|sh '|sh \\\\\"\" Jenkinsfile)", + "Bash(/usr/bin/awk '/^ [a-z0-9_-]+:/{svc=$1} /ports:/{p=1;next} p&&/^ *- /{print \" \"svc\" \"$2} p&&!/^ *- /{p=0}' docker-compose.yml)", + "Bash(/usr/bin/grep -nE \"^ [a-z0-9_-]+:|ports:|^ - \\\\\"\" docker-compose.yml)", + "Bash(/usr/bin/head -40)", + "Bash(/usr/bin/grep -rn \"proxy_pass\\\\|location /api\\\\|location /\" frontend/nginx.conf frontend/default.conf docker/nginx*.conf)", + "Bash(/usr/bin/grep -nE \"server_name|location|proxy_pass|listen\" deploy/nginx-reverse-proxy.conf)", + "Bash(/usr/bin/grep -nE \"listen|server_name|location|proxy_pass|try_files|root\" frontend/nginx.conf)", + "Bash(/usr/bin/grep -A2 \"^ frontend:\" docker-compose.yml)", + "Bash(/usr/bin/head -1)", + "Bash(/usr/bin/grep -nE \"3001\" docker-compose.yml)", + "Bash(/usr/bin/grep -nE \"3001|3000|:80\\\\\"\")", + "Bash(/usr/bin/grep -E \"^[+-].*\\(ports|[0-9]{4}:\\)\")", + "Bash(/usr/bin/curl -sk -o /tmp/stock.html -w \" HTTP %{http_code} / %{size_download} bytes / %{time_total}s\\\\n\" --max-time 10 https://stock.shium.net/)", + "Bash(/usr/bin/head -c 300 /tmp/stock.html)", + "Bash(/usr/bin/curl -sk -o /dev/null -w \" /api/v1/market-data/prices → HTTP %{http_code}\\\\n\" --max-time 10 \"https://stock.shium.net/api/v1/market-data/prices?codes=005930\")", + "Bash(/usr/bin/tail -5)", + "Bash(/usr/bin/head -2)", + "Bash(/usr/bin/cat backend/Dockerfile)", + "Bash(/usr/bin/cat backend/requirements.txt)", + "Bash(docker --version)" ] }, "enableAllProjectMcpServers": true, diff --git a/.env.example b/.env.example index 119b721..833044a 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,13 @@ BACKEND_HOST=0.0.0.0 BACKEND_PORT=8000 SECRET_KEY=your-secret-key-here +# 대시보드 로그인 계정 (ADMIN_PASSWORD_HASH 우선, 없으면 ADMIN_PASSWORD 평문. 둘 다 비면 로그인 불가) +# 해시 생성: python -c "import bcrypt; print(bcrypt.hashpw(b'비밀번호', bcrypt.gensalt()).decode())" +ADMIN_USERNAME=admin +ADMIN_PASSWORD= +ADMIN_PASSWORD_HASH= +SESSION_TOKEN_EXPIRE_HOURS=24 + # ═══════════════════════════════════════════════════════════════════════════════ # 데이터베이스 및 캐시 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index e372f5c..0000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: CI/CD Pipeline - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - -jobs: - test-backend: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - cd backend - pip install -r requirements.txt - - - name: Run tests - run: | - cd backend - pytest - - test-frontend: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '20' - - - name: Install dependencies - run: | - cd frontend - npm install - - - name: Build - run: | - cd frontend - npm run build - - build-and-push: - needs: [test-backend, test-frontend] - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' - - steps: - - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push - run: | - docker-compose build - docker-compose push diff --git a/Jenkinsfile b/Jenkinsfile index ad116c1..3995d30 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -4,6 +4,9 @@ pipeline { environment { DEPLOY_DIR = '/volume1/docker/autostock' ENV_FILE = "${DEPLOY_DIR}/.env" + // Dockerfile 의 pip/npm 캐시 마운트(RUN --mount=type=cache)를 쓰려면 BuildKit 필요 + DOCKER_BUILDKIT = '1' + COMPOSE_DOCKER_CLI_BUILD = '1' } options { diff --git a/backend/Dockerfile b/backend/Dockerfile index a2523d6..9db07e7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM python:3.11-slim WORKDIR /app @@ -13,7 +14,17 @@ RUN apt-get update && apt-get install -y \ # Python 의존성 설치 (requirements.txt 변경 시에만 재설치 — Docker 캐시 활용) COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt + +# PyTorch는 CPU 전용 휠로 먼저 설치. +# PyPI 기본 torch 휠은 NVIDIA CUDA 패키지(nvidia-cublas, nvidia-cudnn 등)를 +# 수 GB 함께 내려받지만 NAS에 GPU가 없어 전부 불필요하다. +# 여기서 먼저 깔아두면 아래 requirements.txt 의 torch 줄은 이미 충족되어 건너뛴다. +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install torch --index-url https://download.pytorch.org/whl/cpu + +# 나머지 의존성 — pip 캐시를 빌드 간 재사용 (BuildKit 필요) +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt # 애플리케이션 코드 복사 (코드 변경 시에만 이 레이어 갱신) COPY . . diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index bf54784..1401bd5 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -14,11 +14,14 @@ from app.api.endpoints import ( journals, day_trading, strategy_config, backtest, analysis, swing_portfolio, daytrading_v2, us_trading, kr_backtest, market_intelligence, market_playbook, news, - adaptive_strategy, manual_buy, + adaptive_strategy, manual_buy, auth, ) api_router = APIRouter() +# ── 로그인 (인증 불필요 — 자체 자격 증명 검증) ─────────────────────────── +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) + # ── 매매 실행 / 설정 변경 API (require_api_key) ─────────────────────────── api_router.include_router( trading_rules.router, prefix="/rules", tags=["trading_rules"], diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py new file mode 100644 index 0000000..1736091 --- /dev/null +++ b/backend/app/api/endpoints/auth.py @@ -0,0 +1,128 @@ +"""대시보드 로그인 엔드포인트 (아이디/비밀번호 → 세션 토큰). + +POST /api/v1/auth/login {username, password} → {token, expires_at, username} +GET /api/v1/auth/me (X-API-Key: 세션 토큰 또는 API 키) → 현재 인증 주체 + +- 자격 증명은 .env 의 ADMIN_USERNAME + ADMIN_PASSWORD_HASH(bcrypt) 또는 ADMIN_PASSWORD(평문) +- 발급 토큰은 기존 X-API-Key 헤더로 전송 → security.require_api_key 가 세션 토큰도 수용 +- 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금(인메모리) +""" +import hmac +import logging +import time +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Request, status +import bcrypt +from pydantic import BaseModel, Field + +from app.core.config import settings +from app.core.security import ( + _get_api_key, + create_session_token, + require_api_key, + verify_session_token, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + +# IP별 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각)} +_failures: dict[str, tuple[int, float]] = {} + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=64) + password: str = Field(..., min_length=1, max_length=256) + + +class LoginResponse(BaseModel): + token: str + expires_at: datetime + username: str + + +def _login_enabled() -> bool: + return bool(settings.ADMIN_PASSWORD_HASH or settings.ADMIN_PASSWORD) + + +def _check_password(password: str) -> bool: + if settings.ADMIN_PASSWORD_HASH: + # passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용 + try: + return bcrypt.checkpw(password.encode()[:72], settings.ADMIN_PASSWORD_HASH.encode()) + except ValueError: + logger.error("[Auth] ADMIN_PASSWORD_HASH 형식 오류 — bcrypt 해시가 아님") + return False + return hmac.compare_digest(password.encode(), settings.ADMIN_PASSWORD.encode()) + + +def _client_ip(request: Request) -> str: + fwd = request.headers.get("x-forwarded-for") + if fwd: + return fwd.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _lockout_remaining(ip: str) -> int: + rec = _failures.get(ip) + if not rec: + return 0 + count, last = rec + if count < settings.LOGIN_MAX_FAILURES: + return 0 + remaining = int(settings.LOGIN_LOCKOUT_SECONDS - (time.monotonic() - last)) + if remaining <= 0: + _failures.pop(ip, None) + return 0 + return remaining + + +def _record_failure(ip: str) -> None: + count, _ = _failures.get(ip, (0, 0.0)) + _failures[ip] = (count + 1, time.monotonic()) + # 오래된 항목 정리 (메모리 누수 방지) + if len(_failures) > 1000: + cutoff = time.monotonic() - settings.LOGIN_LOCKOUT_SECONDS + for k in [k for k, (_, t) in _failures.items() if t < cutoff]: + _failures.pop(k, None) + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest, request: Request): + if not _login_enabled(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="로그인이 설정되지 않았습니다. .env 에 ADMIN_PASSWORD 또는 ADMIN_PASSWORD_HASH 를 설정하세요.", + ) + + ip = _client_ip(request) + remaining = _lockout_remaining(ip) + if remaining > 0: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"로그인 시도가 너무 많습니다. {remaining}초 후 다시 시도하세요.", + ) + + user_ok = hmac.compare_digest(body.username.encode(), settings.ADMIN_USERNAME.encode()) + pw_ok = _check_password(body.password) + if not (user_ok and pw_ok): + _record_failure(ip) + logger.warning(f"[Auth] 로그인 실패 ip={ip} user={body.username!r}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="아이디 또는 비밀번호가 올바르지 않습니다.", + ) + + _failures.pop(ip, None) + token, expires_at = create_session_token(settings.ADMIN_USERNAME) + logger.info(f"[Auth] 로그인 성공 user={settings.ADMIN_USERNAME} ip={ip}") + return LoginResponse(token=token, expires_at=expires_at, username=settings.ADMIN_USERNAME) + + +@router.get("/me") +async def me(credential: str = Depends(require_api_key)): + """현재 자격 증명 확인 — 세션 토큰이면 username, API 키면 'api-key'.""" + if hmac.compare_digest(credential.encode(), _get_api_key().encode()): + return {"username": "api-key", "auth_type": "api_key"} + return {"username": verify_session_token(credential), "auth_type": "session"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d92fbcf..a10ec9f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,6 +23,15 @@ class Settings(BaseSettings): DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화) SECRET_KEY: str = "change-this-secret-key" # JWT/WS 서명용 API_KEY: str = "" # API 인증용 (비어있으면 SECRET_KEY 폴백) + # ── 대시보드 로그인 (아이디/비밀번호) ──────────────────────────────── + # ADMIN_PASSWORD_HASH(bcrypt) 우선, 없으면 ADMIN_PASSWORD(평문) 사용. 둘 다 비면 로그인 비활성(503) + # 해시 생성: python -c "import bcrypt; print(bcrypt.hashpw(b'비밀번호', bcrypt.gensalt()).decode())" + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str = "" + ADMIN_PASSWORD_HASH: str = "" + SESSION_TOKEN_EXPIRE_HOURS: int = 24 # 로그인 세션 토큰 유효시간 + LOGIN_MAX_FAILURES: int = 5 # IP당 연속 실패 허용 횟수 + LOGIN_LOCKOUT_SECONDS: int = 300 # 초과 시 잠금 시간(초) BACKEND_HOST: str = "0.0.0.0" BACKEND_PORT: int = 8000 diff --git a/backend/app/core/security.py b/backend/app/core/security.py index c7ad13d..fd8ef7a 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -16,13 +16,21 @@ import hmac import logging import secrets +from datetime import datetime, timedelta, timezone + from fastapi import Depends, HTTPException, 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" + # API 키 헤더 스키마 _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) @@ -37,6 +45,30 @@ def _get_api_key() -> str: return settings.API_KEY if settings.API_KEY else settings.SECRET_KEY +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, settings.SECRET_KEY, algorithm=_SESSION_ALG), expires_at + + +def verify_session_token(token: str) -> str | None: + """세션 토큰 검증. 유효하면 username, 아니면 None (서명/만료/대상 불일치).""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[_SESSION_ALG], audience=_SESSION_AUD) + except JWTError: + return None + sub = payload.get("sub") + return sub if isinstance(sub, str) and sub else None + + +def is_valid_credential(token: str) -> bool: + """X-API-Key 값이 API 키이거나 유효한 세션 토큰이면 True.""" + if _constant_time_compare(token, _get_api_key()): + return True + return verify_session_token(token) is not None + + async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str: """매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용. @@ -49,9 +81,8 @@ async def require_api_key(api_key: str | None = Security(_api_key_header)) -> st detail="API 키가 필요합니다. X-API-Key 헤더를 설정하세요.", ) - expected = _get_api_key() - if not _constant_time_compare(api_key, expected): - logger.warning("[Security] API 키 불일치 — 요청 거부") + if not is_valid_credential(api_key): + logger.warning("[Security] API 키/세션 토큰 불일치 — 요청 거부") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="유효하지 않은 API 키입니다.", @@ -68,8 +99,7 @@ async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> s if not api_key: return None - expected = _get_api_key() - if not _constant_time_compare(api_key, expected): + if not is_valid_credential(api_key): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="유효하지 않은 API 키입니다.", diff --git a/backend/app/main.py b/backend/app/main.py index 7ef5ee4..958ecb5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -610,13 +610,11 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): → {"type": "chart_data", ...} 그 외 메시지 → echo """ - # WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY 또는 SECRET_KEY 허용) + # WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용) if not settings.DEBUG: - import hmac - from app.core.security import _get_api_key + from app.core.security import is_valid_credential token = websocket.query_params.get("token", "") - expected = _get_api_key() - if not token or not hmac.compare_digest(token.encode(), expected.encode()): + if not token or not is_valid_credential(token): await websocket.close(code=4001, reason="Unauthorized") return diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3afa6da..75ac597 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,11 +1,13 @@ +# syntax=docker/dockerfile:1 # Build stage -FROM node:20-alpine as build +FROM node:20-alpine AS build WORKDIR /app -# 의존성 설치 +# 의존성 설치 — npm 캐시를 빌드 간 재사용 (BuildKit 필요) COPY package*.json ./ -RUN npm install +RUN --mount=type=cache,target=/root/.npm \ + npm install # 애플리케이션 빌드 COPY . . diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ad8aca..9421ddc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ * * 역할: * - React Router 기반 페이지 라우팅 관리 - * - API 키 기반 인증 상태 관리 (localStorage 저장) + * - 로그인 세션 토큰 기반 인증 상태 관리 (localStorage 'api_key' 에 저장, X-API-Key 헤더로 전송) * - ErrorBoundary를 통한 전역 에러 핸들링 * - React.lazy + Suspense를 이용한 코드 스플리팅 (대시보드 제외) * @@ -90,7 +90,7 @@ function App() { // localStorage에서 api_key 존재 여부로 초기 인증 상태 결정 (lazy initializer) const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('api_key')) - // 로그인 성공 콜백 — API 키를 localStorage에 저장하고 인증 상태 전환 + // 로그인 성공 콜백 — 세션 토큰을 localStorage에 저장하고 인증 상태 전환 (키 이름은 api.ts/useWebSocket 호환 유지) const handleLogin = useCallback((apiKey: string) => { localStorage.setItem('api_key', apiKey) setIsAuthenticated(true) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9d8b837..7ad7c47 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -8,7 +8,7 @@ * * 구조: * [Header] — sticky 상단 바 (z-50) - * - 브랜드 로고 (AutoStock GEN 3 ALGO) + * - 브랜드 로고 (AutoStock) * - 네비게이션 링크 (대시보드, 성과분석, 전략설정, 백테스트, 매매로그, MCP상태) * - 연결 상태 표시 (Backend LIVE/OFFLINE, MCP connected/unknown) * [Main] — 페이지 컨텐츠 영역 @@ -78,12 +78,7 @@ const Layout = ({ children }: LayoutProps) => {
GEN 3 ALGO
-GEN 3 ALGO
+자동매매 대시보드