feat: implement Phase 0 auth system, setup wizard, and Docker config

- Add users table to migration, User ORM model
- Implement JWT+TOTP auth API (login, refresh, me, change-password)
- Add first-run setup wizard with rate-limited admin creation,
  TOTP QR enrollment (secret saved only after verification), and
  NAS path verification — served as Jinja2 single-page HTML
- Add setup redirect middleware (bypasses /health, /docs, /openapi.json)
- Mount config.yaml, scripts, logs volumes in docker-compose
- Route API vs frontend traffic in Caddyfile
- Include admin seed script as CLI fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-02 13:21:45 +09:00
parent 0a01e17ea1
commit a601991f48
13 changed files with 1051 additions and 25 deletions

167
app/api/auth.py Normal file
View File

@@ -0,0 +1,167 @@
"""인증 API — 로그인, 토큰 갱신, TOTP 검증"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import (
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
hash_password,
verify_password,
verify_totp,
)
from core.database import get_session
from models.user import User
router = APIRouter()
# ─── 요청/응답 스키마 ───
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str | None = None
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class UserResponse(BaseModel):
id: int
username: str
is_active: bool
totp_enabled: bool
last_login_at: datetime | None
class Config:
from_attributes = True
# ─── 엔드포인트 ───
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""로그인 → JWT 발급"""
result = await session.execute(
select(User).where(User.username == body.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="아이디 또는 비밀번호가 올바르지 않습니다",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="비활성화된 계정입니다",
)
# TOTP 검증 (설정된 경우)
if user.totp_secret:
if not body.totp_code:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="TOTP 코드가 필요합니다",
)
if not verify_totp(body.totp_code, user.totp_secret):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="TOTP 코드가 올바르지 않습니다",
)
# 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
return TokenResponse(
access_token=create_access_token(user.username),
refresh_token=create_refresh_token(user.username),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
body: RefreshRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""리프레시 토큰으로 새 토큰 쌍 발급"""
payload = decode_token(body.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 리프레시 토큰",
)
username = payload.get("sub")
result = await session.execute(
select(User).where(User.username == username, User.is_active.is_(True))
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유저를 찾을 수 없음",
)
return TokenResponse(
access_token=create_access_token(user.username),
refresh_token=create_refresh_token(user.username),
)
@router.get("/me", response_model=UserResponse)
async def get_me(user: Annotated[User, Depends(get_current_user)]):
"""현재 로그인한 유저 정보"""
return UserResponse(
id=user.id,
username=user.username,
is_active=user.is_active,
totp_enabled=bool(user.totp_secret),
last_login_at=user.last_login_at,
)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""비밀번호 변경"""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="현재 비밀번호가 올바르지 않습니다",
)
user.password_hash = hash_password(body.new_password)
await session.commit()
return {"message": "비밀번호가 변경되었습니다"}

231
app/api/setup.py Normal file
View File

