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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 키입니다.",
|
||||
|
||||
Reference in New Issue
Block a user