Merge branch 'dev' into main — DB 계정 회원가입/로그인
This commit is contained in:
@@ -19,11 +19,9 @@ BACKEND_HOST=0.0.0.0
|
|||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-secret-key-here
|
||||||
|
|
||||||
# 대시보드 로그인 계정 (ADMIN_PASSWORD_HASH 우선, 없으면 ADMIN_PASSWORD 평문. 둘 다 비면 로그인 불가)
|
# 대시보드 로그인 — 계정은 DB(users)에 저장. 첫 가입자 = 관리자(자동 승인), 이후 가입자는 관리자 승인 필요
|
||||||
# 해시 생성: python -c "import bcrypt; print(bcrypt.hashpw(b'비밀번호', bcrypt.gensalt()).decode())"
|
SIGNUP_ENABLED=true
|
||||||
ADMIN_USERNAME=admin
|
SIGNUP_AUTO_APPROVE=false
|
||||||
ADMIN_PASSWORD=
|
|
||||||
ADMIN_PASSWORD_HASH=
|
|
||||||
SESSION_TOKEN_EXPIRE_HOURS=24
|
SESSION_TOKEN_EXPIRE_HOURS=24
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
40
backend/alembic/versions/0008_add_users_table.py
Normal file
40
backend/alembic/versions/0008_add_users_table.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""add users table (dashboard login accounts)
|
||||||
|
|
||||||
|
Revision ID: 0008
|
||||||
|
Revises: 0007
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0008"
|
||||||
|
down_revision = "0007"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
exists = conn.execute(sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_name='users'"
|
||||||
|
)).fetchone()
|
||||||
|
if exists:
|
||||||
|
return # create_all 로 이미 생성됨
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(32), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(128), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -1,60 +1,117 @@
|
|||||||
"""대시보드 로그인 엔드포인트 (아이디/비밀번호 → 세션 토큰).
|
"""대시보드 계정 인증 엔드포인트 (DB users 테이블 기반 회원가입/로그인).
|
||||||
|
|
||||||
POST /api/v1/auth/login {username, password} → {token, expires_at, username}
|
POST /api/v1/auth/register {username, password} → 계정 생성 (201)
|
||||||
|
POST /api/v1/auth/login {username, password} → {token, expires_at, username, is_admin}
|
||||||
GET /api/v1/auth/me (X-API-Key: 세션 토큰 또는 API 키) → 현재 인증 주체
|
GET /api/v1/auth/me (X-API-Key: 세션 토큰 또는 API 키) → 현재 인증 주체
|
||||||
|
GET /api/v1/auth/users (관리자) 계정 목록
|
||||||
|
POST /api/v1/auth/users/{id}/approve (관리자) 가입 승인
|
||||||
|
POST /api/v1/auth/users/{id}/revoke (관리자) 비활성화
|
||||||
|
|
||||||
- 자격 증명은 .env 의 ADMIN_USERNAME + ADMIN_PASSWORD_HASH(bcrypt) 또는 ADMIN_PASSWORD(평문)
|
정책
|
||||||
- 발급 토큰은 기존 X-API-Key 헤더로 전송 → security.require_api_key 가 세션 토큰도 수용
|
- 첫 가입자는 자동으로 관리자 + 활성. 이후 가입자는 SIGNUP_AUTO_APPROVE=False 면
|
||||||
- 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금(인메모리)
|
비활성 상태로 생성되어 관리자 승인 전까지 로그인 불가(403).
|
||||||
|
- 비밀번호는 bcrypt 해시로만 저장. 발급 토큰은 기존 X-API-Key 헤더로 전송되며
|
||||||
|
security.require_api_key 가 세션 토큰도 수용한다. API 키(SECRET_KEY)는 관리자로 간주.
|
||||||
|
- 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금.
|
||||||
|
|
||||||
|
DB 접근
|
||||||
|
- 회원가입/로그인은 "중복 확인 → INSERT/UPDATE → 응답" 이 한 요청 안에서 원자적으로
|
||||||
|
끝나야 하므로 sync_portfolio 와 같은 예외로 엔드포인트에서 직접 세션을 사용한다
|
||||||
|
(db_worker 큐는 결과를 응답에 되돌릴 수 없음). 실시간 계층과는 무관한 경로.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from pydantic import BaseModel, Field
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
_get_api_key,
|
_get_api_key,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
require_api_key,
|
require_api_key,
|
||||||
verify_session_token,
|
verify_session_token,
|
||||||
)
|
)
|
||||||
|
from app.models.models import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# IP별 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각)}
|
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$")
|
||||||
|
|
||||||
|
# IP별 로그인 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각 monotonic)}
|
||||||
_failures: dict[str, tuple[int, float]] = {}
|
_failures: dict[str, tuple[int, float]] = {}
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
# ── 스키마 ────────────────────────────────────────────────────────────────
|
||||||
username: str = Field(..., min_length=1, max_length=64)
|
class Credentials(BaseModel):
|
||||||
password: str = Field(..., min_length=1, max_length=256)
|
username: str = Field(..., min_length=3, max_length=32)
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def _username_format(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not _USERNAME_RE.match(v):
|
||||||
|
raise ValueError("아이디는 영문/숫자/밑줄 3~32자여야 합니다.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
username: str
|
username: str
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
def _login_enabled() -> bool:
|
class RegisterResponse(BaseModel):
|
||||||
return bool(settings.ADMIN_PASSWORD_HASH or settings.ADMIN_PASSWORD)
|
username: str
|
||||||
|
is_active: bool
|
||||||
|
is_admin: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
def _check_password(password: str) -> bool:
|
class UserOut(BaseModel):
|
||||||
if settings.ADMIN_PASSWORD_HASH:
|
id: int
|
||||||
# passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용
|
username: str
|
||||||
|
is_active: bool
|
||||||
|
is_admin: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_login_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||||
|
def _hash_password(password: str) -> str:
|
||||||
|
# passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용 (72바이트 제한)
|
||||||
|
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
_DUMMY_HASH = _hash_password("not-a-real-password") # 미존재 계정 타이밍 균등화용
|
||||||
|
|
||||||
|
|
||||||
|
def _check_password(password: str, password_hash: str) -> bool:
|
||||||
try:
|
try:
|
||||||
return bcrypt.checkpw(password.encode()[:72], settings.ADMIN_PASSWORD_HASH.encode())
|
return bcrypt.checkpw(password.encode()[:72], password_hash.encode())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("[Auth] ADMIN_PASSWORD_HASH 형식 오류 — bcrypt 해시가 아님")
|
|
||||||
return False
|
return False
|
||||||
return hmac.compare_digest(password.encode(), settings.ADMIN_PASSWORD.encode())
|
|
||||||
|
|
||||||
|
# bcrypt 는 의도적으로 느린(≈100~300ms) CPU 연산 → 이벤트 루프를 막지 않도록 스레드로 위임
|
||||||
|
async def _hash_password_async(password: str) -> str:
|
||||||
|
return await asyncio.to_thread(_hash_password, password)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_password_async(password: str, password_hash: str) -> bool:
|
||||||
|
return await asyncio.to_thread(_check_password, password, password_hash)
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
@@ -81,21 +138,69 @@ def _lockout_remaining(ip: str) -> int:
|
|||||||
def _record_failure(ip: str) -> None:
|
def _record_failure(ip: str) -> None:
|
||||||
count, _ = _failures.get(ip, (0, 0.0))
|
count, _ = _failures.get(ip, (0, 0.0))
|
||||||
_failures[ip] = (count + 1, time.monotonic())
|
_failures[ip] = (count + 1, time.monotonic())
|
||||||
# 오래된 항목 정리 (메모리 누수 방지)
|
if len(_failures) > 1000: # 오래된 항목 정리
|
||||||
if len(_failures) > 1000:
|
|
||||||
cutoff = time.monotonic() - settings.LOGIN_LOCKOUT_SECONDS
|
cutoff = time.monotonic() - settings.LOGIN_LOCKOUT_SECONDS
|
||||||
for k in [k for k, (_, t) in _failures.items() if t < cutoff]:
|
for k in [k for k, (_, t) in _failures.items() if t < cutoff]:
|
||||||
_failures.pop(k, None)
|
_failures.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
async def _resolve_user(credential: str, db: AsyncSession) -> User | None:
|
||||||
async def login(body: LoginRequest, request: Request):
|
"""세션 토큰 → User. API 키면 None (호출 측에서 관리자 취급)."""
|
||||||
if not _login_enabled():
|
username = verify_session_token(credential)
|
||||||
raise HTTPException(
|
if not username:
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
return None
|
||||||
detail="로그인이 설정되지 않았습니다. .env 에 ADMIN_PASSWORD 또는 ADMIN_PASSWORD_HASH 를 설정하세요.",
|
return (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> str:
|
||||||
|
"""관리자 전용 의존성. API 키(SECRET_KEY) 또는 is_admin 세션 토큰만 통과."""
|
||||||
|
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
|
||||||
|
return "api-key"
|
||||||
|
user = await _resolve_user(credential, db)
|
||||||
|
if user is None or not user.is_active or not user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
|
||||||
|
return user.username
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(body: Credentials, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
if not settings.SIGNUP_ENABLED:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="회원가입이 비활성화되어 있습니다.")
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).select_from(User))).scalar() or 0
|
||||||
|
is_first = total == 0
|
||||||
|
is_active = is_first or settings.SIGNUP_AUTO_APPROVE
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=body.username,
|
||||||
|
password_hash=await _hash_password_async(body.password),
|
||||||
|
is_active=is_active,
|
||||||
|
is_admin=is_first,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 사용 중인 아이디입니다.")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Auth] 회원가입 user={body.username} admin={is_first} active={is_active} ip={_client_ip(request)}"
|
||||||
|
)
|
||||||
|
if is_first:
|
||||||
|
message = "첫 계정이 관리자로 생성되었습니다. 바로 로그인할 수 있습니다."
|
||||||
|
elif is_active:
|
||||||
|
message = "가입이 완료되었습니다. 바로 로그인할 수 있습니다."
|
||||||
|
else:
|
||||||
|
message = "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다."
|
||||||
|
return RegisterResponse(username=user.username, is_active=is_active, is_admin=is_first, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
async def login(body: Credentials, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
remaining = _lockout_remaining(ip)
|
remaining = _lockout_remaining(ip)
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
@@ -104,25 +209,68 @@ async def login(body: LoginRequest, request: Request):
|
|||||||
detail=f"로그인 시도가 너무 많습니다. {remaining}초 후 다시 시도하세요.",
|
detail=f"로그인 시도가 너무 많습니다. {remaining}초 후 다시 시도하세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
user_ok = hmac.compare_digest(body.username.encode(), settings.ADMIN_USERNAME.encode())
|
user = (await db.execute(select(User).where(User.username == body.username))).scalar_one_or_none()
|
||||||
pw_ok = _check_password(body.password)
|
# 존재하지 않는 계정도 해시 검증을 수행해 응답 시간 차이로 계정 유무가 드러나지 않게 함
|
||||||
if not (user_ok and pw_ok):
|
pw_ok = await _check_password_async(body.password, user.password_hash if user else _DUMMY_HASH)
|
||||||
|
|
||||||
|
if user is None or not pw_ok:
|
||||||
_record_failure(ip)
|
_record_failure(ip)
|
||||||
logger.warning(f"[Auth] 로그인 실패 ip={ip} user={body.username!r}")
|
logger.warning(f"[Auth] 로그인 실패 ip={ip} user={body.username!r}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="아이디 또는 비밀번호가 올바르지 않습니다.",
|
detail="아이디 또는 비밀번호가 올바르지 않습니다.",
|
||||||
)
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="관리자 승인 대기 중인 계정입니다.",
|
||||||
|
)
|
||||||
|
|
||||||
_failures.pop(ip, None)
|
_failures.pop(ip, None)
|
||||||
token, expires_at = create_session_token(settings.ADMIN_USERNAME)
|
user.last_login_at = datetime.utcnow()
|
||||||
logger.info(f"[Auth] 로그인 성공 user={settings.ADMIN_USERNAME} ip={ip}")
|
await db.commit()
|
||||||
return LoginResponse(token=token, expires_at=expires_at, username=settings.ADMIN_USERNAME)
|
|
||||||
|
token, expires_at = create_session_token(user.username)
|
||||||
|
logger.info(f"[Auth] 로그인 성공 user={user.username} ip={ip}")
|
||||||
|
return LoginResponse(token=token, expires_at=expires_at, username=user.username, is_admin=user.is_admin)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(credential: str = Depends(require_api_key)):
|
async def me(credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)):
|
||||||
"""현재 자격 증명 확인 — 세션 토큰이면 username, API 키면 'api-key'."""
|
"""현재 자격 증명 확인 — 세션 토큰이면 계정 정보, API 키면 'api-key'(관리자)."""
|
||||||
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
|
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
|
||||||
return {"username": "api-key", "auth_type": "api_key"}
|
return {"username": "api-key", "auth_type": "api_key", "is_admin": True}
|
||||||
return {"username": verify_session_token(credential), "auth_type": "session"}
|
user = await _resolve_user(credential, db)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="세션이 유효하지 않습니다.")
|
||||||
|
return {"username": user.username, "auth_type": "session", "is_admin": user.is_admin}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_admin)])
|
||||||
|
async def list_users(db: AsyncSession = Depends(get_db)):
|
||||||
|
rows = (await db.execute(select(User).order_by(User.id))).scalars().all()
|
||||||
|
return [UserOut.model_validate(u, from_attributes=True) for u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/approve", response_model=UserOut)
|
||||||
|
async def approve_user(user_id: int, admin: str = Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.")
|
||||||
|
user.is_active = True
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[Auth] 계정 승인 user={user.username} by={admin}")
|
||||||
|
return UserOut.model_validate(user, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/revoke", response_model=UserOut)
|
||||||
|
async def revoke_user(user_id: int, admin: str = Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.")
|
||||||
|
if user.is_admin and user.username == admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="자기 자신은 비활성화할 수 없습니다.")
|
||||||
|
user.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[Auth] 계정 비활성화 user={user.username} by={admin}")
|
||||||
|
return UserOut.model_validate(user, from_attributes=True)
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ class Settings(BaseSettings):
|
|||||||
DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화)
|
DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화)
|
||||||
SECRET_KEY: str = "change-this-secret-key" # JWT/WS 서명용
|
SECRET_KEY: str = "change-this-secret-key" # JWT/WS 서명용
|
||||||
API_KEY: str = "" # API 인증용 (비어있으면 SECRET_KEY 폴백)
|
API_KEY: str = "" # API 인증용 (비어있으면 SECRET_KEY 폴백)
|
||||||
# ── 대시보드 로그인 (아이디/비밀번호) ────────────────────────────────
|
# ── 대시보드 로그인 (DB users 테이블, bcrypt) ─────────────────────────
|
||||||
# ADMIN_PASSWORD_HASH(bcrypt) 우선, 없으면 ADMIN_PASSWORD(평문) 사용. 둘 다 비면 로그인 비활성(503)
|
SIGNUP_ENABLED: bool = True # 회원가입 허용. False 면 /auth/register 403
|
||||||
# 해시 생성: python -c "import bcrypt; print(bcrypt.hashpw(b'비밀번호', bcrypt.gensalt()).decode())"
|
SIGNUP_AUTO_APPROVE: bool = False # True: 가입 즉시 활성. False: 첫 가입자만 자동 관리자, 이후 관리자 승인 필요
|
||||||
ADMIN_USERNAME: str = "admin"
|
|
||||||
ADMIN_PASSWORD: str = ""
|
|
||||||
ADMIN_PASSWORD_HASH: str = ""
|
|
||||||
SESSION_TOKEN_EXPIRE_HOURS: int = 24 # 로그인 세션 토큰 유효시간
|
SESSION_TOKEN_EXPIRE_HOURS: int = 24 # 로그인 세션 토큰 유효시간
|
||||||
LOGIN_MAX_FAILURES: int = 5 # IP당 연속 실패 허용 횟수
|
LOGIN_MAX_FAILURES: int = 5 # IP당 연속 실패 허용 횟수
|
||||||
LOGIN_LOCKOUT_SECONDS: int = 300 # 초과 시 잠금 시간(초)
|
LOGIN_LOCKOUT_SECONDS: int = 300 # 초과 시 잠금 시간(초)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ SQLAlchemy ORM 모델 정의
|
|||||||
- StrategyConfig: 전략 파라미터 DB 관리 (하드코딩 대체)
|
- StrategyConfig: 전략 파라미터 DB 관리 (하드코딩 대체)
|
||||||
- ExchangeRate: 환율 이력
|
- ExchangeRate: 환율 이력
|
||||||
- UsStockUniverse: 미국 주식 종목 유니버스
|
- UsStockUniverse: 미국 주식 종목 유니버스
|
||||||
|
- User: 대시보드 로그인 계정 (bcrypt 해시, 관리자 승인)
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
@@ -384,3 +385,21 @@ class StrategyOptimizationLog(Base):
|
|||||||
apply_reason = Column(Text, nullable=True)
|
apply_reason = Column(Text, nullable=True)
|
||||||
total_combinations = Column(Integer, nullable=True)
|
total_combinations = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""대시보드 로그인 계정.
|
||||||
|
|
||||||
|
- 첫 가입자는 자동으로 관리자(is_admin) + 활성(is_active)
|
||||||
|
- 이후 가입자는 is_active=False 로 생성 → 관리자 승인 후 로그인 가능
|
||||||
|
(SIGNUP_AUTO_APPROVE=True 면 즉시 활성)
|
||||||
|
"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(32), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(128), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=False, nullable=False)
|
||||||
|
is_admin = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
last_login_at = Column(DateTime, nullable=True)
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Login.tsx — 로그인 페이지 컴포넌트
|
* Login.tsx — 로그인 / 회원가입 페이지
|
||||||
*
|
*
|
||||||
* 역할:
|
* 역할:
|
||||||
* - 아이디/비밀번호 입력 → POST /api/v1/auth/login 으로 세션 토큰 발급
|
* - 로그인: POST /api/v1/auth/login → 세션 토큰을 App 에 전달
|
||||||
* - 발급된 토큰을 App 컴포넌트에 전달 (App 이 localStorage 'api_key' 에 저장,
|
* (App 이 localStorage 'api_key' 에 저장 → api.ts / useWebSocket 이 X-API-Key 로 전송)
|
||||||
* 이후 api.ts / useWebSocket 이 기존 X-API-Key 경로로 그대로 전송)
|
* - 회원가입: POST /api/v1/auth/register → 첫 가입자는 관리자로 즉시 활성(자동 로그인),
|
||||||
* - 인증 실패(401)·잠금(429)·미설정(503)·네트워크 오류 메시지 표시
|
* 이후 가입자는 관리자 승인 안내 후 로그인 탭으로 전환
|
||||||
|
* - 상태코드별 안내: 401 자격 오류 / 403 승인 대기·가입 비활성 / 409 아이디 중복 / 429 잠금
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* - onLogin: 인증 성공 시 세션 토큰을 전달받는 콜백
|
* - onLogin: 인증 성공 시 세션 토큰을 전달받는 콜백
|
||||||
*/
|
*/
|
||||||
import { useState, FormEvent } from 'react'
|
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 정의 */
|
/** Login 컴포넌트 Props 정의 */
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
@@ -32,48 +33,105 @@ const errorBorder = 'border-red-500/60 focus:border-red-500 focus:ring-red-500/3
|
|||||||
* - error: 인증 실패 에러 메시지
|
* - error: 인증 실패 에러 메시지
|
||||||
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
|
* - 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 Login = ({ onLogin }: LoginProps) => {
|
||||||
|
const [mode, setMode] = useState<Mode>('login')
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||||
const [showPw, setShowPw] = useState(false)
|
const [showPw, setShowPw] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
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) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!username.trim() || !password) {
|
const u = username.trim()
|
||||||
|
if (!u || !password) {
|
||||||
setError('아이디와 비밀번호를 입력하세요.')
|
setError('아이디와 비밀번호를 입력하세요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (isRegister) {
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('비밀번호는 8자 이상이어야 합니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
setError('비밀번호 확인이 일치하지 않습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setNotice('')
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/login', {
|
const msg = isRegister ? await doRegister(u, password) : await doLogin(u, password)
|
||||||
method: 'POST',
|
if (msg) setError(msg)
|
||||||
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})`)
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('서버 연결에 실패했습니다.')
|
setError('서버 연결에 실패했습니다.')
|
||||||
} finally {
|
} 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 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>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
|
<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)
|
setPassword(e.target.value)
|
||||||
if (error) setError('')
|
if (error) setError('')
|
||||||
}}
|
}}
|
||||||
placeholder="비밀번호"
|
placeholder={isRegister ? '8자 이상' : '비밀번호'}
|
||||||
autoComplete="current-password"
|
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
aria-describedby={error ? 'login-error' : undefined}
|
aria-describedby={error ? 'login-error' : undefined}
|
||||||
className={`${inputClass} pr-11 ${error ? errorBorder : normalBorder}`}
|
className={`${inputClass} pr-11 ${error ? errorBorder : normalBorder}`}
|
||||||
@@ -156,12 +244,39 @@ const Login = ({ onLogin }: LoginProps) => {
|
|||||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</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 && (
|
{error && (
|
||||||
<p id="login-error" role="alert" className="text-red-400 text-sm mt-2">
|
<p id="login-error" role="alert" className="text-red-400 text-sm">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -169,13 +284,13 @@ const Login = ({ onLogin }: LoginProps) => {
|
|||||||
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"
|
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 && <Loader2 size={16} className="animate-spin" />}
|
||||||
{loading ? '확인 중' : '로그인'}
|
{loading ? '처리 중' : isRegister ? '가입하기' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-600 text-center mt-6">
|
<p className="text-xs text-gray-600 text-center mt-6 leading-relaxed">
|
||||||
계정은 서버 <code className="font-mono text-gray-500">.env</code>의 ADMIN_USERNAME / ADMIN_PASSWORD 로 관리됩니다.
|
첫 번째 가입 계정이 관리자가 됩니다. 이후 가입자는 관리자 승인 후 로그인할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user