Merge branch 'dev' into main — 로그인 아이디/비밀번호 전환, Gitea Actions 제거

This commit is contained in:
2026-09-20 14:40:11 +09:00
15 changed files with 358 additions and 166 deletions

View File

@@ -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,

View File

@@ -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
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# 데이터베이스 및 캐시 # 데이터베이스 및 캐시
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -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
View File

@@ -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 {

View File

@@ -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 . .

View File

@@ -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"],

View 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"}

View File

@@ -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

View File

@@ -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 키입니다.",

View File

@@ -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

View File

@@ -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 . .

View File

@@ -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)

View File

@@ -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 */}

View File

@@ -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 className="bg-[#161b22] border border-gray-800 rounded-2xl p-6 shadow-xl shadow-black/30">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div> <div>
<label className="block text-sm text-gray-400 mb-2">API Key</label> <label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
아이디
</label>
<div className="relative">
<User
size={16}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
/>
<input <input
type="password" id="username"
value={apiKey} type="text"
onChange={(e) => setApiKey(e.target.value)} value={username}
placeholder="SECRET_KEY 입력" onChange={(e) => {
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" setUsername(e.target.value)
if (error) setError('')
}}
placeholder="아이디"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
className={`${inputClass} pr-4 ${error ? errorBorder : normalBorder}`}
autoFocus autoFocus
/> />
</div> </div>
</div>
<div>
<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 && ( {error && (
<p className="text-red-400 text-sm">{error}</p> <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" />}
{loading ? '확인 중' : '로그인'}
</button> </button>
</form> </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>

View File

@@ -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 . .