feat: implement Phase 4 SvelteKit frontend + backend enhancements
Backend: - Add dashboard API (today stats, inbox count, law alerts, pipeline status) - Add /api/documents/tree endpoint for sidebar domain/sub_group tree - Migrate auth to HttpOnly cookie for refresh token (XSS defense) - Add /api/auth/logout endpoint (cookie cleanup) - Register dashboard router in main.py Frontend (SvelteKit + Tailwind CSS v4): - api.ts: fetch wrapper with refresh queue pattern, 401 single retry, forced logout on refresh failure - Auth store: login/logout/refresh with memory-based access token - UI store: toast system, sidebar state - Login page with TOTP support - Dashboard with 4 stat widgets + recent documents - Document list with hybrid search (debounce, URL query state, mode select) - Document detail with format-aware viewer (markdown/PDF/HWP/Synology/fallback) - Metadata panel (AI summary, tags, processing history) - Inbox triage UI (batch select, confirm dialog, domain override) - Settings page (password change, TOTP status) Infrastructure: - Enable frontend service in docker-compose - Caddy path routing (/api/* → fastapi, / → frontend) + gzip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
"""인증 API — 로그인, 토큰 갱신, TOTP 검증"""
|
||||
"""인증 API — 로그인, 토큰 갱신, TOTP 검증
|
||||
|
||||
access token: 응답 body (프론트에서 메모리 보관)
|
||||
refresh token: HttpOnly cookie (XSS 방어)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import (
|
||||
REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
@@ -32,16 +37,11 @@ class LoginRequest(BaseModel):
|
||||
totp_code: str | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
class AccessTokenResponse(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
|
||||
@@ -58,15 +58,31 @@ class UserResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ─── 헬퍼 ───
|
||||
|
||||
def _set_refresh_cookie(response: Response, token: str):
|
||||
"""refresh token을 HttpOnly cookie로 설정"""
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 86400,
|
||||
path="/api/auth",
|
||||
)
|
||||
|
||||
|
||||
# ─── 엔드포인트 ───
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@router.post("/login", response_model=AccessTokenResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
response: Response,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""로그인 → JWT 발급"""
|
||||
"""로그인 → access token(body) + refresh token(cookie)"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
@@ -101,19 +117,28 @@ async def login(
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return TokenResponse(
|
||||
# refresh token → HttpOnly cookie
|
||||
_set_refresh_cookie(response, create_refresh_token(user.username))
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=create_access_token(user.username),
|
||||
refresh_token=create_refresh_token(user.username),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
@router.post("/refresh", response_model=AccessTokenResponse)
|
||||
async def refresh_token(
|
||||
body: RefreshRequest,
|
||||
response: Response,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
refresh_token: str | None = Cookie(None),
|
||||
):
|
||||
"""리프레시 토큰으로 새 토큰 쌍 발급"""
|
||||
payload = decode_token(body.refresh_token)
|
||||
"""cookie의 refresh token으로 새 토큰 쌍 발급"""
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="리프레시 토큰이 없습니다",
|
||||
)
|
||||
|
||||
payload = decode_token(refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -131,12 +156,21 @@ async def refresh_token(
|
||||
detail="유저를 찾을 수 없음",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
# 새 refresh token → cookie
|
||||
_set_refresh_cookie(response, create_refresh_token(user.username))
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=create_access_token(user.username),
|
||||
refresh_token=create_refresh_token(user.username),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
"""로그아웃 — refresh cookie 삭제"""
|
||||
response.delete_cookie("refresh_token", path="/api/auth")
|
||||
return {"message": "로그아웃 완료"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
"""현재 로그인한 유저 정보"""
|
||||
|
||||
Reference in New Issue
Block a user