diff --git a/Caddyfile b/Caddyfile index 6102d7e..88b46c3 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,22 @@ pkm.hyungi.net { - reverse_proxy fastapi:8000 + # API + OpenAPI 문서 + handle /api/* { + reverse_proxy fastapi:8000 + } + handle /docs { + reverse_proxy fastapi:8000 + } + handle /openapi.json { + reverse_proxy fastapi:8000 + } + handle /health { + reverse_proxy fastapi:8000 + } + + # 프론트엔드 + handle { + reverse_proxy frontend:3000 + } } # Synology Office 프록시 diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..8c68b8a --- /dev/null +++ b/app/api/auth.py @@ -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": "비밀번호가 변경되었습니다"} diff --git a/app/api/setup.py b/app/api/setup.py new file mode 100644 index 0000000..ac2c8a9 --- /dev/null +++ b/app/api/setup.py @@ -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}) diff --git a/app/core/auth.py b/app/core/auth.py index fd390dd..b60c6f6 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -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 diff --git a/app/core/config.py b/app/core/config.py index 6017693..1a0d076 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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" diff --git a/app/core/database.py b/app/core/database.py index 5ef068a..a1da05d 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -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: diff --git a/app/main.py b/app/main.py index 76c778e..be36b71 100644 --- a/app/main.py +++ b/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", + } diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..9f415ef --- /dev/null +++ b/app/models/user.py @@ -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)) diff --git a/app/requirements.txt b/app/requirements.txt index 2d1d06e..e151703 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -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 diff --git a/app/templates/setup.html b/app/templates/setup.html new file mode 100644 index 0000000..ef69e66 --- /dev/null +++ b/app/templates/setup.html @@ -0,0 +1,405 @@ + + +
+ + +초기 설정 위자드
+ +