@@ -0,0 +1,231 @@
"""첫 접속 셋업 위자드 API
유저가 0명일 때만 동작. 셋업 완료 후 자동 비활성화.
"""
import time
from pathlib import Path
from typing import Annotated
import pyotp
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import create_access_token, create_refresh_token, hash_password
from core.config import settings
from core.database import get_session
from models.user import User
router = APIRouter()
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
# ─── Rate Limiting (인메모리, 단일 프로세스) ───
_failed_attempts: dict[str, list[float]] = {}
RATE_LIMIT_MAX = 5
RATE_LIMIT_WINDOW = 300 # 5분
def _check_rate_limit(client_ip: str):
"""5분 내 5회 실패 시 차단"""
now = time.time()
attempts = _failed_attempts.get(client_ip, [])
# 윈도우 밖의 기록 제거
attempts = [t for t in attempts if now - t < RATE_LIMIT_WINDOW]
_failed_attempts[client_ip] = attempts
if len(attempts) >= RATE_LIMIT_MAX:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"너무 많은 시도입니다. {RATE_LIMIT_WINDOW // 60}분 후 다시 시도하세요.",
)
def _record_failure(client_ip: str):
_failed_attempts.setdefault(client_ip, []).append(time.time())
# ─── 헬퍼: 셋업 필요 여부 ───
async def _needs_setup(session: AsyncSession) -> bool:
result = await session.execute(select(func.count(User.id)))
return result.scalar() == 0
async def _require_setup(session: AsyncSession):
if not await _needs_setup(session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="셋업이 이미 완료되었습니다",
)
# ─── 스키마 ───
class SetupStatusResponse(BaseModel):
needs_setup: bool
class CreateAdminRequest(BaseModel):
username: str
password: str
class CreateAdminResponse(BaseModel):
message: str
access_token: str
refresh_token: str
class TOTPInitResponse(BaseModel):
secret: str
otpauth_uri: str
class TOTPVerifyRequest(BaseModel):
secret: str
code: str
class VerifyNASRequest(BaseModel):
path: str
class VerifyNASResponse(BaseModel):
exists: bool
readable: bool
writable: bool
path: str
# ─── 엔드포인트 ───
@router.get("/status", response_model=SetupStatusResponse)
async def setup_status(session: Annotated[AsyncSession, Depends(get_session)]):
"""셋업 필요 여부 확인"""
return SetupStatusResponse(needs_setup=await _needs_setup(session))
@router.post("/admin", response_model=CreateAdminResponse)
async def create_admin(
body: CreateAdminRequest,
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""관리자 계정 생성 (유저 0명일 때만)"""
await _require_setup(session)
client_ip = request.client.host if request.client else "unknown"
_check_rate_limit(client_ip)
# 유효성 검사
if len(body.username) < 2:
_record_failure(client_ip)
raise HTTPException(status_code=400, detail="아이디는 2자 이상이어야 합니다")
if len(body.password) < 8:
_record_failure(client_ip)
raise HTTPException(status_code=400, detail="비밀번호는 8자 이상이어야 합니다")
user = User(
username=body.username,
password_hash=hash_password(body.password),
is_active=True,
)
session.add(user)
await session.commit()
return CreateAdminResponse(
message=f"관리자 '{body.username}' 계정이 생성되었습니다",
access_token=create_access_token(body.username),
refresh_token=create_refresh_token(body.username),
)
@router.post("/totp/init", response_model=TOTPInitResponse)
async def totp_init(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""TOTP 시크릿 생성 + otpauth URI 반환 (DB에 저장하지 않음)"""
# 셋업 중이거나 인증된 유저만 사용 가능
# 셋업 중에는 admin 생성 직후 호출됨
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name="admin",
issuer_name="hyungi Document Server",
)
return TOTPInitResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/verify")
async def totp_verify(
body: TOTPVerifyRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""TOTP 코드 검증 후 DB에 시크릿 저장"""
# 코드 검증
totp = pyotp.TOTP(body.secret)
if not totp.verify(body.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TOTP 코드가 올바르지 않습니다. 다시 시도하세요.",
)
# 가장 최근 생성된 유저에 저장 (셋업 직후이므로 유저 1명)
result = await session.execute(
select(User).order_by(User.id.desc()).limit(1)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="유저를 찾을 수 없습니다")
user.totp_secret = body.secret
await session.commit()
return {"message": "TOTP 2FA가 활성화되었습니다"}
@router.post("/verify-nas", response_model=VerifyNASResponse)
async def verify_nas(body: VerifyNASRequest):
"""NAS 마운트 경로 읽기/쓰기 테스트"""
path = Path(body.path)
exists = path.exists()
readable = path.is_dir() and any(True for _ in path.iterdir()) if exists else False
writable = False
if exists:
test_file = path / ".pkm_write_test"
try:
test_file.write_text("test")
test_file.unlink()
writable = True
except OSError:
pass
return VerifyNASResponse(
exists=exists,
readable=readable,
writable=writable,
path=str(path),
)
@router.get("/", response_class=HTMLResponse)
async def setup_page(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""셋업 위자드 HTML 페이지"""
if not await _needs_setup(session):
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/docs")
return templates.TemplateResponse("setup.html", {"request": request})

View File

@@ -1,14 +1,21 @@
"""JWT + TOTP 2FA 인증"""
from datetime import datetime, timedelta, timezone
from typing import Annotated
import pyotp
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
from core.database import get_session
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
# JWT 설정
ALGORITHM = "HS256"
@@ -43,9 +50,37 @@ def decode_token(token: str) -> dict | None:
return None
def verify_totp(code: str) -> bool:
"""TOTP 코드 검증"""
if not settings.totp_secret:
def verify_totp(code: str, secret: str | None = None) -> bool:
"""TOTP 코드 검증 (유저별 secret 또는 글로벌 설정)"""
totp_secret = secret or settings.totp_secret
if not totp_secret:
return True # TOTP 미설정 시 스킵
totp = pyotp.TOTP(settings.totp_secret)
totp = pyotp.TOTP(totp_secret)
return totp.verify(code)
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Bearer 토큰에서 현재 유저 조회"""
from models.user import User
payload = decode_token(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰",
)
username = payload.get("sub")
result = await session.execute(
select(User).where(User.username == username, User.is_active.is_(True))
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유저를 찾을 수 없음",
)
return user

View File

@@ -53,8 +53,10 @@ def load_settings() -> Settings:
totp_secret = os.getenv("TOTP_SECRET", "")
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
# config.yaml
config_path = Path(__file__).parent.parent.parent / "config.yaml"
# config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트
config_path = Path("/app/config.yaml")
if not config_path.exists():
config_path = Path(__file__).parent.parent.parent / "config.yaml"
ai_config = None
nas_mount = "/documents"
nas_pkm = "/documents/PKM"

View File

@@ -21,11 +21,10 @@ class Base(DeclarativeBase):
async def init_db():
"""DB 연결 확인 (스키마는 migrations/로 관리)"""
from sqlalchemy import text
async with engine.begin() as conn:
# 연결 테스트
await conn.execute(
__import__("sqlalchemy").text("SELECT 1")
)
await conn.execute(text("SELECT 1"))
async def get_session() -> AsyncSession:

View File

@@ -2,21 +2,26 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select, text
from api.auth import router as auth_router
from api.setup import router as setup_router
from core.config import settings
from core.database import init_db
from core.database import async_session, engine, init_db
from models.user import User
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
# 시작: DB 연결, 스케줄러 등록
# 시작: DB 연결 확인
await init_db()
# TODO: APScheduler 시작 (Phase 3)
yield
# 종료: 리소스 정리
# TODO: 스케줄러 종료, DB 연결 해제
# 종료: DB 엔진 정리
await engine.dispose()
app = FastAPI(
@@ -26,16 +31,68 @@ app = FastAPI(
lifespan=lifespan,
)
# ─── 라우터 등록 ───
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
@app.get("/health")
async def health_check():
return {"status": "ok", "version": "2.0.0"}
# TODO: 라우터 등록 (Phase 0~2)
# from api import documents, search, tasks, dashboard, export
# TODO: Phase 2에서 추가
# app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
# app.include_router(search.router, prefix="/api/search", tags=["search"])
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
# app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"])
# app.include_router(export.router, prefix="/api/export", tags=["export"])
# ─── 셋업 미들웨어: 유저 0명이면 /setup으로 리다이렉트 ───
SETUP_BYPASS_PREFIXES = (
"/api/setup", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
)
@app.middleware("http")
async def setup_redirect_middleware(request: Request, call_next):
path = request.url.path
# 바이패스 경로는 항상 통과
if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
return await call_next(request)
# 유저 존재 여부 확인
try:
async with async_session() as session:
result = await session.execute(select(func.count(User.id)))
user_count = result.scalar()
if user_count == 0:
return RedirectResponse(url="/setup")
except Exception:
pass # DB 연결 실패 시 통과 (health에서 확인 가능)
return await call_next(request)
# ─── 셋업 페이지 라우트 (API가 아닌 HTML 페이지) ───
@app.get("/setup")
async def setup_page_redirect(request: Request):
"""셋업 위자드 페이지로 포워딩"""
from api.setup import setup_page
from core.database import get_session
async for session in get_session():
return await setup_page(request, session)
@app.get("/health")
async def health_check():
"""헬스체크 — DB 연결 상태 포함"""
db_ok = False
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
return {
"status": "ok" if db_ok else "degraded",
"version": "2.0.0",
"database": "connected" if db_ok else "disconnected",
}

22
app/models/user.py Normal file
View File

@@ -0,0 +1,22 @@
"""users 테이블 ORM"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret: Mapped[str | None] = mapped_column(String(64))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View File

@@ -14,3 +14,4 @@ apscheduler>=3.10.0
anthropic>=0.40.0
markdown>=3.5.0
python-multipart>=0.0.9
jinja2>=3.1.0

405
app/templates/setup.html Normal file
View File

@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hyungi Document Server — 초기 설정</title>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--text-dim: #8b8d98;
--accent: #6c8aff;
--accent-hover: #859dff;
--error: #f5564e;
--success: #4ade80;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 100%;
max-width: 480px;
padding: 2rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-dim);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.steps {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.step-dot {
width: 2.5rem;
height: 4px;
border-radius: 2px;
background: var(--border);
transition: background 0.3s;
}
.step-dot.active { background: var(--accent); }
.step-dot.done { background: var(--success); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 0.3rem;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 0.65rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: var(--accent); }
.btn {
display: inline-block;
padding: 0.65rem 1.5rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.2s;
width: 100%;
margin-top: 0.5rem;
}
.btn:hover { background: var(--accent-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-skip {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
margin-top: 0.5rem;
}
.btn-skip:hover { border-color: var(--text-dim); }
.error-msg {
color: var(--error);
font-size: 0.85rem;
margin-top: 0.5rem;
display: none;
}
.success-msg {
color: var(--success);
font-size: 0.85rem;
margin-top: 0.5rem;
display: none;
}
.qr-wrap {
display: flex;
justify-content: center;
margin: 1rem 0;
background: #fff;
border-radius: 8px;
padding: 1rem;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.secret-text {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-dim);
word-break: break-all;
text-align: center;
margin-bottom: 1rem;
}
.nas-result {
font-size: 0.85rem;
margin-top: 0.5rem;
}
.nas-result span { margin-right: 1rem; }
.check { color: var(--success); }
.cross { color: var(--error); }
.hidden { display: none; }
.done-icon {
font-size: 3rem;
text-align: center;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="container">
<h1>hyungi Document Server</h1>
<p class="subtitle">초기 설정 위자드</p>
<div class="steps">
<div class="step-dot active" id="dot-0"></div>
<div class="step-dot" id="dot-1"></div>
<div class="step-dot" id="dot-2"></div>
</div>
<!-- Step 0: 관리자 계정 -->
<div class="card" id="step-0">
<h2>1. 관리자 계정 생성</h2>
<div class="field">
<label for="username">아이디</label>
<input type="text" id="username" placeholder="admin" autocomplete="username">
</div>
<div class="field">
<label for="password">비밀번호 (8자 이상)</label>
<input type="password" id="password" autocomplete="new-password">
</div>
<div class="field">
<label for="password2">비밀번호 확인</label>
<input type="password" id="password2" autocomplete="new-password">
</div>
<div class="error-msg" id="admin-error"></div>
<button class="btn" onclick="createAdmin()">계정 생성</button>
</div>
<!-- Step 1: TOTP 2FA -->
<div class="card hidden" id="step-1">
<h2>2. 2단계 인증 (TOTP)</h2>
<p style="color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem;">
Google Authenticator 등 인증 앱으로 QR 코드를 스캔하세요.
</p>
<div class="qr-wrap" id="qr-container"></div>
<p class="secret-text" id="totp-secret-text"></p>
<div class="field">
<label for="totp-code">인증 코드 6자리</label>
<input type="text" id="totp-code" maxlength="6" placeholder="000000" inputmode="numeric" pattern="[0-9]*">
</div>
<div class="error-msg" id="totp-error"></div>
<div class="success-msg" id="totp-success"></div>
<button class="btn" onclick="verifyTOTP()">인증 확인</button>
<button class="btn btn-skip" onclick="skipTOTP()">건너뛰기</button>
</div>
<!-- Step 2: NAS 경로 확인 -->
<div class="card hidden" id="step-2">
<h2>3. NAS 저장소 경로 확인</h2>
<div class="field">
<label for="nas-path">NAS 마운트 경로</label>
<input type="text" id="nas-path" value="/documents">
</div>
<div class="nas-result hidden" id="nas-result"></div>
<div class="error-msg" id="nas-error"></div>
<button class="btn" onclick="verifyNAS()">경로 확인</button>
<button class="btn btn-skip" onclick="finishSetup()">건너뛰기</button>
</div>
<!-- Step 3: 완료 -->
<div class="card hidden" id="step-3">
<div class="done-icon">&#10003;</div>
<h2 style="text-align:center;">설정 완료</h2>
<p style="color: var(--text-dim); text-align: center; margin: 1rem 0;">
관리자 계정이 생성되었습니다. API 문서에서 엔드포인트를 확인하세요.
</p>
<button class="btn" onclick="location.href='/docs'">API 문서 열기</button>
</div>
</div>
<script>
const API = '/api/setup';
let currentStep = 0;
let authToken = '';
let totpSecret = '';
function showStep(n) {
for (let i = 0; i < 4; i++) {
const el = document.getElementById('step-' + i);
if (el) el.classList.toggle('hidden', i !== n);
}
for (let i = 0; i < 3; i++) {
const dot = document.getElementById('dot-' + i);
dot.classList.remove('active', 'done');
if (i < n) dot.classList.add('done');
else if (i === n) dot.classList.add('active');
}
currentStep = n;
}
function showError(id, msg) {
const el = document.getElementById(id);
el.textContent = msg;
el.style.display = 'block';
}
function hideError(id) {
document.getElementById(id).style.display = 'none';
}
async function createAdmin() {
hideError('admin-error');
const username = document.getElementById('username').value.trim() || 'admin';
const password = document.getElementById('password').value;
const password2 = document.getElementById('password2').value;
if (password !== password2) {
showError('admin-error', '비밀번호가 일치하지 않습니다');
return;
}
if (password.length < 8) {
showError('admin-error', '비밀번호는 8자 이상이어야 합니다');
return;
}
try {
const res = await fetch(API + '/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
showError('admin-error', data.detail || '계정 생성 실패');
return;
}
authToken = data.access_token;
await initTOTP();
showStep(1);
} catch (e) {
showError('admin-error', '서버 연결 실패: ' + e.message);
}
}
async function initTOTP() {
try {
const res = await fetch(API + '/totp/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + authToken,
},
});
const data = await res.json();
totpSecret = data.secret;
document.getElementById('totp-secret-text').textContent = '수동 입력: ' + data.secret;
const container = document.getElementById('qr-container');
container.innerHTML = '';
QRCode.toCanvas(document.createElement('canvas'), data.otpauth_uri, {
width: 200,
margin: 0,
}, function(err, canvas) {
if (!err) container.appendChild(canvas);
});
} catch (e) {
console.error('TOTP init failed:', e);
}
}
async function verifyTOTP() {
hideError('totp-error');
const code = document.getElementById('totp-code').value.trim();
if (code.length !== 6) {
showError('totp-error', '6자리 코드를 입력하세요');
return;
}
try {
const res = await fetch(API + '/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: totpSecret, code }),
});
const data = await res.json();
if (!res.ok) {
showError('totp-error', data.detail || 'TOTP 검증 실패');
return;
}
const el = document.getElementById('totp-success');
el.textContent = '2단계 인증이 활성화되었습니다';
el.style.display = 'block';
setTimeout(() => showStep(2), 1000);
} catch (e) {
showError('totp-error', '서버 연결 실패');
}
}
function skipTOTP() {
showStep(2);
}
async function verifyNAS() {
hideError('nas-error');
const path = document.getElementById('nas-path').value.trim();
if (!path) {
showError('nas-error', '경로를 입력하세요');
return;
}
try {
const res = await fetch(API + '/verify-nas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) {
showError('nas-error', data.detail || '경로 확인 실패');
return;
}
const result = document.getElementById('nas-result');
result.innerHTML = `
<span class="${data.exists ? 'check' : 'cross'}">${data.exists ? '&#10003;' : '&#10007;'} 존재</span>
<span class="${data.readable ? 'check' : 'cross'}">${data.readable ? '&#10003;' : '&#10007;'} 읽기</span>
<span class="${data.writable ? 'check' : 'cross'}">${data.writable ? '&#10003;' : '&#10007;'} 쓰기</span>
`;
result.classList.remove('hidden');
if (data.exists && data.readable) {
setTimeout(() => finishSetup(), 1500);
}
} catch (e) {
showError('nas-error', '서버 연결 실패');
}
}
function finishSetup() {
showStep(3);
}
// 초기화: 이미 셋업 완료 상태인지 확인
(async () => {
try {
const res = await fetch(API + '/status');
const data = await res.json();
if (!data.needs_setup) {
location.href = '/docs';
}
} catch (e) {
// 서버 연결 실패 시 그냥 위자드 표시
}
})();
</script>
</body>
</html>