From dc128ae4cf9c0f05a1d65147cf31d209f8b5a10f Mon Sep 17 00:00:00 2001 From: shuim Date: Sun, 20 Sep 2026 14:48:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DB=20=EA=B3=84=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85/=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20(users=20=ED=85=8C=EC=9D=B4=EB=B8=94)=20?= =?UTF-8?q?=E2=80=94=20.env=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EB=B0=A9=EC=8B=9D=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 8 +- .../alembic/versions/0008_add_users_table.py | 40 +++ backend/app/api/endpoints/auth.py | 232 ++++++++++++++---- backend/app/core/config.py | 9 +- backend/app/models/models.py | 19 ++ frontend/src/pages/Login.tsx | 199 +++++++++++---- 6 files changed, 412 insertions(+), 95 deletions(-) create mode 100644 backend/alembic/versions/0008_add_users_table.py diff --git a/.env.example b/.env.example index 833044a..145c93f 100644 --- a/.env.example +++ b/.env.example @@ -19,11 +19,9 @@ 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= +# 대시보드 로그인 — 계정은 DB(users)에 저장. 첫 가입자 = 관리자(자동 승인), 이후 가입자는 관리자 승인 필요 +SIGNUP_ENABLED=true +SIGNUP_AUTO_APPROVE=false SESSION_TOKEN_EXPIRE_HOURS=24 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/backend/alembic/versions/0008_add_users_table.py b/backend/alembic/versions/0008_add_users_table.py new file mode 100644 index 0000000..ef40781 --- /dev/null +++ b/backend/alembic/versions/0008_add_users_table.py @@ -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") diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 1736091..38605ed 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -1,60 +1,117 @@ -"""대시보드 로그인 엔드포인트 (아이디/비밀번호 → 세션 토큰). +"""대시보드 계정 인증 엔드포인트 (DB users 테이블 기반 회원가입/로그인). -POST /api/v1/auth/login {username, password} → {token, expires_at, username} -GET /api/v1/auth/me (X-API-Key: 세션 토큰 또는 API 키) → 현재 인증 주체 +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/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 가 세션 토큰도 수용 -- 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금(인메모리) +정책 +- 첫 가입자는 자동으로 관리자 + 활성. 이후 가입자는 SIGNUP_AUTO_APPROVE=False 면 + 비활성 상태로 생성되어 관리자 승인 전까지 로그인 불가(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 logging +import re import time from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Request, status 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.database import get_db from app.core.security import ( _get_api_key, create_session_token, require_api_key, verify_session_token, ) +from app.models.models import User logger = logging.getLogger(__name__) router = APIRouter() -# IP별 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각)} +_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$") + +# IP별 로그인 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각 monotonic)} _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 Credentials(BaseModel): + 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): token: str expires_at: datetime username: str + is_admin: bool -def _login_enabled() -> bool: - return bool(settings.ADMIN_PASSWORD_HASH or settings.ADMIN_PASSWORD) +class RegisterResponse(BaseModel): + username: str + is_active: bool + is_admin: bool + message: str -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()) +class UserOut(BaseModel): + id: int + 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: + return bcrypt.checkpw(password.encode()[:72], password_hash.encode()) + except ValueError: + return False + + +# 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: @@ -81,21 +138,69 @@ def _lockout_remaining(ip: str) -> int: def _record_failure(ip: str) -> None: count, _ = _failures.get(ip, (0, 0.0)) _failures[ip] = (count + 1, time.monotonic()) - # 오래된 항목 정리 (메모리 누수 방지) - if len(_failures) > 1000: + 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 를 설정하세요.", - ) +async def _resolve_user(credential: str, db: AsyncSession) -> User | None: + """세션 토큰 → User. API 키면 None (호출 측에서 관리자 취급).""" + username = verify_session_token(credential) + if not username: + return None + 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) remaining = _lockout_remaining(ip) if remaining > 0: @@ -104,25 +209,68 @@ async def login(body: LoginRequest, request: Request): 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): + user = (await db.execute(select(User).where(User.username == body.username))).scalar_one_or_none() + # 존재하지 않는 계정도 해시 검증을 수행해 응답 시간 차이로 계정 유무가 드러나지 않게 함 + 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) logger.warning(f"[Auth] 로그인 실패 ip={ip} user={body.username!r}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="아이디 또는 비밀번호가 올바르지 않습니다.", ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + 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) + user.last_login_at = datetime.utcnow() + await db.commit() + + 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") -async def me(credential: str = Depends(require_api_key)): - """현재 자격 증명 확인 — 세션 토큰이면 username, API 키면 'api-key'.""" +async def me(credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)): + """현재 자격 증명 확인 — 세션 토큰이면 계정 정보, 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"} + return {"username": "api-key", "auth_type": "api_key", "is_admin": True} + 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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a10ec9f..9717df7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,12 +23,9 @@ 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 = "" + # ── 대시보드 로그인 (DB users 테이블, bcrypt) ───────────────────────── + SIGNUP_ENABLED: bool = True # 회원가입 허용. False 면 /auth/register 403 + SIGNUP_AUTO_APPROVE: bool = False # True: 가입 즉시 활성. False: 첫 가입자만 자동 관리자, 이후 관리자 승인 필요 SESSION_TOKEN_EXPIRE_HOURS: int = 24 # 로그인 세션 토큰 유효시간 LOGIN_MAX_FAILURES: int = 5 # IP당 연속 실패 허용 횟수 LOGIN_LOCKOUT_SECONDS: int = 300 # 초과 시 잠금 시간(초) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 8faa7e1..7dd8076 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -8,6 +8,7 @@ SQLAlchemy ORM 모델 정의 - StrategyConfig: 전략 파라미터 DB 관리 (하드코딩 대체) - ExchangeRate: 환율 이력 - UsStockUniverse: 미국 주식 종목 유니버스 +- User: 대시보드 로그인 계정 (bcrypt 해시, 관리자 승인) """ from datetime import datetime, date from typing import Optional, List @@ -384,3 +385,21 @@ class StrategyOptimizationLog(Base): apply_reason = Column(Text, nullable=True) total_combinations = Column(Integer, nullable=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) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ce59faa..e1fb868 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 => { + 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('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 => { + 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 => { + 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) => { {/* 로그인 카드 */}
+ {/* 로그인 / 회원가입 탭 */} +
+ {(['login', 'register'] as Mode[]).map((m) => ( + + ))} +
+ + {notice && ( +
+ + {notice} +
+ )} +
- {error && ( - - )}
+ {isRegister && ( +
+ +
+ + { + setPasswordConfirm(e.target.value) + if (error) setError('') + }} + placeholder="비밀번호 다시 입력" + autoComplete="new-password" + className={`${inputClass} pr-4 ${error ? errorBorder : normalBorder}`} + /> +
+
+ )} + + {error && ( + + )} + -

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

+ 첫 번째 가입 계정이 관리자가 됩니다. 이후 가입자는 관리자 승인 후 로그인할 수 있습니다.