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

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

View File

@@ -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) => {
{/* 로그인 카드 */}
<div className="bg-[#161b22] border border-gray-800 rounded-2xl p-6 shadow-xl shadow-black/30">
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label htmlFor="api-key" className="block text-sm font-medium text-gray-300 mb-2">
API 키
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
아이디
</label>
<div className="relative">
<KeyRound
<User
size={16}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
/>
<input
id="api-key"
type={showKey ? 'text' : 'password'}
value={apiKey}
id="username"
type="text"
value={username}
onChange={(e) => {
setApiKey(e.target.value)
setUsername(e.target.value)
if (error) setError('')
}}
placeholder="SECRET_KEY"
autoComplete="current-password"
placeholder="아이디"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
aria-invalid={!!error}
aria-describedby={error ? 'api-key-error' : undefined}
className={`w-full pl-10 pr-11 py-3 bg-[#0f1115] border rounded-xl text-gray-100 placeholder-gray-600 font-mono text-sm transition-colors focus:outline-none focus:ring-2 ${
error
? 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30'
: 'border-gray-700 focus:border-blue-500 focus:ring-blue-500/30'
}`}
className={`${inputClass} pr-4 ${error ? errorBorder : normalBorder}`}
autoFocus
/>
</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={() => setShowKey((v) => !v)}
aria-label={showKey ? 'API 키 숨기기' : 'API 키 표시'}
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"
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{error && (
<p id="api-key-error" role="alert" className="text-red-400 text-sm mt-2">
<p id="login-error" role="alert" className="text-red-400 text-sm mt-2">
{error}
</p>
)}
@@ -149,9 +174,8 @@ const Login = ({ onLogin }: LoginProps) => {
</form>
</div>
<p className="text-xs text-gray-600 text-center mt-6 leading-relaxed">
백엔드 <code className="font-mono text-gray-500">.env</code>의{' '}
<code className="font-mono text-gray-500">SECRET_KEY</code> 값으로 로그인합니다.
<p className="text-xs text-gray-600 text-center mt-6">
계정은 서버 <code className="font-mono text-gray-500">.env</code>의 ADMIN_USERNAME / ADMIN_PASSWORD 로 관리됩니다.
</p>
</div>
</div>