Files
hyungi_document_server/app/api/auth.py
Hyungi Ahn a601991f48 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>
2026-04-02 13:21:45 +09:00

168 lines
4.6 KiB
Python

"""인증 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": "비밀번호가 변경되었습니다"}