From 9063a9d01ed537c30b481400301af8541fe1ad04 Mon Sep 17 00:00:00 2001 From: shuim Date: Sun, 20 Sep 2026 00:06:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=84=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94/=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20(SECRET=5FKE?= =?UTF-8?q?Y=20=EC=A7=81=EC=A0=91=20=EC=9E=85=EB=A0=A5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 7 ++ backend/app/api/__init__.py | 5 +- backend/app/api/endpoints/auth.py | 128 +++++++++++++++++++++++++++ backend/app/core/config.py | 9 ++ backend/app/core/security.py | 40 +++++++-- backend/app/main.py | 8 +- frontend/src/App.tsx | 4 +- frontend/src/pages/Login.tsx | 138 ++++++++++++++++++------------ 8 files changed, 269 insertions(+), 70 deletions(-) create mode 100644 backend/app/api/endpoints/auth.py 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/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/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/pages/Login.tsx b/frontend/src/pages/Login.tsx index cd01509..ce59faa 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -2,48 +2,48 @@ * Login.tsx — 로그인 페이지 컴포넌트 * * 역할: - * - API 키(SECRET_KEY) 입력을 통한 사용자 인증 처리 - * - 백엔드 portfolio API로 키 유효성 검증 후 App 컴포넌트에 인증 콜백 전달 - * - 인증 실패 시 에러 메시지 표시 (401/403 또는 네트워크 오류) + * - 아이디/비밀번호 입력 → POST /api/v1/auth/login 으로 세션 토큰 발급 + * - 발급된 토큰을 App 컴포넌트에 전달 (App 이 localStorage 'api_key' 에 저장, + * 이후 api.ts / useWebSocket 이 기존 X-API-Key 경로로 그대로 전송) + * - 인증 실패(401)·잠금(429)·미설정(503)·네트워크 오류 메시지 표시 * * Props: - * - onLogin: 인증 성공 시 API 키를 전달받는 콜백 (App에서 localStorage 저장) + * - onLogin: 인증 성공 시 세션 토큰을 전달받는 콜백 */ import { useState, FormEvent } from 'react' -import { Activity, Eye, EyeOff, KeyRound, Loader2 } from 'lucide-react' +import { Activity, Eye, EyeOff, Loader2, Lock, User } from 'lucide-react' /** Login 컴포넌트 Props 정의 */ 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): - * - apiKey: 사용자 입력 API 키 + * - username, password: 사용자 입력 자격 증명 / showPw: 비밀번호 표시 여부 * - error: 인증 실패 에러 메시지 * - loading: 검증 요청 진행 중 여부 (버튼 비활성화) */ 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 [loading, setLoading] = useState(false) - const [showKey, setShowKey] = useState(false) - /** - * 폼 제출 핸들러 — API 키 유효성 검증 - * - * 검증 흐름: - * 1. 빈 입력 체크 - * 2. /api/v1/portfolio/combined 엔드포인트로 API 키 검증 요청 - * 3. 401/403 응답 시 에러 표시, 성공 시 onLogin 콜백 호출 - */ + /** 폼 제출 — 아이디/비밀번호로 세션 토큰 발급 */ const handleSubmit = async (e: FormEvent) => { e.preventDefault() - if (!apiKey.trim()) { - setError('API 키를 입력하세요.') + if (!username.trim() || !password) { + setError('아이디와 비밀번호를 입력하세요.') return } @@ -51,25 +51,29 @@ const Login = ({ onLogin }: LoginProps) => { setError('') try { - // API 키 검증: portfolio 엔드포인트로 테스트 - const res = await fetch('/api/v1/portfolio/combined', { - headers: { 'X-API-Key': apiKey.trim() }, + const res = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username.trim(), password }), }) - if (res.status === 401 || res.status === 403) { - setError('유효하지 않은 API 키입니다.') - setLoading(false) + if (res.ok) { + const data = (await res.json()) as { token: string } + onLogin(data.token) return } - if (!res.ok) { - setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`) - setLoading(false) - return + let detail = '' + try { + detail = ((await res.json()) as { detail?: string }).detail ?? '' + } catch { + /* 본문 없음 */ } - // 검증 성공 — 상위 컴포넌트에 API 키 전달 - onLogin(apiKey.trim()) + if (res.status === 401) setError(detail || '아이디 또는 비밀번호가 올바르지 않습니다.') + else if (res.status === 429) setError(detail || '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.') + else if (res.status === 503) setError(detail || '서버에 로그인 계정이 설정되지 않았습니다.') + else setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`) } catch { setError('서버 연결에 실패했습니다.') } finally { @@ -91,48 +95,69 @@ const Login = ({ onLogin }: LoginProps) => { {/* 로그인 카드 */}
-
+
-
+ +
+ +
+ + { + 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}`} + />
{error && ( - )} @@ -149,9 +174,8 @@ const Login = ({ onLogin }: LoginProps) => {
-

- 백엔드 .env의{' '} - SECRET_KEY 값으로 로그인합니다. +

+ 계정은 서버 .env의 ADMIN_USERNAME / ADMIN_PASSWORD 로 관리됩니다.