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:
167
app/api/auth.py
Normal file
167
app/api/auth.py
Normal 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
231
app/api/setup.py
Normal 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})
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
81
app/main.py
81
app/main.py
@@ -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
22
app/models/user.py
Normal 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))
|
||||
@@ -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
405
app/templates/setup.html
Normal 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">✓</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 ? '✓' : '✗'} 존재</span>
|
||||
<span class="${data.readable ? 'check' : 'cross'}">${data.readable ? '✓' : '✗'} 읽기</span>
|
||||
<span class="${data.writable ? 'check' : 'cross'}">${data.writable ? '✓' : '✗'} 쓰기</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>
|
||||
Reference in New Issue
Block a user