Compare commits
6 Commits
e339e80f1a
...
06c9f7daa6
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c9f7daa6 | |||
| 9063a9d01e | |||
| f8c4612650 | |||
| 19d25e5528 | |||
| 64e377d6bc | |||
| afd98d7fd6 |
@@ -160,7 +160,26 @@
|
|||||||
"Bash(/usr/bin/grep -nE \"^ [a-z0-9_-]+:\" docker-compose.yml)",
|
"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/grep -nE \"^#{1,3} \" deploy/NAS-SETUP-GUIDE.md)",
|
||||||
"Bash(/usr/bin/head -30)",
|
"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,
|
"enableAllProjectMcpServers": true,
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ BACKEND_HOST=0.0.0.0
|
|||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
SECRET_KEY=your-secret-key-here
|
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
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
# 데이터베이스 및 캐시
|
# 데이터베이스 및 캐시
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
72
.github/workflows/ci-cd.yml
vendored
72
.github/workflows/ci-cd.yml
vendored
@@ -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
|
|
||||||
3
Jenkinsfile
vendored
3
Jenkinsfile
vendored
@@ -4,6 +4,9 @@ pipeline {
|
|||||||
environment {
|
environment {
|
||||||
DEPLOY_DIR = '/volume1/docker/autostock'
|
DEPLOY_DIR = '/volume1/docker/autostock'
|
||||||
ENV_FILE = "${DEPLOY_DIR}/.env"
|
ENV_FILE = "${DEPLOY_DIR}/.env"
|
||||||
|
// Dockerfile 의 pip/npm 캐시 마운트(RUN --mount=type=cache)를 쓰려면 BuildKit 필요
|
||||||
|
DOCKER_BUILDKIT = '1'
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD = '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
options {
|
options {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -13,7 +14,17 @@ RUN apt-get update && apt-get install -y \
|
|||||||
|
|
||||||
# Python 의존성 설치 (requirements.txt 변경 시에만 재설치 — Docker 캐시 활용)
|
# Python 의존성 설치 (requirements.txt 변경 시에만 재설치 — Docker 캐시 활용)
|
||||||
COPY requirements.txt .
|
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 . .
|
COPY . .
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ from app.api.endpoints import (
|
|||||||
journals, day_trading, strategy_config, backtest, analysis,
|
journals, day_trading, strategy_config, backtest, analysis,
|
||||||
swing_portfolio, daytrading_v2, us_trading, kr_backtest,
|
swing_portfolio, daytrading_v2, us_trading, kr_backtest,
|
||||||
market_intelligence, market_playbook, news,
|
market_intelligence, market_playbook, news,
|
||||||
adaptive_strategy, manual_buy,
|
adaptive_strategy, manual_buy, auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
# ── 로그인 (인증 불필요 — 자체 자격 증명 검증) ───────────────────────────
|
||||||
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
# ── 매매 실행 / 설정 변경 API (require_api_key) ───────────────────────────
|
# ── 매매 실행 / 설정 변경 API (require_api_key) ───────────────────────────
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
trading_rules.router, prefix="/rules", tags=["trading_rules"],
|
trading_rules.router, prefix="/rules", tags=["trading_rules"],
|
||||||
|
|||||||
128
backend/app/api/endpoints/auth.py
Normal file
128
backend/app/api/endpoints/auth.py
Normal file
@@ -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"}
|
||||||
@@ -23,6 +23,15 @@ class Settings(BaseSettings):
|
|||||||
DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화)
|
DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화)
|
||||||
SECRET_KEY: str = "change-this-secret-key" # JWT/WS 서명용
|
SECRET_KEY: str = "change-this-secret-key" # JWT/WS 서명용
|
||||||
API_KEY: str = "" # API 인증용 (비어있으면 SECRET_KEY 폴백)
|
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_HOST: str = "0.0.0.0"
|
||||||
BACKEND_PORT: int = 8000
|
BACKEND_PORT: int = 8000
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,21 @@
|
|||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Security, status
|
from fastapi import Depends, HTTPException, Security, status
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 로그인 세션 토큰(JWT, HS256). 헤더는 기존 X-API-Key 를 그대로 사용하므로
|
||||||
|
# 프론트 api 클라이언트·WebSocket 은 변경 없이 세션 토큰을 실어 보낼 수 있다.
|
||||||
|
_SESSION_ALG = "HS256"
|
||||||
|
_SESSION_AUD = "autostock-dashboard"
|
||||||
|
|
||||||
# API 키 헤더 스키마
|
# API 키 헤더 스키마
|
||||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
_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
|
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:
|
async def require_api_key(api_key: str | None = Security(_api_key_header)) -> str:
|
||||||
"""매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용.
|
"""매매 실행, 관리 API 등 민감한 엔드포인트에 필수 적용.
|
||||||
|
|
||||||
@@ -49,9 +81,8 @@ async def require_api_key(api_key: str | None = Security(_api_key_header)) -> st
|
|||||||
detail="API 키가 필요합니다. X-API-Key 헤더를 설정하세요.",
|
detail="API 키가 필요합니다. X-API-Key 헤더를 설정하세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
expected = _get_api_key()
|
if not is_valid_credential(api_key):
|
||||||
if not _constant_time_compare(api_key, expected):
|
logger.warning("[Security] API 키/세션 토큰 불일치 — 요청 거부")
|
||||||
logger.warning("[Security] API 키 불일치 — 요청 거부")
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="유효하지 않은 API 키입니다.",
|
detail="유효하지 않은 API 키입니다.",
|
||||||
@@ -68,8 +99,7 @@ async def optional_api_key(api_key: str | None = Security(_api_key_header)) -> s
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
expected = _get_api_key()
|
if not is_valid_credential(api_key):
|
||||||
if not _constant_time_compare(api_key, expected):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="유효하지 않은 API 키입니다.",
|
detail="유효하지 않은 API 키입니다.",
|
||||||
|
|||||||
@@ -610,13 +610,11 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
|||||||
→ {"type": "chart_data", ...}
|
→ {"type": "chart_data", ...}
|
||||||
그 외 메시지 → echo
|
그 외 메시지 → echo
|
||||||
"""
|
"""
|
||||||
# WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY 또는 SECRET_KEY 허용)
|
# WebSocket 인증: 운영 환경에서는 토큰 검증 (API_KEY/SECRET_KEY 또는 로그인 세션 토큰 허용)
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
import hmac
|
from app.core.security import is_valid_credential
|
||||||
from app.core.security import _get_api_key
|
|
||||||
token = websocket.query_params.get("token", "")
|
token = websocket.query_params.get("token", "")
|
||||||
expected = _get_api_key()
|
if not token or not is_valid_credential(token):
|
||||||
if not token or not hmac.compare_digest(token.encode(), expected.encode()):
|
|
||||||
await websocket.close(code=4001, reason="Unauthorized")
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20-alpine as build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 의존성 설치
|
# 의존성 설치 — npm 캐시를 빌드 간 재사용 (BuildKit 필요)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install
|
||||||
|
|
||||||
# 애플리케이션 빌드
|
# 애플리케이션 빌드
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 역할:
|
* 역할:
|
||||||
* - React Router 기반 페이지 라우팅 관리
|
* - React Router 기반 페이지 라우팅 관리
|
||||||
* - API 키 기반 인증 상태 관리 (localStorage 저장)
|
* - 로그인 세션 토큰 기반 인증 상태 관리 (localStorage 'api_key' 에 저장, X-API-Key 헤더로 전송)
|
||||||
* - ErrorBoundary를 통한 전역 에러 핸들링
|
* - ErrorBoundary를 통한 전역 에러 핸들링
|
||||||
* - React.lazy + Suspense를 이용한 코드 스플리팅 (대시보드 제외)
|
* - React.lazy + Suspense를 이용한 코드 스플리팅 (대시보드 제외)
|
||||||
*
|
*
|
||||||
@@ -90,7 +90,7 @@ function App() {
|
|||||||
// localStorage에서 api_key 존재 여부로 초기 인증 상태 결정 (lazy initializer)
|
// localStorage에서 api_key 존재 여부로 초기 인증 상태 결정 (lazy initializer)
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('api_key'))
|
const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('api_key'))
|
||||||
|
|
||||||
// 로그인 성공 콜백 — API 키를 localStorage에 저장하고 인증 상태 전환
|
// 로그인 성공 콜백 — 세션 토큰을 localStorage에 저장하고 인증 상태 전환 (키 이름은 api.ts/useWebSocket 호환 유지)
|
||||||
const handleLogin = useCallback((apiKey: string) => {
|
const handleLogin = useCallback((apiKey: string) => {
|
||||||
localStorage.setItem('api_key', apiKey)
|
localStorage.setItem('api_key', apiKey)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*
|
*
|
||||||
* 구조:
|
* 구조:
|
||||||
* [Header] — sticky 상단 바 (z-50)
|
* [Header] — sticky 상단 바 (z-50)
|
||||||
* - 브랜드 로고 (AutoStock GEN 3 ALGO)
|
* - 브랜드 로고 (AutoStock)
|
||||||
* - 네비게이션 링크 (대시보드, 성과분석, 전략설정, 백테스트, 매매로그, MCP상태)
|
* - 네비게이션 링크 (대시보드, 성과분석, 전략설정, 백테스트, 매매로그, MCP상태)
|
||||||
* - 연결 상태 표시 (Backend LIVE/OFFLINE, MCP connected/unknown)
|
* - 연결 상태 표시 (Backend LIVE/OFFLINE, MCP connected/unknown)
|
||||||
* [Main] — 페이지 컨텐츠 영역
|
* [Main] — 페이지 컨텐츠 영역
|
||||||
@@ -78,12 +78,7 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
|
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
|
||||||
<Activity className="text-white" size={20} />
|
<Activity className="text-white" size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h1 className="text-lg font-semibold tracking-tight leading-none">AutoStock</h1>
|
||||||
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent leading-none">
|
|
||||||
AutoStock
|
|
||||||
</h1>
|
|
||||||
<p className="text-[10px] text-gray-500 font-mono tracking-wider">GEN 3 ALGO</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|||||||
@@ -2,47 +2,48 @@
|
|||||||
* Login.tsx — 로그인 페이지 컴포넌트
|
* Login.tsx — 로그인 페이지 컴포넌트
|
||||||
*
|
*
|
||||||
* 역할:
|
* 역할:
|
||||||
* - API 키(SECRET_KEY) 입력을 통한 사용자 인증 처리
|
* - 아이디/비밀번호 입력 → POST /api/v1/auth/login 으로 세션 토큰 발급
|
||||||
* - 백엔드 portfolio API로 키 유효성 검증 후 App 컴포넌트에 인증 콜백 전달
|
* - 발급된 토큰을 App 컴포넌트에 전달 (App 이 localStorage 'api_key' 에 저장,
|
||||||
* - 인증 실패 시 에러 메시지 표시 (401/403 또는 네트워크 오류)
|
* 이후 api.ts / useWebSocket 이 기존 X-API-Key 경로로 그대로 전송)
|
||||||
|
* - 인증 실패(401)·잠금(429)·미설정(503)·네트워크 오류 메시지 표시
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* - onLogin: 인증 성공 시 API 키를 전달받는 콜백 (App에서 localStorage 저장)
|
* - onLogin: 인증 성공 시 세션 토큰을 전달받는 콜백
|
||||||
*/
|
*/
|
||||||
import { useState, FormEvent } from 'react'
|
import { useState, FormEvent } from 'react'
|
||||||
import { Activity } from 'lucide-react'
|
import { Activity, Eye, EyeOff, Loader2, Lock, User } from 'lucide-react'
|
||||||
|
|
||||||
/** Login 컴포넌트 Props 정의 */
|
/** Login 컴포넌트 Props 정의 */
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
/** 인증 성공 시 호출되는 콜백 — API 키 문자열 전달 */
|
/** 인증 성공 시 호출되는 콜백 — 세션 토큰 문자열 전달 */
|
||||||
onLogin: (apiKey: string) => void
|
onLogin: (token: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full pl-10 py-3 bg-[#0f1115] border rounded-xl text-gray-100 placeholder-gray-600 text-sm transition-colors focus:outline-none focus:ring-2'
|
||||||
|
const normalBorder = 'border-gray-700 focus:border-blue-500 focus:ring-blue-500/30'
|
||||||
|
const errorBorder = 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login — API 키 인증 폼 컴포넌트
|
* Login — 아이디/비밀번호 로그인 폼 컴포넌트
|
||||||
*
|
*
|
||||||
* 상태(State):
|
* 상태(State):
|
||||||
* - apiKey: 사용자 입력 API 키
|
* - username, password: 사용자 입력 자격 증명 / showPw: 비밀번호 표시 여부
|
||||||
* - error: 인증 실패 에러 메시지
|
* - error: 인증 실패 에러 메시지
|
||||||
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
|
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
|
||||||
*/
|
*/
|
||||||
const Login = ({ onLogin }: LoginProps) => {
|
const Login = ({ onLogin }: LoginProps) => {
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPw, setShowPw] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
/**
|
/** 폼 제출 — 아이디/비밀번호로 세션 토큰 발급 */
|
||||||
* 폼 제출 핸들러 — API 키 유효성 검증
|
|
||||||
*
|
|
||||||
* 검증 흐름:
|
|
||||||
* 1. 빈 입력 체크
|
|
||||||
* 2. /api/v1/portfolio/combined 엔드포인트로 API 키 검증 요청
|
|
||||||
* 3. 401/403 응답 시 에러 표시, 성공 시 onLogin 콜백 호출
|
|
||||||
*/
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!apiKey.trim()) {
|
if (!username.trim() || !password) {
|
||||||
setError('API 키를 입력하세요.')
|
setError('아이디와 비밀번호를 입력하세요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,25 +51,29 @@ const Login = ({ onLogin }: LoginProps) => {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API 키 검증: portfolio 엔드포인트로 테스트
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
const res = await fetch('/api/v1/portfolio/combined', {
|
method: 'POST',
|
||||||
headers: { 'X-API-Key': apiKey.trim() },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: username.trim(), password }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.ok) {
|
||||||
setError('유효하지 않은 API 키입니다.')
|
const data = (await res.json()) as { token: string }
|
||||||
setLoading(false)
|
onLogin(data.token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
let detail = ''
|
||||||
setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`)
|
try {
|
||||||
setLoading(false)
|
detail = ((await res.json()) as { detail?: string }).detail ?? ''
|
||||||
return
|
} catch {
|
||||||
|
/* 본문 없음 */
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검증 성공 — 상위 컴포넌트에 API 키 전달
|
if (res.status === 401) setError(detail || '아이디 또는 비밀번호가 올바르지 않습니다.')
|
||||||
onLogin(apiKey.trim())
|
else if (res.status === 429) setError(detail || '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.')
|
||||||
|
else if (res.status === 503) setError(detail || '서버에 로그인 계정이 설정되지 않았습니다.')
|
||||||
|
else setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`)
|
||||||
} catch {
|
} catch {
|
||||||
setError('서버 연결에 실패했습니다.')
|
setError('서버 연결에 실패했습니다.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,48 +82,100 @@ const Login = ({ onLogin }: LoginProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0f1115] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0f1115] text-gray-100 flex items-center justify-center px-4 py-12">
|
||||||
<div className="w-full max-w-sm p-8">
|
<div className="w-full max-w-[380px]">
|
||||||
<div className="flex flex-col items-center gap-4 mb-8">
|
{/* 브랜드 */}
|
||||||
<div className="bg-blue-600 p-3 rounded-xl shadow-lg shadow-blue-500/20">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<Activity className="text-white" size={28} />
|
<div className="bg-blue-600 p-3 rounded-2xl shadow-lg shadow-blue-500/25 mb-4">
|
||||||
</div>
|
<Activity className="text-white" size={26} />
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
|
||||||
AutoStock
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs text-gray-500 font-mono mt-1">GEN 3 ALGO</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">AutoStock</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">자동매매 대시보드</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{/* 로그인 카드 */}
|
||||||
<div>
|
<div className="bg-[#161b22] border border-gray-800 rounded-2xl p-6 shadow-xl shadow-black/30">
|
||||||
<label className="block text-sm text-gray-400 mb-2">API Key</label>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
<input
|
<div>
|
||||||
type="password"
|
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
value={apiKey}
|
아이디
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
</label>
|
||||||
placeholder="SECRET_KEY 입력"
|
<div className="relative">
|
||||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
<User
|
||||||
autoFocus
|
size={16}
|
||||||
/>
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
|
||||||
</div>
|
/>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value)
|
||||||
|
if (error) setError('')
|
||||||
|
}}
|
||||||
|
placeholder="아이디"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
className={`${inputClass} pr-4 ${error ? errorBorder : normalBorder}`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
<div>
|
||||||
<p className="text-red-400 text-sm">{error}</p>
|
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
)}
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPw ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
if (error) setError('')
|
||||||
|
}}
|
||||||
|
placeholder="비밀번호"
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? 'login-error' : undefined}
|
||||||
|
className={`${inputClass} pr-11 ${error ? errorBorder : normalBorder}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPw((v) => !v)}
|
||||||
|
aria-label={showPw ? '비밀번호 숨기기' : '비밀번호 표시'}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p id="login-error" role="alert" className="text-red-400 text-sm mt-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white font-medium rounded-lg transition-colors"
|
className="w-full py-3 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{loading ? '확인 중...' : '로그인'}
|
{loading && <Loader2 size={16} className="animate-spin" />}
|
||||||
</button>
|
{loading ? '확인 중' : '로그인'}
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-600 text-center mt-6">
|
<p className="text-xs text-gray-600 text-center mt-6">
|
||||||
.env 파일의 SECRET_KEY 값을 입력하세요
|
계정은 서버 <code className="font-mono text-gray-500">.env</code>의 ADMIN_USERNAME / ADMIN_PASSWORD 로 관리됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -9,9 +10,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certifi
|
|||||||
&& npm install -g @anthropic-ai/claude-code \
|
&& npm install -g @anthropic-ai/claude-code \
|
||||||
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
&& apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Python 의존성 설치
|
# Python 의존성 설치 — pip 캐시를 빌드 간 재사용 (BuildKit 필요)
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 애플리케이션 코드 복사
|
# 애플리케이션 코드 복사
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
Reference in New Issue
Block a user