feat: 대시보드 로그인을 아이디/비밀번호 방식으로 전환 (SECRET_KEY 직접 입력 제거)

- auth.py 신규: POST /api/v1/auth/login → JWT 세션 토큰(HS256, SECRET_KEY 서명, 24h)
  GET /api/v1/auth/me 자격 확인. IP당 5회 실패 시 300초 잠금(인메모리)
- config: ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_PASSWORD_HASH(bcrypt) /
  SESSION_TOKEN_EXPIRE_HOURS / LOGIN_MAX_FAILURES / LOGIN_LOCKOUT_SECONDS
- security: create_session_token / verify_session_token / is_valid_credential 추가.
  require_api_key·optional_api_key 는 시그니처 유지, X-API-Key 값으로 API 키 또는
  세션 토큰 모두 수용 → 프론트 api.ts·useWebSocket·MCP 서버 호출 경로 무변경
- main.py WebSocket 인증도 is_valid_credential 로 통일
- bcrypt 검증은 passlib 대신 bcrypt 직접 사용 (passlib 1.7.4 ↔ bcrypt 5.0 비호환)
- Login.tsx: 아이디/비밀번호 폼, 401/429/503 별 안내 메시지
- .env.example 에 관리자 계정 항목 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 00:06:23 +09:00
parent f8c4612650
commit 9063a9d01e
8 changed files with 269 additions and 70 deletions

View File

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

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로 개발 모드 활성화)
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

View File

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

View File

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