feat: DB 계정 기반 회원가입/로그인 (users 테이블) — .env 관리자 계정 방식 대체

- models.User + alembic 0008 (users: username unique, bcrypt password_hash, is_active, is_admin)
- auth.py: POST /auth/register, /auth/login, GET /auth/me, 관리자용 GET /auth/users,
  POST /auth/users/{id}/approve|revoke. bcrypt 는 asyncio.to_thread 로 오프로드,
  미존재 계정도 더미 해시 검증(타이밍 균등화), IP당 실패 잠금 유지
- 정책: 첫 가입자 = 관리자·즉시 활성, 이후 가입자는 관리자 승인 필요
  (SIGNUP_AUTO_APPROVE=true 로 해제, SIGNUP_ENABLED=false 로 가입 차단). API 키는 관리자 취급
- config: ADMIN_USERNAME/ADMIN_PASSWORD(_HASH) 제거 → SIGNUP_ENABLED / SIGNUP_AUTO_APPROVE
- Login.tsx: 로그인/회원가입 탭, 비밀번호 확인, 승인 안내 배너, 상태코드별 메시지,
  첫 가입자는 가입 직후 자동 로그인
- DB 접근: 회원가입/로그인은 sync_portfolio 와 같은 단일 트랜잭션 예외로 엔드포인트 직접 접근
  (컨테이너 Postgres 대상 통합 테스트 19건 통과)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 14:48:05 +09:00
parent 9063a9d01e
commit dc128ae4cf
6 changed files with 412 additions and 95 deletions

View File

