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})
|
||||
Reference in New Issue
Block a user