@@ -1,17 +1,18 @@
/**
* Login.tsx — 로그인 페이지 컴포넌트
* Login.tsx — 로그인 / 회원가입 페이지
*
* 역할:
* - 아이디/비밀번호 입력 → POST /api/v1/auth/login 으로 세션 토큰 발급
* - 발급된 토큰을 App 컴포넌트에 전달 (App 이 localStorage 'api_key' 에 저장,
* 이후 api.ts / useWebSocket 이 기존 X-API-Key 경로로 그대로 전송)
* - 인증 실패(401)·잠금(429)·미설정(503)·네트워크 오류 메시지 표시
* - 로그인: POST /api/v1/auth/login → 세션 토큰을 App 에 전달
* (App 이 localStorage 'api_key' 에 저장 → api.ts / useWebSocket 이 X-API-Key 로 전송)
* - 회원가입: POST /api/v1/auth/register → 첫 가입자는 관리자로 즉시 활성(자동 로그인),
* 이후 가입자는 관리자 승인 안내 후 로그인 탭으로 전환
* - 상태코드별 안내: 401 자격 오류 / 403 승인 대기·가입 비활성 / 409 아이디 중복 / 429 잠금
*
* Props:
* - onLogin: 인증 성공 시 세션 토큰을 전달받는 콜백
*/
import { useState, FormEvent } from 'react'
import { Activity, Eye, EyeOff, Loader2, Lock, User } from 'lucide-react'
import { Activity, CheckCircle2, Eye, EyeOff, Loader2, Lock, User } from 'lucide-react'
/** Login 컴포넌트 Props 정의 */
interface LoginProps {
@@ -32,48 +33,105 @@ const errorBorder = 'border-red-500/60 focus:border-red-500 focus:ring-red-500/3
* - error: 인증 실패 에러 메시지
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
*/
type Mode = 'login' | 'register'
/** 서버 detail 메시지 추출 (없으면 빈 문자열) */
const readDetail = async (res: Response): Promise<string> => {
try {
const data = (await res.json()) as { detail?: string | { msg?: string }[] }
if (typeof data.detail === 'string') return data.detail
if (Array.isArray(data.detail)) return data.detail[0]?.msg?.replace(/^Value error, /, '') ?? ''
} catch {
/* 본문 없음 */
}
return ''
}
const Login = ({ onLogin }: LoginProps) => {
const [mode, setMode] = useState<Mode>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [loading, setLoading] = useState(false)
/** 폼 제출 — 아이디/비밀번호로 세션 토큰 발급 */
const isRegister = mode === 'register'
const switchMode = (next: Mode) => {
setMode(next)
setError('')
setNotice('')
setPassword('')
setPasswordConfirm('')
}
/** 로그인 요청 — 성공 시 토큰 전달, 실패 시 에러 메시지 반환 */
const doLogin = async (u: string, p: string): Promise<string> => {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p }),
})
if (res.ok) {
const data = (await res.json()) as { token: string }
onLogin(data.token)
return ''
}
const detail = await readDetail(res)
if (res.status === 401) return detail || '아이디 또는 비밀번호가 올바르지 않습니다.'
if (res.status === 403) return detail || '관리자 승인 대기 중인 계정입니다.'
if (res.status === 429) return detail || '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.'
if (res.status === 422) return detail || '입력 형식이 올바르지 않습니다.'
return `서버 오류가 발생했습니다. (HTTP ${res.status})`
}
/** 회원가입 요청 — 즉시 활성 계정이면 자동 로그인 */
const doRegister = async (u: string, p: string): Promise<string> => {
const res = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p }),
})
if (res.ok) {
const data = (await res.json()) as { is_active: boolean; message: string }
if (data.is_active) return doLogin(u, p)
switchMode('login')
setNotice(data.message)
return ''
}
const detail = await readDetail(res)
if (res.status === 409) return detail || '이미 사용 중인 아이디입니다.'
if (res.status === 403) return detail || '회원가입이 비활성화되어 있습니다.'
if (res.status === 422) return detail || '아이디는 영문/숫자/밑줄 3~32자, 비밀번호는 8자 이상이어야 합니다.'
return `서버 오류가 발생했습니다. (HTTP ${res.status})`
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!username.trim() || !password) {
const u = username.trim()
if (!u || !password) {
setError('아이디와 비밀번호를 입력하세요.')
return
}
if (isRegister) {
if (password.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다.')
return
}
if (password !== passwordConfirm) {
setError('비밀번호 확인이 일치하지 않습니다.')
return
}
}
setLoading(true)
setError('')
setNotice('')
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
})
if (res.ok) {
const data = (await res.json()) as { token: string }
onLogin(data.token)
return
}
let detail = ''
try {
detail = ((await res.json()) as { detail?: string }).detail ?? ''
} catch {
/* 본문 없음 */
}
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})`)
const msg = isRegister ? await doRegister(u, password) : await doLogin(u, password)
if (msg) setError(msg)
} catch {
setError('서버 연결에 실패했습니다.')
} finally {
@@ -95,6 +153,36 @@ const Login = ({ onLogin }: LoginProps) => {
{/* 로그인 카드 */}
<div className="bg-[#161b22] border border-gray-800 rounded-2xl p-6 shadow-xl shadow-black/30">
{/* 로그인 / 회원가입 탭 */}
<div role="tablist" className="grid grid-cols-2 gap-1 p-1 mb-6 bg-[#0f1115] rounded-xl">
{(['login', 'register'] as Mode[]).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={mode === m}
onClick={() => switchMode(m)}
className={`py-2 text-sm font-medium rounded-lg transition-colors ${
mode === m
? 'bg-[#161b22] text-gray-100 shadow-sm'
: 'text-gray-500 hover:text-gray-300'
}`}
>
{m === 'login' ? '로그인' : '회원가입'}
</button>
))}
</div>
{notice && (
<div
role="status"
className="flex items-start gap-2 mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-sm"
>
<CheckCircle2 size={16} className="mt-0.5 shrink-0" />
<span>{notice}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
@@ -140,8 +228,8 @@ const Login = ({ onLogin }: LoginProps) => {
setPassword(e.target.value)
if (error) setError('')
}}
placeholder="비밀번호"
autoComplete="current-password"
placeholder={isRegister ? '8자 이상' : '비밀번호'}
autoComplete={isRegister ? 'new-password' : 'current-password'}
aria-invalid={!!error}
aria-describedby={error ? 'login-error' : undefined}
className={`${inputClass} pr-11 ${error ? errorBorder : normalBorder}`}
@@ -156,26 +244,53 @@ const Login = ({ onLogin }: LoginProps) => {
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{error && (
<p id="login-error" role="alert" className="text-red-400 text-sm mt-2">
{error}
</p>
)}
</div>
{isRegister && (
<div>
<label htmlFor="password-confirm" 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-confirm"
type={showPw ? 'text' : 'password'}
value={passwordConfirm}
onChange={(e) => {
setPasswordConfirm(e.target.value)
if (error) setError('')
}}
placeholder="비밀번호 다시 입력"
autoComplete="new-password"
className={`${inputClass} pr-4 ${error ? errorBorder : normalBorder}`}
/>
</div>
</div>
)}
{error && (
<p id="login-error" role="alert" className="text-red-400 text-sm">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
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 && <Loader2 size={16} className="animate-spin" />}
{loading ? '확인 중' : '로그인'}
{loading ? '처리 중' : isRegister ? '가입하기' : '로그인'}
</button>
</form>
</div>
<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 className="text-xs text-gray-600 text-center mt-6 leading-relaxed">
첫 번째 가입 계정이 관리자가 됩니다. 이후 가입자는 관리자 승인 후 로그인할 수 있습니다.
</p>
</div>
</div>